feat(watch-connectivity): add WearOS support alongside Apple Watch
This commit is contained in:
@@ -36,7 +36,8 @@
|
||||
"@bacons/apple-targets",
|
||||
["expo-localization", {
|
||||
"supportedLocales": ["en", "nl", "de", "fr", "it", "es", "pl", "pt", "dk"]
|
||||
}]
|
||||
}],
|
||||
"./modules/wear-connectivity"
|
||||
],
|
||||
"extra": {
|
||||
"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>
|
||||
+145
@@ -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
|
||||
}
|
||||
}
|
||||
+11
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "wear-connectivity",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
@@ -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 { 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<SyncResult> => {
|
||||
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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 () => {},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package nl.guidoit.swimbuddy.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.wear.compose.material.Button
|
||||
import androidx.wear.compose.material.ButtonDefaults
|
||||
import androidx.wear.compose.material.MaterialTheme
|
||||
import androidx.wear.compose.material.Text
|
||||
import nl.guidoit.swimbuddy.R
|
||||
import nl.guidoit.swimbuddy.data.StoredWorkout
|
||||
|
||||
@Composable
|
||||
fun ActiveWorkoutScreen(
|
||||
workout: StoredWorkout?,
|
||||
currentSetIndex: Int,
|
||||
currentRep: Int,
|
||||
elapsedSeconds: Float,
|
||||
heartRate: Int,
|
||||
calories: Float,
|
||||
lapDetected: Boolean,
|
||||
totalDistanceSwum: Int,
|
||||
currentSetDistanceSwum: Int,
|
||||
currentSetTotalDistance: Int,
|
||||
onCompleteLap: () -> Unit,
|
||||
onEndWorkout: () -> Unit
|
||||
) {
|
||||
val currentSet = workout?.sets?.getOrNull(currentSetIndex)
|
||||
val mins = (elapsedSeconds / 60).toInt()
|
||||
val secs = (elapsedSeconds % 60).toInt()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.set_format, currentSetIndex + 1, workout?.sets?.size ?: 0),
|
||||
style = MaterialTheme.typography.caption2,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "·",
|
||||
style = MaterialTheme.typography.caption2,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.rep_format, currentRep + 1, currentSet?.repCount ?: 0),
|
||||
style = MaterialTheme.typography.caption2,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = currentSet?.stroke ?: "",
|
||||
style = MaterialTheme.typography.headline3,
|
||||
color = Color.Cyan
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "${currentSetDistanceSwum}/${currentSetTotalDistance}m",
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Cyan
|
||||
)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "\uD83C\uDFCA",
|
||||
fontSize = 14.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.total, totalDistanceSwum),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = String.format("%d:%02d", mins, secs),
|
||||
style = MaterialTheme.typography.title3,
|
||||
fontFamily = MaterialTheme.typography.title3.fontFamily
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "\uD83D\uDD25",
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = "${calories.toInt()}",
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
if (heartRate > 0) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "\u2764\uFE0F",
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = "$heartRate",
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lapDetected) {
|
||||
Text(
|
||||
text = stringResource(R.string.lap_detected),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = Color.Green
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onCompleteLap,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = Color.Cyan
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.complete_lap),
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.end),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
color = Color.Red,
|
||||
style = MaterialTheme.typography.caption3,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package nl.guidoit.swimbuddy.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.wear.compose.material.MaterialTheme
|
||||
import androidx.wear.compose.material.Text
|
||||
import nl.guidoit.swimbuddy.R
|
||||
import nl.guidoit.swimbuddy.data.StoredSet
|
||||
|
||||
@Composable
|
||||
fun RestScreen(
|
||||
restTimeRemaining: Float,
|
||||
totalDistanceSwum: Int,
|
||||
calories: Float,
|
||||
nextSet: StoredSet?
|
||||
) {
|
||||
val mins = (restTimeRemaining / 60).toInt()
|
||||
val secs = (restTimeRemaining % 60).toInt()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.rest),
|
||||
style = MaterialTheme.typography.headline3,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = String.format("%d:%02d", mins, secs),
|
||||
fontSize = 44.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.Cyan
|
||||
)
|
||||
|
||||
if (nextSet != null) {
|
||||
Text(
|
||||
text = stringResource(R.string.next_format, nextSet.stroke, nextSet.distancePerRep),
|
||||
style = MaterialTheme.typography.caption,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "\uD83C\uDFCA",
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = "${totalDistanceSwum}m",
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "\uD83D\uDD25",
|
||||
fontSize = 12.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(
|
||||
text = "${calories.toInt()}kcal",
|
||||
style = MaterialTheme.typography.caption
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package nl.guidoit.swimbuddy.presentation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import nl.guidoit.swimbuddy.data.StoredWorkout
|
||||
import nl.guidoit.swimbuddy.data.WorkoutSummary
|
||||
|
||||
@Composable
|
||||
fun SwimBuddyApp(
|
||||
savedWorkouts: List<StoredWorkout>,
|
||||
completedWorkouts: List<StoredWorkout>,
|
||||
selectedWorkout: StoredWorkout?,
|
||||
isWorkoutActive: Boolean,
|
||||
currentSetIndex: Int,
|
||||
currentRep: Int,
|
||||
elapsedSeconds: Float,
|
||||
heartRate: Int,
|
||||
calories: Float,
|
||||
lapDetected: Boolean,
|
||||
isResting: Boolean,
|
||||
restTimeRemaining: Float,
|
||||
workoutSummary: WorkoutSummary?,
|
||||
totalDistanceSwum: Int,
|
||||
currentSetDistanceSwum: Int,
|
||||
currentSetTotalDistance: Int,
|
||||
onSelectWorkout: (StoredWorkout) -> Unit,
|
||||
onStartWorkout: () -> Unit,
|
||||
onCompleteLap: () -> Unit,
|
||||
onEndWorkout: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onDeleteSaved: (StoredWorkout) -> Unit,
|
||||
onDeleteCompleted: (StoredWorkout) -> Unit,
|
||||
onDismissSummary: () -> Unit
|
||||
) {
|
||||
when {
|
||||
workoutSummary != null -> {
|
||||
WorkoutSummaryScreen(
|
||||
summary = workoutSummary,
|
||||
onDismiss = onDismissSummary
|
||||
)
|
||||
}
|
||||
isWorkoutActive && isResting -> {
|
||||
RestScreen(
|
||||
restTimeRemaining = restTimeRemaining,
|
||||
totalDistanceSwum = totalDistanceSwum,
|
||||
calories = calories,
|
||||
nextSet = selectedWorkout?.sets?.getOrNull(currentSetIndex)
|
||||
)
|
||||
}
|
||||
isWorkoutActive -> {
|
||||
ActiveWorkoutScreen(
|
||||
workout = selectedWorkout,
|
||||
currentSetIndex = currentSetIndex,
|
||||
currentRep = currentRep,
|
||||
elapsedSeconds = elapsedSeconds,
|
||||
heartRate = heartRate,
|
||||
calories = calories,
|
||||
lapDetected = lapDetected,
|
||||
totalDistanceSwum = totalDistanceSwum,
|
||||
currentSetDistanceSwum = currentSetDistanceSwum,
|
||||
currentSetTotalDistance = currentSetTotalDistance,
|
||||
onCompleteLap = onCompleteLap,
|
||||
onEndWorkout = onEndWorkout
|
||||
)
|
||||
}
|
||||
selectedWorkout != null -> {
|
||||
WorkoutDetailScreen(
|
||||
workout = selectedWorkout,
|
||||
onStart = onStartWorkout,
|
||||
onBack = onBack
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
WorkoutListScreen(
|
||||
savedWorkouts = savedWorkouts,
|
||||
completedWorkouts = completedWorkouts,
|
||||
onSelectWorkout = onSelectWorkout,
|
||||
onDeleteSaved = onDeleteSaved,
|
||||
onDeleteCompleted = onDeleteCompleted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package nl.guidoit.swimbuddy.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.wear.compose.material.Button
|
||||
import androidx.wear.compose.material.ButtonDefaults
|
||||
import androidx.wear.compose.material.MaterialTheme
|
||||
import androidx.wear.compose.material.Text
|
||||
import androidx.wear.compose.material.ListColumn
|
||||
import nl.guidoit.swimbuddy.R
|
||||
import nl.guidoit.swimbuddy.data.StoredWorkout
|
||||
|
||||
@Composable
|
||||
fun WorkoutDetailScreen(
|
||||
workout: StoredWorkout,
|
||||
onStart: () -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
ListColumn(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = workout.name ?: stringResource(R.string.workout),
|
||||
style = MaterialTheme.typography.title3
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.pool, workout.poolLength.toInt()),
|
||||
style = MaterialTheme.typography.caption3,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.total, workout.sets.sumOf { it.distancePerRep * it.repCount }),
|
||||
style = MaterialTheme.typography.caption3,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.sets),
|
||||
style = MaterialTheme.typography.title3
|
||||
)
|
||||
}
|
||||
items(workout.sets.size) { index ->
|
||||
val set = workout.sets[index]
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "${index + 1}. ${set.stroke}",
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
Text(
|
||||
text = "${set.repCount}x${set.distancePerRep}m",
|
||||
style = MaterialTheme.typography.caption3,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = onStart,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = Color.Cyan
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.start),
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.back),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.caption3,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package nl.guidoit.swimbuddy.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.wear.compose.material.MaterialTheme
|
||||
import androidx.wear.compose.material.Text
|
||||
import androidx.wear.compose.material.ListColumn
|
||||
import androidx.wear.compose.material.Card
|
||||
import androidx.wear.compose.material.PositionedCircularButton
|
||||
import androidx.wear.compose.material.ButtonDefaults
|
||||
import androidx.wear.compose.material.Icon
|
||||
import androidx.wear.compose.material.icons.Icons
|
||||
import androidx.wear.compose.material.icons.filled.Check
|
||||
import nl.guidoit.swimbuddy.R
|
||||
import nl.guidoit.swimbuddy.data.StoredWorkout
|
||||
|
||||
@Composable
|
||||
fun WorkoutListScreen(
|
||||
savedWorkouts: List<StoredWorkout>,
|
||||
completedWorkouts: List<StoredWorkout>,
|
||||
onSelectWorkout: (StoredWorkout) -> Unit,
|
||||
onDeleteSaved: (StoredWorkout) -> Unit,
|
||||
onDeleteCompleted: (StoredWorkout) -> Unit
|
||||
) {
|
||||
if (savedWorkouts.isEmpty() && completedWorkouts.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_workouts),
|
||||
style = MaterialTheme.typography.body1,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.create_on_phone),
|
||||
style = MaterialTheme.typography.caption3,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ListColumn {
|
||||
if (savedWorkouts.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.saved_workouts),
|
||||
style = MaterialTheme.typography.title3
|
||||
)
|
||||
}
|
||||
items(savedWorkouts.size) { index ->
|
||||
val workout = savedWorkouts[index]
|
||||
Card(onClick = { onSelectWorkout(workout) }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = workout.name ?: stringResource(R.string.workout),
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
Text(
|
||||
text = "${workout.sets.sumOf { it.distancePerRep * it.repCount }}m · ${workout.sets.size} sets",
|
||||
style = MaterialTheme.typography.caption3
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (completedWorkouts.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.recent),
|
||||
style = MaterialTheme.typography.title3
|
||||
)
|
||||
}
|
||||
items(completedWorkouts.size.coerceAtMost(3)) { index ->
|
||||
val workout = completedWorkouts[index]
|
||||
Card(onClick = { }) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = workout.name ?: stringResource(R.string.workout),
|
||||
style = MaterialTheme.typography.body1
|
||||
)
|
||||
Text(
|
||||
text = workout.completedAt ?: "",
|
||||
style = MaterialTheme.typography.caption3
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = stringResource(R.string.completed),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package nl.guidoit.swimbuddy.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.wear.compose.material.Button
|
||||
import androidx.wear.compose.material.ButtonDefaults
|
||||
import androidx.wear.compose.material.MaterialTheme
|
||||
import androidx.wear.compose.material.Text
|
||||
import nl.guidoit.swimbuddy.R
|
||||
import nl.guidoit.swimbuddy.data.WorkoutSummary
|
||||
|
||||
@Composable
|
||||
fun WorkoutSummaryScreen(
|
||||
summary: WorkoutSummary,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val mins = (summary.duration / 60).toInt()
|
||||
val secs = (summary.duration % 60).toInt()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Text(
|
||||
text = "\u2705",
|
||||
fontSize = 32.sp
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.workout_complete),
|
||||
style = MaterialTheme.typography.headline3,
|
||||
color = Color.Green
|
||||
)
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(R.string.total_time),
|
||||
style = MaterialTheme.typography.caption3,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
Text(
|
||||
text = String.format("%d:%02d", mins, secs),
|
||||
style = MaterialTheme.typography.body1,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(R.string.total_distance_label),
|
||||
style = MaterialTheme.typography.caption3,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
Text(
|
||||
text = "${summary.totalDistance}m",
|
||||
style = MaterialTheme.typography.body1,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(R.string.calories_label),
|
||||
style = MaterialTheme.typography.caption3,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
Text(
|
||||
text = "${summary.calories.toInt()}kcal",
|
||||
style = MaterialTheme.typography.body1,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = stringResource(R.string.laps_label),
|
||||
style = MaterialTheme.typography.caption3,
|
||||
color = MaterialTheme.colors.onSecondary.copy(alpha = 0.7f)
|
||||
)
|
||||
Text(
|
||||
text = "${summary.laps.size}",
|
||||
style = MaterialTheme.typography.body1,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
backgroundColor = Color.Cyan
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.ok),
|
||||
color = Color.Black
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SwimBuddy</string>
|
||||
<string name="no_workouts">Ingen træninger endnu</string>
|
||||
<string name="create_on_phone">Opret en træning på din telefon</string>
|
||||
<string name="saved_workouts">Gemte træninger</string>
|
||||
<string name="recent">Seneste</string>
|
||||
<string name="completed">Afsluttet</string>
|
||||
<string name="sets">Sæt</string>
|
||||
<string name="start">Start</string>
|
||||
<string name="back">Tilbage</string>
|
||||
<string name="complete_lap">Afslut omgang</string>
|
||||
<string name="end">Slut</string>
|
||||
<string name="rest">Hvile</string>
|
||||
<string name="lap_detected">Omgang registreret!</string>
|
||||
<string name="workout">Træning</string>
|
||||
<string name="pool">%dm pool</string>
|
||||
<string name="total">%dm total</string>
|
||||
<string name="kcal">%dkcal</string>
|
||||
<string name="set_format">Sæt %1$d/%2$d</string>
|
||||
<string name="rep_format">Gentagelse %1$d/%2$d</string>
|
||||
<string name="next_format">Næste: %1$s %2$dm</string>
|
||||
<string name="workout_complete">Træning afsluttet!</string>
|
||||
<string name="total_time">Samlet tid</string>
|
||||
<string name="total_distance_label">Distance</string>
|
||||
<string name="calories_label">Kalorier</string>
|
||||
<string name="laps_label">Omgange</string>
|
||||
<string name="tap_to_dismiss">Tryk for at lukke</string>
|
||||
<string name="ok">OK</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SwimBuddy</string>
|
||||
<string name="no_workouts">Noch keine Trainings</string>
|
||||
<string name="create_on_phone">Erstelle ein Training auf deinem Telefon</string>
|
||||
<string name="saved_workouts">Gespeicherte Trainings</string>
|
||||
<string name="recent">Zuletzt</string>
|
||||
<string name="completed">Abgeschlossen</string>
|
||||
<string name="sets">Sätze</string>
|
||||
<string name="start">Starten</string>
|
||||
<string name="back">Zurück</string>
|
||||
<string name="complete_lap">Runde abschließen</string>
|
||||
<string name="end">Ende</string>
|
||||
<string name="rest">Pause</string>
|
||||
<string name="lap_detected">Runde erkannt!</string>
|
||||
<string name="workout">Training</string>
|
||||
<string name="pool">%dm Becken</string>
|
||||
<string name="total">%dm gesamt</string>
|
||||
<string name="kcal">%dkcal</string>
|
||||
<string name="set_format">Satz %1$d/%2$d</string>
|
||||
<string name="rep_format">Wiederholung %1$d/%2$d</string>
|
||||
<string name="next_format">Nächste: %1$s %2$dm</string>
|
||||
<string name="workout_complete">Training abgeschlossen!</string>
|
||||
<string name="total_time">Gesamtzeit</string>
|
||||
<string name="total_distance_label">Distanz</string>
|
||||
<string name="calories_label">Kalorien</string>
|
||||
<string name="laps_label">Runden</string>
|
||||
<string name="tap_to_dismiss">Tippen zum Schließen</string>
|
||||
<string name="ok">OK</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SwimBuddy</string>
|
||||
<string name="no_workouts">Ningún entrenamiento</string>
|
||||
<string name="create_on_phone">Crea un entrenamiento en tu teléfono</string>
|
||||
<string name="saved_workouts">Entrenamientos guardados</string>
|
||||
<string name="recent">Recientes</string>
|
||||
<string name="completed">Completado</string>
|
||||
<string name="sets">Series</string>
|
||||
<string name="start">Iniciar</string>
|
||||
<string name="back">Volver</string>
|
||||
<string name="complete_lap">Completar vuelta</string>
|
||||
<string name="end">Fin</string>
|
||||
<string name="rest">Descanso</string>
|
||||
<string name="lap_detected">¡Vuelta detectada!</string>
|
||||
<string name="workout">Entrenamiento</string>
|
||||
<string name="pool">%dm piscina</string>
|
||||
<string name="total">%dm total</string>
|
||||
<string name="kcal">%dkcal</string>
|
||||
<string name="set_format">Serie %1$d/%2$d</string>
|
||||
<string name="rep_format">Rep %1$d/%2$d</string>
|
||||
<string name="next_format">Siguiente: %1$s %2$dm</string>
|
||||
<string name="workout_complete">¡Entrenamiento completado!</string>
|
||||
<string name="total_time">Tiempo total</string>
|
||||
<string name="total_distance_label">Distancia</string>
|
||||
<string name="calories_label">Calorías</string>
|
||||
<string name="laps_label">Vueltas</string>
|
||||
<string name="tap_to_dismiss">Toca para cerrar</string>
|
||||
<string name="ok">OK</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SwimBuddy</string>
|
||||
<string name="no_workouts">Aucun entraînement</string>
|
||||
<string name="create_on_phone">Créez un entraînement sur votre téléphone</string>
|
||||
<string name="saved_workouts">Entraînements enregistrés</string>
|
||||
<string name="recent">Récent</string>
|
||||
<string name="completed">Terminé</string>
|
||||
<string name="sets">Séries</string>
|
||||
<string name="start">Démarrer</string>
|
||||
<string name="back">Retour</string>
|
||||
<string name="complete_lap">Terminer le tour</string>
|
||||
<string name="end">Fin</string>
|
||||
<string name="rest">Repos</string>
|
||||
<string name="lap_detected">Tour détecté !</string>
|
||||
<string name="workout">Entraînement</string>
|
||||
<string name="pool">%dm bassin</string>
|
||||
<string name="total">%dm total</string>
|
||||
<string name="kcal">%dkcal</string>
|
||||
<string name="set_format">Série %1$d/%2$d</string>
|
||||
<string name="rep_format">Répétition %1$d/%2$d</string>
|
||||
<string name="next_format">Prochain : %1$s %2$dm</string>
|
||||
<string name="workout_complete">Entraînement terminé !</string>
|
||||
<string name="total_time">Durée totale</string>
|
||||
<string name="total_distance_label">Distance</string>
|
||||
<string name="calories_label">Calories</string>
|
||||
<string name="laps_label">Tours</string>
|
||||
<string name="tap_to_dismiss">Appuyez pour fermer</string>
|
||||
<string name="ok">OK</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SwimBuddy</string>
|
||||
<string name="no_workouts">Nessun allenamento</string>
|
||||
<string name="create_on_phone">Crea un allenamento sul tuo telefono</string>
|
||||
<string name="saved_workouts">Allenamenti salvati</string>
|
||||
<string name="recent">Recenti</string>
|
||||
<string name="completed">Completato</string>
|
||||
<string name="sets">Serie</string>
|
||||
<string name="start">Inizia</string>
|
||||
<string name="back">Indietro</string>
|
||||
<string name="complete_lap">Completa il giro</string>
|
||||
<string name="end">Fine</string>
|
||||
<string name="rest">Riposo</string>
|
||||
<string name="lap_detected">Giro rilevato!</string>
|
||||
<string name="workout">Allenamento</string>
|
||||
<string name="pool">%dm vasca</string>
|
||||
<string name="total">%dm totale</string>
|
||||
<string name="kcal">%dkcal</string>
|
||||
<string name="set_format">Serie %1$d/%2$d</string>
|
||||
<string name="rep_format">Rep %1$d/%2$d</string>
|
||||
<string name="next_format">Prossimo: %1$s %2$dm</string>
|
||||
<string name="workout_complete">Allenamento completato!</string>
|
||||
<string name="total_time">Tempo totale</string>
|
||||
<string name="total_distance_label">Distanza</string>
|
||||
<string name="calories_label">Calorie</string>
|
||||
<string name="laps_label">Giri</string>
|
||||
<string name="tap_to_dismiss">Tocca per chiudere</string>
|
||||
<string name="ok">OK</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SwimBuddy</string>
|
||||
<string name="no_workouts">Nog geen trainingen</string>
|
||||
<string name="create_on_phone">Maak een training aan op je telefoon</string>
|
||||
<string name="saved_workouts">Opgeslagen trainingen</string>
|
||||
<string name="recent">Recent</string>
|
||||
<string name="completed">Voltooid</string>
|
||||
<string name="sets">Sets</string>
|
||||
<string name="start">Start</string>
|
||||
<string name="back">Terug</string>
|
||||
<string name="complete_lap">Ronde voltooien</string>
|
||||
<string name="end">Einde</string>
|
||||
<string name="rest">Rust</string>
|
||||
<string name="lap_detected">Ronde gedetecteerd!</string>
|
||||
<string name="workout">Training</string>
|
||||
<string name="pool">%dm bad</string>
|
||||
<string name="total">%dm totaal</string>
|
||||
<string name="kcal">%dkcal</string>
|
||||
<string name="set_format">Set %1$d/%2$d</string>
|
||||
<string name="rep_format">Herhaling %1$d/%2$d</string>
|
||||
<string name="next_format">Volgende: %1$s %2$dm</string>
|
||||
<string name="workout_complete">Training voltooid!</string>
|
||||
<string name="total_time">Totale tijd</string>
|
||||
<string name="total_distance_label">Afstand</string>
|
||||
<string name="calories_label">Calorieën</string>
|
||||
<string name="laps_label">Rondes</string>
|
||||
<string name="tap_to_dismiss">Tik om te sluiten</string>
|
||||
<string name="ok">OK</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SwimBuddy</string>
|
||||
<string name="no_workouts">Brak treningów</string>
|
||||
<string name="create_on_phone">Utwórz trening na telefonie</string>
|
||||
<string name="saved_workouts">Zapisane treningi</string>
|
||||
<string name="recent">Ostatnie</string>
|
||||
<string name="completed">Ukończono</string>
|
||||
<string name="sets">Serie</string>
|
||||
<string name="start">Start</string>
|
||||
<string name="back">Wstecz</string>
|
||||
<string name="complete_lap">Zakończ okrążenie</string>
|
||||
<string name="end">Koniec</string>
|
||||
<string name="rest">Odpoczynek</string>
|
||||
<string name="lap_detected">Wykryto okrążenie!</string>
|
||||
<string name="workout">Trening</string>
|
||||
<string name="pool">%dm basen</string>
|
||||
<string name="total">%dm łącznie</string>
|
||||
<string name="kcal">%dkcal</string>
|
||||
<string name="set_format">Serie %1$d/%2$d</string>
|
||||
<string name="rep_format">Powtórzenie %1$d/%2$d</string>
|
||||
<string name="next_format">Następne: %1$s %2$dm</string>
|
||||
<string name="workout_complete">Trening zakończony!</string>
|
||||
<string name="total_time">Czas łączny</string>
|
||||
<string name="total_distance_label">Dystans</string>
|
||||
<string name="calories_label">Kalorie</string>
|
||||
<string name="laps_label">Okrążenia</string>
|
||||
<string name="tap_to_dismiss">Stuknij aby zamknąć</string>
|
||||
<string name="ok">OK</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SwimBuddy</string>
|
||||
<string name="no_workouts">Nenhum treino</string>
|
||||
<string name="create_on_phone">Crie um treino no seu telefone</string>
|
||||
<string name="saved_workouts">Treinos salvos</string>
|
||||
<string name="recent">Recentes</string>
|
||||
<string name="completed">Concluído</string>
|
||||
<string name="sets">Séries</string>
|
||||
<string name="start">Iniciar</string>
|
||||
<string name="back">Voltar</string>
|
||||
<string name="complete_lap">Completar volta</string>
|
||||
<string name="end">Fim</string>
|
||||
<string name="rest">Descanso</string>
|
||||
<string name="lap_detected">Volta detectada!</string>
|
||||
<string name="workout">Treino</string>
|
||||
<string name="pool">%dm piscina</string>
|
||||
<string name="total">%dm total</string>
|
||||
<string name="kcal">%dkcal</string>
|
||||
<string name="set_format">Série %1$d/%2$d</string>
|
||||
<string name="rep_format">Repetição %1$d/%2$d</string>
|
||||
<string name="next_format">Próximo: %1$s %2$dm</string>
|
||||
<string name="workout_complete">Treino concluído!</string>
|
||||
<string name="total_time">Tempo total</string>
|
||||
<string name="total_distance_label">Distância</string>
|
||||
<string name="calories_label">Calorias</string>
|
||||
<string name="laps_label">Voltas</string>
|
||||
<string name="tap_to_dismiss">Toque para fechar</string>
|
||||
<string name="ok">OK</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">SwimBuddy</string>
|
||||
<string name="no_workouts">No workouts yet</string>
|
||||
<string name="create_on_phone">Create a workout on your phone to get started</string>
|
||||
<string name="saved_workouts">Saved Workouts</string>
|
||||
<string name="recent">Recent</string>
|
||||
<string name="completed">Completed</string>
|
||||
<string name="sets">Sets</string>
|
||||
<string name="start">Start</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="complete_lap">Complete Lap</string>
|
||||
<string name="end">End</string>
|
||||
<string name="rest">Rest</string>
|
||||
<string name="lap_detected">Lap detected!</string>
|
||||
<string name="workout">Workout</string>
|
||||
<string name="pool">%dm pool</string>
|
||||
<string name="total">%dm total</string>
|
||||
<string name="kcal">%dkcal</string>
|
||||
<string name="set_format">Set %1$d/%2$d</string>
|
||||
<string name="rep_format">Rep %1$d/%2$d</string>
|
||||
<string name="next_format">Next: %1$s %2$dm</string>
|
||||
<string name="workout_complete">Workout complete!</string>
|
||||
<string name="total_time">Total time</string>
|
||||
<string name="total_distance_label">Distance</string>
|
||||
<string name="calories_label">Calories</string>
|
||||
<string name="laps_label">Laps</string>
|
||||
<string name="tap_to_dismiss">Tap to dismiss</string>
|
||||
<string name="ok">OK</string>
|
||||
</resources>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user