feat: initial project scaffold with Watch companion app

This commit is contained in:
2026-08-04 17:28:06 +02:00
parent adbb4f01c5
commit 43b6467efb
65 changed files with 6361 additions and 166 deletions
@@ -0,0 +1,150 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable, ActivityIndicator } from "react-native";
import { useNavigation, useRoute } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RootStackParamList } from "@navigation/types";
import type { RouteProp } from "@react-navigation/native";
import { getWorkoutWithSets } from "@db/operations";
import { useSyncWorkout } from "../hooks/useSyncWorkout";
import { useWatchConnection } from "../hooks/useWatchConnection";
import { formatDistance } from "@utils/formatDistance";
import { STROKE_COLORS } from "@constants/strokes";
import type { WorkoutSet } from "@types/workout";
type WorkoutWithSets = {
id: string;
name: string | null;
poolLength: number;
totalDistance: number | null;
sets: WorkoutSet[];
};
export function PreSwimScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "PreSwim">>();
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
const { isSyncing, sendWorkoutToWatch } = useSyncWorkout();
const { status, isPaired, isInstalled } = useWatchConnection();
useEffect(() => {
loadWorkout();
}, []);
const loadWorkout = async () => {
const data = await getWorkoutWithSets(route.params.workoutId);
setWorkout(data as WorkoutWithSets | null);
};
const handleStartSwim = async () => {
if (!workout) return;
if (status !== "connected") {
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
return;
}
const result = await sendWorkoutToWatch(
workout.id,
workout.name,
workout.poolLength,
workout.sets
);
if ("error" in result) {
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
return;
}
navigation.navigate("PostSwim", { workoutId: route.params.workoutId });
};
if (!workout) {
return (
<View className="flex-1 bg-white items-center justify-center">
<ActivityIndicator size="large" color="#0ea5e9" />
</View>
);
}
return (
<View className="flex-1 bg-white p-4">
<View className="items-center mb-6">
<Text className="text-2xl font-bold text-slate-900 mb-1">
{workout.name || "Workout"}
</Text>
<Text className="text-slate-500">
{formatDistance(workout.totalDistance ?? 0)} · {workout.poolLength}m pool
</Text>
</View>
<View className="bg-sky-50 rounded-2xl p-4 mb-4 border border-sky-100">
<Text className="text-xs font-semibold text-sky-700 mb-3 uppercase tracking-wide">
Sets
</Text>
{workout.sets.map((set, i) => (
<View
key={i}
className="flex-row items-center justify-between py-2 border-b border-sky-100 last:border-b-0"
>
<View className="flex-row items-center gap-2">
<View
className="w-3 h-3 rounded-full"
style={{ backgroundColor: STROKE_COLORS[set.stroke] ?? "#94a3b8" }}
/>
<Text className="text-slate-700 font-medium">{set.stroke}</Text>
</View>
<Text className="text-slate-600">
{set.repCount}×{set.distancePerRep}m
</Text>
</View>
))}
</View>
<View className="bg-slate-50 rounded-2xl p-4 mb-6">
<View className="flex-row justify-between mb-2">
<Text className="text-slate-500">Rest</Text>
<Text className="text-slate-900 font-medium">
{workout.sets[0]?.restDuration ?? 0}s between sets
</Text>
</View>
</View>
<View className="bg-slate-50 rounded-2xl p-3 mb-6 flex-row items-center gap-3">
<View
className={`w-3 h-3 rounded-full ${
status === "connected" ? "bg-green-500" : "bg-slate-300"
}`}
/>
<Text className="text-sm text-slate-600">
{status === "connected"
? "Apple Watch connected"
: isPaired
? "Watch paired but not connected"
: "No Watch paired"}
</Text>
</View>
<Pressable
onPress={handleStartSwim}
disabled={isSyncing}
className={`rounded-2xl py-4 items-center ${
isSyncing ? "bg-sky-300" : "bg-sky-500"
}`}
>
{isSyncing ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-white font-bold text-lg">
{status === "connected" ? "Send to Watch & Start" : "Start Without Watch"}
</Text>
)}
</Pressable>
<Text className="text-slate-400 text-xs text-center mt-3">
{status === "connected"
? "Workout will be sent to your Apple Watch"
: "Workout will be tracked on this device"}
</Text>
</View>
);
}