feat(watch-connectivity): add WearOS support alongside Apple Watch

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