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,140 @@
import { useState } from "react";
import { View, Text, TextInput, Pressable, ScrollView, Alert } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { RootStackParamList } from "@navigation/types";
import { useWorkoutBuilder } from "../hooks/useWorkoutBuilder";
import { useSettings } from "../../../providers/WatchProvider";
import type { StrokeType } from "@types/workout";
const STROKES: StrokeType[] = ["Freestyle", "Backstroke", "Breaststroke", "Butterfly", "Mixed"];
export function BuilderScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { poolLength } = useSettings();
const {
sets,
workoutName,
setWorkoutName,
totalDistance,
isSaving,
addSet,
updateSet,
removeSet,
save,
} = useWorkoutBuilder();
const handleSave = async () => {
try {
const workoutId = await save(poolLength);
navigation.navigate("PreSwim", { workoutId });
} catch (e) {
Alert.alert("Error", "Failed to save workout");
}
};
return (
<ScrollView className="flex-1 bg-white">
<View className="p-4">
<Text className="text-2xl font-bold text-slate-900 mb-1">Build Workout</Text>
<Text className="text-slate-500 mb-4">
{totalDistance}m total · {poolLength}m pool
</Text>
<View className="mb-4">
<Text className="text-sm font-medium text-slate-500 mb-1">Workout Name</Text>
<TextInput
value={workoutName}
onChangeText={setWorkoutName}
placeholder="Morning Swim"
placeholderTextColor="#94a3b8"
className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-slate-900"
/>
</View>
{sets.map((set, index) => (
<View key={index} className="bg-slate-50 rounded-2xl p-4 mb-3 border border-slate-200">
<View className="flex-row justify-between items-center mb-3">
<Text className="font-semibold text-slate-700">Set {index + 1}</Text>
{sets.length > 1 && (
<Pressable onPress={() => removeSet(index)}>
<Text className="text-red-500 text-sm font-medium">Remove</Text>
</Pressable>
)}
</View>
<Text className="text-xs font-medium text-slate-500 mb-1.5 uppercase tracking-wide">
Stroke
</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-3">
{STROKES.map((stroke) => (
<Pressable
key={stroke}
onPress={() => updateSet(index, { stroke })}
className={`mr-2 px-3 py-1.5 rounded-full ${
set.stroke === stroke ? "bg-sky-500" : "bg-slate-200"
}`}
>
<Text
className={`text-xs font-medium ${
set.stroke === stroke ? "text-white" : "text-slate-600"
}`}
>
{stroke}
</Text>
</Pressable>
))}
</ScrollView>
<View className="flex-row gap-2">
<View className="flex-1">
<Text className="text-xs font-medium text-slate-500 mb-1">Distance (m)</Text>
<TextInput
keyboardType="numeric"
value={String(set.distancePerRep)}
onChangeText={(t) => updateSet(index, { distancePerRep: Number(t) || 0 })}
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 text-center"
/>
</View>
<View className="flex-1">
<Text className="text-xs font-medium text-slate-500 mb-1">Reps</Text>
<TextInput
keyboardType="numeric"
value={String(set.repCount)}
onChangeText={(t) => updateSet(index, { repCount: Number(t) || 1 })}
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 text-center"
/>
</View>
<View className="flex-1">
<Text className="text-xs font-medium text-slate-500 mb-1">Rest (s)</Text>
<TextInput
keyboardType="numeric"
value={String(set.restDuration)}
onChangeText={(t) => updateSet(index, { restDuration: Number(t) || 0 })}
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900 text-center"
/>
</View>
</View>
</View>
))}
<Pressable
onPress={addSet}
className="bg-slate-100 rounded-2xl py-3 items-center mb-6 border border-slate-200"
>
<Text className="text-sky-600 font-semibold">+ Add Set</Text>
</Pressable>
<Pressable
onPress={handleSave}
disabled={isSaving}
className={`rounded-2xl py-4 items-center mb-8 ${isSaving ? "bg-sky-300" : "bg-sky-500"}`}
>
<Text className="text-white font-bold text-lg">
{isSaving ? "Saving..." : "Start Swim"}
</Text>
</Pressable>
</View>
</ScrollView>
);
}
@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable, FlatList } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { WorkoutStackParamList } from "@navigation/types";
import { getAllWorkouts, deleteWorkout } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
type WorkoutRow = {
id: string;
name: string | null;
totalDistance: number | null;
totalDuration: number | null;
startedAt: string;
finishedAt: string | null;
};
export function WorkoutListScreen() {
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
useEffect(() => {
loadWorkouts();
}, []);
const loadWorkouts = async () => {
const data = await getAllWorkouts();
setWorkouts(data as WorkoutRow[]);
};
return (
<View className="flex-1 bg-white">
<View className="p-4 pb-2">
<Pressable
onPress={() => navigation.navigate("Builder")}
className="bg-sky-500 rounded-2xl p-4 items-center mb-3"
>
<Text className="text-white font-bold text-lg">+ New Workout</Text>
</Pressable>
<Pressable
onPress={() => navigation.navigate("Templates")}
className="bg-slate-100 rounded-2xl p-3 items-center mb-4"
>
<Text className="text-sky-600 font-semibold">Browse Templates</Text>
</Pressable>
</View>
<FlatList
data={workouts}
keyExtractor={(item) => item.id}
contentContainerClassName="px-4 pb-8"
ListEmptyComponent={
<View className="py-12 items-center">
<Text className="text-slate-400 text-lg">No workouts yet</Text>
<Text className="text-slate-400 text-sm mt-1">Tap "New Workout" to get started</Text>
</View>
}
renderItem={({ item }) => (
<Pressable
onPress={() => navigation.navigate("PreSwim", { workoutId: item.id })}
className="bg-slate-50 rounded-2xl p-4 mb-3 border border-slate-200"
>
<View className="flex-row justify-between items-start">
<View className="flex-1">
<Text className="font-semibold text-slate-900 text-base">
{item.name || "Untitled Workout"}
</Text>
<Text className="text-slate-500 text-sm mt-1">
{formatDistance(item.totalDistance ?? 0)}
{item.totalDuration ? ` · ${formatTime(item.totalDuration)}` : ""}
</Text>
<Text className="text-slate-400 text-xs mt-1">
{new Date(item.startedAt).toLocaleDateString()}
</Text>
</View>
{item.finishedAt && (
<View className="bg-green-100 rounded-full px-2 py-0.5">
<Text className="text-green-700 text-xs font-medium">Done</Text>
</View>
)}
</View>
</Pressable>
)}
/>
</View>
);
}