feat(watch-connectivity): add WearOS support alongside Apple Watch
This commit is contained in:
@@ -36,7 +36,8 @@
|
|||||||
"@bacons/apple-targets",
|
"@bacons/apple-targets",
|
||||||
["expo-localization", {
|
["expo-localization", {
|
||||||
"supportedLocales": ["en", "nl", "de", "fr", "it", "es", "pl", "pt", "dk"]
|
"supportedLocales": ["en", "nl", "de", "fr", "it", "es", "pl", "pt", "dk"]
|
||||||
}]
|
}],
|
||||||
|
"./modules/wear-connectivity"
|
||||||
],
|
],
|
||||||
"extra": {
|
"extra": {
|
||||||
"eas": {
|
"eas": {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="nl.guidoit.swimbuddy.wear">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<service
|
||||||
|
android:name=".WearMessageListenerService"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.android.gms.wearable.MESSAGE_RECEIVED" />
|
||||||
|
<data
|
||||||
|
android:scheme="wear"
|
||||||
|
android:host="*"
|
||||||
|
android:pathPrefix="/swimbuddy" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
+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 { useState } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import WatchConnectivity from "../../../modules/watch-connectivity";
|
import WatchConnectivity from "../../../modules/watch-connectivity";
|
||||||
|
import WearConnectivity from "../../../modules/wear-connectivity";
|
||||||
import type { WorkoutSet } from "@types/workout";
|
import type { WorkoutSet } from "@types/workout";
|
||||||
|
|
||||||
type SyncResult = { status: string } | { error: string };
|
type SyncResult = { status: string } | { error: string };
|
||||||
@@ -15,20 +17,6 @@ export function useSyncWorkout() {
|
|||||||
): Promise<SyncResult> => {
|
): Promise<SyncResult> => {
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
try {
|
try {
|
||||||
console.log("[useSyncWorkout] Checking module availability...");
|
|
||||||
|
|
||||||
const supported = await WatchConnectivity.isSupported();
|
|
||||||
console.log("[useSyncWorkout] isSupported:", supported);
|
|
||||||
|
|
||||||
const paired = await WatchConnectivity.isPaired();
|
|
||||||
console.log("[useSyncWorkout] isPaired:", paired);
|
|
||||||
|
|
||||||
const installed = await WatchConnectivity.isWatchAppInstalled();
|
|
||||||
console.log("[useSyncWorkout] isWatchAppInstalled:", installed);
|
|
||||||
|
|
||||||
const reachable = await WatchConnectivity.isReachable();
|
|
||||||
console.log("[useSyncWorkout] isReachable:", reachable);
|
|
||||||
|
|
||||||
const plan = {
|
const plan = {
|
||||||
id: workoutId,
|
id: workoutId,
|
||||||
name: name ?? "Workout",
|
name: name ?? "Workout",
|
||||||
@@ -39,16 +27,37 @@ export function useSyncWorkout() {
|
|||||||
repCount: s.repCount,
|
repCount: s.repCount,
|
||||||
restType: s.restType,
|
restType: s.restType,
|
||||||
restDuration: s.restDuration,
|
restDuration: s.restDuration,
|
||||||
targetTime: s.targetTime ?? null,
|
...(s.targetTime != null ? { targetTime: s.targetTime } : {}),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("[useSyncWorkout] Sending plan:", JSON.stringify(plan).slice(0, 200));
|
if (Platform.OS === "ios") {
|
||||||
|
const supported = await WatchConnectivity.isSupported();
|
||||||
|
const paired = await WatchConnectivity.isPaired();
|
||||||
|
const installed = await WatchConnectivity.isWatchAppInstalled();
|
||||||
|
const reachable = await WatchConnectivity.isReachable();
|
||||||
|
|
||||||
|
if (!supported || !paired || !installed) {
|
||||||
|
return { error: "Apple Watch not available" };
|
||||||
|
}
|
||||||
|
|
||||||
const result = await WatchConnectivity.sendWorkoutPlan(plan);
|
const result = await WatchConnectivity.sendWorkoutPlan(plan);
|
||||||
console.log("[useSyncWorkout] Result:", JSON.stringify(result));
|
|
||||||
return result as SyncResult;
|
return result as SyncResult;
|
||||||
|
} else if (Platform.OS === "android") {
|
||||||
|
const supported = await WearConnectivity.isSupported();
|
||||||
|
const paired = await WearConnectivity.isPaired();
|
||||||
|
const reachable = await WearConnectivity.isReachable();
|
||||||
|
|
||||||
|
if (!supported || !paired) {
|
||||||
|
return { error: "WearOS watch not available" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await WearConnectivity.sendWorkoutPlan(plan);
|
||||||
|
return result as SyncResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { error: "Platform not supported" };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.log("[useSyncWorkout] Error:", error.message, error.code);
|
|
||||||
return { error: error.message ?? "Failed to sync" };
|
return { error: error.message ?? "Failed to sync" };
|
||||||
} finally {
|
} finally {
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import WatchConnectivity from "../../../modules/watch-connectivity";
|
import WatchConnectivity from "../../../modules/watch-connectivity";
|
||||||
|
import WearConnectivity from "../../../modules/wear-connectivity";
|
||||||
|
|
||||||
type WatchConnectionStatus = "connected" | "disconnected" | "pending";
|
type WatchConnectionStatus = "connected" | "disconnected" | "pending";
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ export function useWatchConnection() {
|
|||||||
|
|
||||||
const checkStatus = async () => {
|
const checkStatus = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
const supported = await WatchConnectivity.isSupported();
|
const supported = await WatchConnectivity.isSupported();
|
||||||
if (!supported) {
|
if (!supported) {
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
@@ -29,6 +32,22 @@ export function useWatchConnection() {
|
|||||||
setIsPaired(paired);
|
setIsPaired(paired);
|
||||||
setIsInstalled(installed);
|
setIsInstalled(installed);
|
||||||
setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected");
|
setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected");
|
||||||
|
} else if (Platform.OS === "android") {
|
||||||
|
const supported = await WearConnectivity.isSupported();
|
||||||
|
if (!supported) {
|
||||||
|
setStatus("disconnected");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paired = await WearConnectivity.isPaired();
|
||||||
|
const reachable = await WearConnectivity.isReachable();
|
||||||
|
|
||||||
|
setIsPaired(paired);
|
||||||
|
setIsInstalled(paired); // WearOS doesn't have a separate install check
|
||||||
|
setStatus(reachable ? "connected" : paired ? "disconnected" : "disconnected");
|
||||||
|
} else {
|
||||||
|
setStatus("disconnected");
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import WatchConnectivity from "../../../modules/watch-connectivity";
|
import WatchConnectivity from "../../../modules/watch-connectivity";
|
||||||
|
import WearConnectivity from "../../../modules/wear-connectivity";
|
||||||
import { saveWatchCompletedWorkout } from "@db/operations";
|
import { saveWatchCompletedWorkout } from "@db/operations";
|
||||||
|
|
||||||
export function useWatchWorkoutSync() {
|
export function useWatchWorkoutSync() {
|
||||||
@@ -8,10 +10,16 @@ export function useWatchWorkoutSync() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
pollingRef.current = setInterval(async () => {
|
pollingRef.current = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const events = (await WatchConnectivity.pollEvents()) as any[];
|
let events: any[] = [];
|
||||||
|
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
events = (await WatchConnectivity.pollEvents()) as any[];
|
||||||
|
} else if (Platform.OS === "android") {
|
||||||
|
events = (await WearConnectivity.pollEvents()) as any[];
|
||||||
|
}
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.type === "workoutCompleted") {
|
if (event.type === "workoutCompleted") {
|
||||||
console.log("[useWatchWorkoutSync] Received workoutCompleted:", event.workoutId);
|
|
||||||
await saveWatchCompletedWorkout({
|
await saveWatchCompletedWorkout({
|
||||||
workoutId: event.workoutId,
|
workoutId: event.workoutId,
|
||||||
duration: event.duration ?? 0,
|
duration: event.duration ?? 0,
|
||||||
@@ -19,7 +27,6 @@ export function useWatchWorkoutSync() {
|
|||||||
calories: event.calories ?? 0,
|
calories: event.calories ?? 0,
|
||||||
laps: event.laps ?? [],
|
laps: event.laps ?? [],
|
||||||
});
|
});
|
||||||
console.log("[useWatchWorkoutSync] Saved completed workout to DB");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"workoutSentToWatch": "Workout an Watch gesendet!",
|
"workoutSentToWatch": "Workout an Watch gesendet!",
|
||||||
"openWatchApp": "Öffne die SwimBuddy-App auf deiner Apple Watch, um mit der Verfolgung zu beginnen.",
|
"openWatchApp": "Öffne die SwimBuddy-App auf deiner Watch, um mit der Verfolgung zu beginnen.",
|
||||||
"failedToSend": "Senden fehlgeschlagen",
|
"failedToSend": "Senden fehlgeschlagen",
|
||||||
"couldNotSend": "Workout konnte nicht an Watch gesendet werden. Starte ohne Watch.",
|
"couldNotSend": "Workout konnte nicht an Watch gesendet werden. Starte ohne Watch.",
|
||||||
"startWithoutWatch": "Ohne Watch starten",
|
"startWithoutWatch": "Ohne Watch starten",
|
||||||
"sendToWatchAndStart": "An Watch senden & starten",
|
"sendToWatchAndStart": "An Watch senden & starten",
|
||||||
"syncToWatch": "Workout wird mit deiner Apple Watch synchronisiert",
|
"syncToWatch": "Workout wird mit deiner Watch synchronisiert",
|
||||||
"trackedOnDevice": "Workout wird auf diesem Gerät verfolgt",
|
"trackedOnDevice": "Workout wird auf diesem Gerät verfolgt",
|
||||||
"rest": "Pause",
|
"rest": "Pause",
|
||||||
"betweenSets": "{{duration}}s Pause zwischen Sets",
|
"betweenSets": "{{duration}}s Pause zwischen Sets",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"greatJob": "Gut gemacht! Du bist heute ins Wasser gegangen!",
|
"greatJob": "Gut gemacht! Du bist heute ins Wasser gegangen!",
|
||||||
"loadingSummary": "Zusammenfassung laden...",
|
"loadingSummary": "Zusammenfassung laden...",
|
||||||
"watchStatus": {
|
"watchStatus": {
|
||||||
"ready": "Apple Watch bereit",
|
"ready": "Watch bereit",
|
||||||
"pairedNotInRange": "Watch gekoppelt, aber nicht in Reichweite",
|
"pairedNotInRange": "Watch gekoppelt, aber nicht in Reichweite",
|
||||||
"pairedNotInstalled": "Watch gekoppelt, App nicht installiert",
|
"pairedNotInstalled": "Watch gekoppelt, App nicht installiert",
|
||||||
"noWatch": "Keine Watch gekoppelt"
|
"noWatch": "Keine Watch gekoppelt"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"workoutSentToWatch": "Træning sendt til Watch!",
|
"workoutSentToWatch": "Træning sendt til Watch!",
|
||||||
"openWatchApp": "Åbn SwimBuddy-appen på din Apple Watch for at begynde sporing.",
|
"openWatchApp": "Åbn SwimBuddy-appen på din Watch for at begynde sporing.",
|
||||||
"failedToSend": "Sendning mislykkedes",
|
"failedToSend": "Sendning mislykkedes",
|
||||||
"couldNotSend": "Kunne ikke sende træning til Watch. Starter uden Watch.",
|
"couldNotSend": "Kunne ikke sende træning til Watch. Starter uden Watch.",
|
||||||
"startWithoutWatch": "Start uden Watch",
|
"startWithoutWatch": "Start uden Watch",
|
||||||
"sendToWatchAndStart": "Send til Watch & start",
|
"sendToWatchAndStart": "Send til Watch & start",
|
||||||
"syncToWatch": "Træningen synkroniseres med din Apple Watch",
|
"syncToWatch": "Træningen synkroniseres med din Watch",
|
||||||
"trackedOnDevice": "Træningen spores på denne enhed",
|
"trackedOnDevice": "Træningen spores på denne enhed",
|
||||||
"rest": "Hvile",
|
"rest": "Hvile",
|
||||||
"betweenSets": "{{duration}}s hvile mellem sæt",
|
"betweenSets": "{{duration}}s hvile mellem sæt",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"greatJob": "Godt gået! Du var i vandet i dag!",
|
"greatJob": "Godt gået! Du var i vandet i dag!",
|
||||||
"loadingSummary": "Indlæser opsummering...",
|
"loadingSummary": "Indlæser opsummering...",
|
||||||
"watchStatus": {
|
"watchStatus": {
|
||||||
"ready": "Apple Watch klar",
|
"ready": "Watch klar",
|
||||||
"pairedNotInRange": "Watch parret, men ikke inden for rækkevidde",
|
"pairedNotInRange": "Watch parret, men ikke inden for rækkevidde",
|
||||||
"pairedNotInstalled": "Watch parret, app ikke installeret",
|
"pairedNotInstalled": "Watch parret, app ikke installeret",
|
||||||
"noWatch": "Ingen Watch parret"
|
"noWatch": "Ingen Watch parret"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"workoutSentToWatch": "Workout sent to Watch!",
|
"workoutSentToWatch": "Workout sent to Watch!",
|
||||||
"openWatchApp": "Open the SwimBuddy app on your Apple Watch to start tracking.",
|
"openWatchApp": "Open the SwimBuddy app on your watch to start tracking.",
|
||||||
"failedToSend": "Failed to send",
|
"failedToSend": "Failed to send",
|
||||||
"couldNotSend": "Could not send workout to Watch. Starting without Watch instead.",
|
"couldNotSend": "Could not send workout to Watch. Starting without Watch instead.",
|
||||||
"startWithoutWatch": "Start Without Watch",
|
"startWithoutWatch": "Start Without Watch",
|
||||||
"sendToWatchAndStart": "Send to Watch & Start",
|
"sendToWatchAndStart": "Send to Watch & Start",
|
||||||
"syncToWatch": "Workout will be synced to your Apple Watch",
|
"syncToWatch": "Workout will be synced to your watch",
|
||||||
"trackedOnDevice": "Workout will be tracked on this device",
|
"trackedOnDevice": "Workout will be tracked on this device",
|
||||||
"rest": "Rest",
|
"rest": "Rest",
|
||||||
"betweenSets": "{{duration}}s between sets",
|
"betweenSets": "{{duration}}s between sets",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"greatJob": "Great job getting in the water today!",
|
"greatJob": "Great job getting in the water today!",
|
||||||
"loadingSummary": "Loading summary...",
|
"loadingSummary": "Loading summary...",
|
||||||
"watchStatus": {
|
"watchStatus": {
|
||||||
"ready": "Apple Watch ready",
|
"ready": "Watch ready",
|
||||||
"pairedNotInRange": "Watch paired but not in range",
|
"pairedNotInRange": "Watch paired but not in range",
|
||||||
"pairedNotInstalled": "Watch paired, app not installed",
|
"pairedNotInstalled": "Watch paired, app not installed",
|
||||||
"noWatch": "No Watch paired"
|
"noWatch": "No Watch paired"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"workoutSentToWatch": "¡Entrenamiento enviado al Watch!",
|
"workoutSentToWatch": "¡Entrenamiento enviado al Watch!",
|
||||||
"openWatchApp": "Abre la app SwimBuddy en tu Apple Watch para empezar el seguimiento.",
|
"openWatchApp": "Abre la app SwimBuddy en tu Watch para empezar el seguimiento.",
|
||||||
"failedToSend": "Error al enviar",
|
"failedToSend": "Error al enviar",
|
||||||
"couldNotSend": "No se pudo enviar el entrenamiento al Watch. Iniciando sin Watch.",
|
"couldNotSend": "No se pudo enviar el entrenamiento al Watch. Iniciando sin Watch.",
|
||||||
"startWithoutWatch": "Iniciar sin Watch",
|
"startWithoutWatch": "Iniciar sin Watch",
|
||||||
"sendToWatchAndStart": "Enviar al Watch e iniciar",
|
"sendToWatchAndStart": "Enviar al Watch e iniciar",
|
||||||
"syncToWatch": "El entrenamiento se sincronizará con tu Apple Watch",
|
"syncToWatch": "El entrenamiento se sincronizará con tu Watch",
|
||||||
"trackedOnDevice": "El entrenamiento se rastreará en este dispositivo",
|
"trackedOnDevice": "El entrenamiento se rastreará en este dispositivo",
|
||||||
"rest": "Descanso",
|
"rest": "Descanso",
|
||||||
"betweenSets": "{{duration}}s de descanso entre series",
|
"betweenSets": "{{duration}}s de descanso entre series",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"greatJob": "¡Buen trabajo! ¡Hoy has estado en el agua!",
|
"greatJob": "¡Buen trabajo! ¡Hoy has estado en el agua!",
|
||||||
"loadingSummary": "Cargando resumen...",
|
"loadingSummary": "Cargando resumen...",
|
||||||
"watchStatus": {
|
"watchStatus": {
|
||||||
"ready": "Apple Watch lista",
|
"ready": "Watch lista",
|
||||||
"pairedNotInRange": "Watch emparejada pero fuera de alcance",
|
"pairedNotInRange": "Watch emparejada pero fuera de alcance",
|
||||||
"pairedNotInstalled": "Watch emparejada, app no instalada",
|
"pairedNotInstalled": "Watch emparejada, app no instalada",
|
||||||
"noWatch": "Ninguna Watch emparejada"
|
"noWatch": "Ninguna Watch emparejada"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"workoutSentToWatch": "Entraînement envoyé à la Watch !",
|
"workoutSentToWatch": "Entraînement envoyé à la Watch !",
|
||||||
"openWatchApp": "Ouvrez l'application SwimBuddy sur votre Apple Watch pour commencer le suivi.",
|
"openWatchApp": "Ouvrez l'application SwimBuddy sur votre Watch pour commencer le suivi.",
|
||||||
"failedToSend": "Échec de l'envoi",
|
"failedToSend": "Échec de l'envoi",
|
||||||
"couldNotSend": "Impossible d'envoyer l'entraînement à la Watch. Démarrage sans Watch.",
|
"couldNotSend": "Impossible d'envoyer l'entraînement à la Watch. Démarrage sans Watch.",
|
||||||
"startWithoutWatch": "Démarrer sans Watch",
|
"startWithoutWatch": "Démarrer sans Watch",
|
||||||
"sendToWatchAndStart": "Envoyer à la Watch et commencer",
|
"sendToWatchAndStart": "Envoyer à la Watch et commencer",
|
||||||
"syncToWatch": "L'entraînement sera synchronisé avec votre Apple Watch",
|
"syncToWatch": "L'entraînement sera synchronisé avec votre Watch",
|
||||||
"trackedOnDevice": "L'entraînement sera suivi sur cet appareil",
|
"trackedOnDevice": "L'entraînement sera suivi sur cet appareil",
|
||||||
"rest": "Repos",
|
"rest": "Repos",
|
||||||
"betweenSets": "{{duration}}s de repos entre les séries",
|
"betweenSets": "{{duration}}s de repos entre les séries",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"greatJob": "Bravo ! Vous êtes allé à l'eau aujourd'hui !",
|
"greatJob": "Bravo ! Vous êtes allé à l'eau aujourd'hui !",
|
||||||
"loadingSummary": "Chargement du résumé...",
|
"loadingSummary": "Chargement du résumé...",
|
||||||
"watchStatus": {
|
"watchStatus": {
|
||||||
"ready": "Apple Watch prête",
|
"ready": "Watch prête",
|
||||||
"pairedNotInRange": "Watch appariée mais hors de portée",
|
"pairedNotInRange": "Watch appariée mais hors de portée",
|
||||||
"pairedNotInstalled": "Watch appariée, application non installée",
|
"pairedNotInstalled": "Watch appariée, application non installée",
|
||||||
"noWatch": "Aucune Watch appariée"
|
"noWatch": "Aucune Watch appariée"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"workoutSentToWatch": "Allenamento inviato alla Watch!",
|
"workoutSentToWatch": "Allenamento inviato alla Watch!",
|
||||||
"openWatchApp": "Apri l'app SwimBuddy sulla tua Apple Watch per iniziare il monitoraggio.",
|
"openWatchApp": "Apri l'app SwimBuddy sulla tua Watch per iniziare il monitoraggio.",
|
||||||
"failedToSend": "Invio fallito",
|
"failedToSend": "Invio fallito",
|
||||||
"couldNotSend": "Impossibile inviare l'allenamento alla Watch. Avvio senza Watch.",
|
"couldNotSend": "Impossibile inviare l'allenamento alla Watch. Avvio senza Watch.",
|
||||||
"startWithoutWatch": "Avvia senza Watch",
|
"startWithoutWatch": "Avvia senza Watch",
|
||||||
"sendToWatchAndStart": "Invia alla Watch e avvia",
|
"sendToWatchAndStart": "Invia alla Watch e avvia",
|
||||||
"syncToWatch": "L'allenamento verrà sincronizzato con la tua Apple Watch",
|
"syncToWatch": "L'allenamento verrà sincronizzato con la tua Watch",
|
||||||
"trackedOnDevice": "L'allenamento verrà monitorato su questo dispositivo",
|
"trackedOnDevice": "L'allenamento verrà monitorato su questo dispositivo",
|
||||||
"rest": "Recupero",
|
"rest": "Recupero",
|
||||||
"betweenSets": "{{duration}}s di recupero tra le serie",
|
"betweenSets": "{{duration}}s di recupero tra le serie",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"greatJob": "Ottimo lavoro! Sei entrato in acqua oggi!",
|
"greatJob": "Ottimo lavoro! Sei entrato in acqua oggi!",
|
||||||
"loadingSummary": "Caricamento riepilogo...",
|
"loadingSummary": "Caricamento riepilogo...",
|
||||||
"watchStatus": {
|
"watchStatus": {
|
||||||
"ready": "Apple Watch pronta",
|
"ready": "Watch pronta",
|
||||||
"pairedNotInRange": "Watch associata ma fuori portata",
|
"pairedNotInRange": "Watch associata ma fuori portata",
|
||||||
"pairedNotInstalled": "Watch associata, app non installata",
|
"pairedNotInstalled": "Watch associata, app non installata",
|
||||||
"noWatch": "Nessuna Watch associata"
|
"noWatch": "Nessuna Watch associata"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"workoutSentToWatch": "Workout verzonden naar Watch!",
|
"workoutSentToWatch": "Workout verzonden naar Watch!",
|
||||||
"openWatchApp": "Open de SwimBuddy-app op je Apple Watch om te beginnen met bijhouden.",
|
"openWatchApp": "Open de SwimBuddy-app op je Watch om te beginnen met bijhouden.",
|
||||||
"failedToSend": "Verzenden mislukt",
|
"failedToSend": "Verzenden mislukt",
|
||||||
"couldNotSend": "Kon workout niet naar Watch verzenden. Start zonder Watch.",
|
"couldNotSend": "Kon workout niet naar Watch verzenden. Start zonder Watch.",
|
||||||
"startWithoutWatch": "Start zonder Watch",
|
"startWithoutWatch": "Start zonder Watch",
|
||||||
"sendToWatchAndStart": "Verzenden naar Watch & Start",
|
"sendToWatchAndStart": "Verzenden naar Watch & Start",
|
||||||
"syncToWatch": "Workout wordt gesynchroniseerd naar je Apple Watch",
|
"syncToWatch": "Workout wordt gesynchroniseerd naar je Watch",
|
||||||
"trackedOnDevice": "Workout wordt op dit apparaat bijgehouden",
|
"trackedOnDevice": "Workout wordt op dit apparaat bijgehouden",
|
||||||
"rest": "Rust",
|
"rest": "Rust",
|
||||||
"betweenSets": "{{duration}}s rust tussen sets",
|
"betweenSets": "{{duration}}s rust tussen sets",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"greatJob": "Goed gedaan! Je bent vandaag het water in geweest!",
|
"greatJob": "Goed gedaan! Je bent vandaag het water in geweest!",
|
||||||
"loadingSummary": "Samenvatting laden...",
|
"loadingSummary": "Samenvatting laden...",
|
||||||
"watchStatus": {
|
"watchStatus": {
|
||||||
"ready": "Apple Watch gereed",
|
"ready": "Watch gereed",
|
||||||
"pairedNotInRange": "Watch gekoppeld maar niet in bereik",
|
"pairedNotInRange": "Watch gekoppeld maar niet in bereik",
|
||||||
"pairedNotInstalled": "Watch gekoppeld, app niet geïnstalleerd",
|
"pairedNotInstalled": "Watch gekoppeld, app niet geïnstalleerd",
|
||||||
"noWatch": "Geen Watch gekoppeld"
|
"noWatch": "Geen Watch gekoppeld"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"workoutSentToWatch": "Trening wysłany do Watch!",
|
"workoutSentToWatch": "Trening wysłany do Watch!",
|
||||||
"openWatchApp": "Otwórz aplikację SwimBuddy na swojej Apple Watch, aby rozpocząć śledzenie.",
|
"openWatchApp": "Otwórz aplikację SwimBuddy na swojej Watch, aby rozpocząć śledzenie.",
|
||||||
"failedToSend": "Nie udało się wysłać",
|
"failedToSend": "Nie udało się wysłać",
|
||||||
"couldNotSend": "Nie udało się wysłać treningu do Watch. Uruchamianie bez Watch.",
|
"couldNotSend": "Nie udało się wysłać treningu do Watch. Uruchamianie bez Watch.",
|
||||||
"startWithoutWatch": "Uruchom bez Watch",
|
"startWithoutWatch": "Uruchom bez Watch",
|
||||||
"sendToWatchAndStart": "Wyślij do Watch i rozpocznij",
|
"sendToWatchAndStart": "Wyślij do Watch i rozpocznij",
|
||||||
"syncToWatch": "Trening zostanie zsynchronizowany z Apple Watch",
|
"syncToWatch": "Trening zostanie zsynchronizowany z Watch",
|
||||||
"trackedOnDevice": "Trening będzie śledzony na tym urządzeniu",
|
"trackedOnDevice": "Trening będzie śledzony na tym urządzeniu",
|
||||||
"rest": "Odpoczynek",
|
"rest": "Odpoczynek",
|
||||||
"betweenSets": "{{duration}}s odpoczynku między seriami",
|
"betweenSets": "{{duration}}s odpoczynku między seriami",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"greatJob": "Świetna robota! Dziś byłeś w wodzie!",
|
"greatJob": "Świetna robota! Dziś byłeś w wodzie!",
|
||||||
"loadingSummary": "Ładowanie podsumowania...",
|
"loadingSummary": "Ładowanie podsumowania...",
|
||||||
"watchStatus": {
|
"watchStatus": {
|
||||||
"ready": "Apple Watch gotowa",
|
"ready": "Watch gotowa",
|
||||||
"pairedNotInRange": "Watch sparowana, ale poza zasięgiem",
|
"pairedNotInRange": "Watch sparowana, ale poza zasięgiem",
|
||||||
"pairedNotInstalled": "Watch sparowana, aplikacja nie zainstalowana",
|
"pairedNotInstalled": "Watch sparowana, aplikacja nie zainstalowana",
|
||||||
"noWatch": "Brak sparowanej Watch"
|
"noWatch": "Brak sparowanej Watch"
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"workoutSentToWatch": "Treino enviado para a Watch!",
|
"workoutSentToWatch": "Treino enviado para a Watch!",
|
||||||
"openWatchApp": "Abra a aplicação SwimBuddy na sua Apple Watch para iniciar o registo.",
|
"openWatchApp": "Abra a aplicação SwimBuddy na sua Watch para iniciar o registo.",
|
||||||
"failedToSend": "Falha no envio",
|
"failedToSend": "Falha no envio",
|
||||||
"couldNotSend": "Não foi possível enviar o treino para a Watch. Iniciando sem Watch.",
|
"couldNotSend": "Não foi possível enviar o treino para a Watch. Iniciando sem Watch.",
|
||||||
"startWithoutWatch": "Iniciar sem Watch",
|
"startWithoutWatch": "Iniciar sem Watch",
|
||||||
"sendToWatchAndStart": "Enviar para a Watch e iniciar",
|
"sendToWatchAndStart": "Enviar para a Watch e iniciar",
|
||||||
"syncToWatch": "O treino será sincronizado com a sua Apple Watch",
|
"syncToWatch": "O treino será sincronizado com a sua Watch",
|
||||||
"trackedOnDevice": "O treino será registado neste dispositivo",
|
"trackedOnDevice": "O treino será registado neste dispositivo",
|
||||||
"rest": "Descanso",
|
"rest": "Descanso",
|
||||||
"betweenSets": "{{duration}}s de descanso entre séries",
|
"betweenSets": "{{duration}}s de descanso entre séries",
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"greatJob": "Bom trabalho! Esteve na água hoje!",
|
"greatJob": "Bom trabalho! Esteve na água hoje!",
|
||||||
"loadingSummary": "A carregar resumo...",
|
"loadingSummary": "A carregar resumo...",
|
||||||
"watchStatus": {
|
"watchStatus": {
|
||||||
"ready": "Apple Watch pronta",
|
"ready": "Watch pronta",
|
||||||
"pairedNotInRange": "Watch associada, mas fora de alcance",
|
"pairedNotInRange": "Watch associada, mas fora de alcance",
|
||||||
"pairedNotInstalled": "Watch associada, aplicação não instalada",
|
"pairedNotInstalled": "Watch associada, aplicação não instalada",
|
||||||
"noWatch": "Nenhuma Watch associada"
|
"noWatch": "Nenhuma Watch associada"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ WatchConnectivity = requireOptionalNativeModule("WatchConnectivity") ?? {
|
|||||||
isReachable: async () => false,
|
isReachable: async () => false,
|
||||||
sendWorkoutPlan: async () => ({ error: "WatchConnectivity native module not available" }),
|
sendWorkoutPlan: async () => ({ error: "WatchConnectivity native module not available" }),
|
||||||
sendWorkoutCompletion: async () => ({ error: "WatchConnectivity native module not available" }),
|
sendWorkoutCompletion: async () => ({ error: "WatchConnectivity native module not available" }),
|
||||||
|
pollEvents: async () => [],
|
||||||
getApplicationContext: async () => null,
|
getApplicationContext: async () => null,
|
||||||
updateApplicationContext: async () => {},
|
updateApplicationContext: async () => {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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