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
+35
View File
@@ -0,0 +1,35 @@
import { Pressable, Text, ActivityIndicator, type PressableProps } from "react-native";
type ButtonProps = PressableProps & {
title: string;
variant?: "primary" | "secondary" | "ghost";
loading?: boolean;
};
export function Button({ title, variant = "primary", loading, disabled, ...props }: ButtonProps) {
const base = "rounded-xl px-6 py-3 items-center justify-center";
const variants = {
primary: "bg-sky-500",
secondary: "bg-slate-100",
ghost: "bg-transparent",
};
const textVariants = {
primary: "text-white font-semibold",
secondary: "text-slate-700 font-semibold",
ghost: "text-sky-500 font-medium",
};
return (
<Pressable
className={`${base} ${variants[variant]} ${disabled ? "opacity-50" : ""}`}
disabled={disabled || loading}
{...props}
>
{loading ? (
<ActivityIndicator color={variant === "primary" ? "#fff" : "#0ea5e9"} />
) : (
<Text className={textVariants[variant]}>{title}</Text>
)}
</Pressable>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { View, type ViewProps } from "react-native";
type CardProps = ViewProps & {
variant?: "default" | "highlighted";
};
export function Card({ variant = "default", className = "", children, ...props }: CardProps) {
const base = "rounded-2xl p-4";
const variants = {
default: "bg-white border border-slate-200",
highlighted: "bg-sky-50 border border-sky-200",
};
return (
<View className={`${base} ${variants[variant]} ${className}`} {...props}>
{children}
</View>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { View, Text, ActivityIndicator } from "react-native";
type EmptyStateProps = {
title: string;
message?: string;
icon?: string;
};
export function EmptyState({ title, message }: EmptyStateProps) {
return (
<View className="flex-1 items-center justify-center py-12 px-4">
<Text className="text-lg font-semibold text-slate-900 mb-2">{title}</Text>
{message && <Text className="text-slate-500 text-center">{message}</Text>}
</View>
);
}
type LoadingSpinnerProps = {
message?: string;
};
export function LoadingSpinner({ message }: LoadingSpinnerProps) {
return (
<View className="flex-1 items-center justify-center py-12">
<ActivityIndicator size="large" color="#0ea5e9" />
{message && <Text className="text-slate-500 mt-3">{message}</Text>}
</View>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { TextInput, View, Text, type TextInputProps } from "react-native";
type InputProps = TextInputProps & {
label?: string;
error?: string;
};
export function Input({ label, error, className = "", ...props }: InputProps) {
return (
<View className="mb-3">
{label && <Text className="text-sm font-medium text-slate-500 mb-1">{label}</Text>}
<TextInput
className={`bg-white border ${error ? "border-red-400" : "border-slate-300"} rounded-lg px-3 py-2.5 text-slate-900 ${className}`}
placeholderTextColor="#94a3b8"
{...props}
/>
{error && <Text className="text-red-500 text-xs mt-1">{error}</Text>}
</View>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { View, type ViewProps } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
type ScreenContainerProps = ViewProps & {
padded?: boolean;
};
export function ScreenContainer({ padded = true, className = "", children, ...props }: ScreenContainerProps) {
const insets = useSafeAreaInsets();
return (
<View
className={`flex-1 bg-white ${padded ? "p-4" : ""} ${className}`}
style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
{...props}
>
{children}
</View>
);
}
+13
View File
@@ -0,0 +1,13 @@
export const COLORS = {
primary: "#0ea5e9",
primaryDark: "#0284c7",
primaryLight: "#e0f2fe",
background: "#ffffff",
surface: "#f8fafc",
text: "#0f172a",
textSecondary: "#64748b",
border: "#e2e8f0",
success: "#10b981",
warning: "#f59e0b",
error: "#ef4444",
} as const;
+3
View File
@@ -0,0 +1,3 @@
export const POOL_LENGTHS = [25, 50] as const;
export const DEFAULT_POOL_LENGTH = 25;
+15
View File
@@ -0,0 +1,15 @@
export const STROKE_TYPES = [
"Freestyle",
"Backstroke",
"Breaststroke",
"Butterfly",
"Mixed",
] as const;
export const STROKE_COLORS: Record<string, string> = {
Freestyle: "#0ea5e9",
Backstroke: "#8b5cf6",
Breaststroke: "#10b981",
Butterfly: "#f59e0b",
Mixed: "#6b7280",
};
+9
View File
@@ -0,0 +1,9 @@
import { openDatabaseSync } from "expo-sqlite";
import { drizzle } from "drizzle-orm/expo-sqlite";
import * as schema from "./schema";
export const DATABASE_NAME = "swimbuddy.db";
const expoDb = openDatabaseSync(DATABASE_NAME, { enableChangeListener: true });
export const db = drizzle(expoDb, { schema });
+177
View File
@@ -0,0 +1,177 @@
import { db } from "./client";
import { workouts, sets, laps, templates } from "./schema";
import { eq, desc, sql } from "drizzle-orm";
import type { WorkoutSet, LapData } from "@types/workout";
// ─── Templates ───────────────────────────────────────────────
export async function saveTemplate(name: string, poolLength: number, setsData: WorkoutSet[]) {
const id = crypto.randomUUID();
await db.insert(templates).values({
id,
name,
poolLength,
jsonData: JSON.stringify(setsData),
createdAt: new Date().toISOString(),
});
return id;
}
export async function getTemplates() {
return db.select().from(templates).orderBy(desc(templates.createdAt));
}
export async function deleteTemplate(id: string) {
await db.delete(templates).where(eq(templates.id, id));
}
export async function toggleTemplateFavorite(id: string, isFavorite: boolean) {
await db.update(templates).set({ isFavorite }).where(eq(templates.id, id));
}
// ─── Workouts ────────────────────────────────────────────────
export async function saveWorkout(
name: string | null,
poolLength: number,
setsData: WorkoutSet[],
templateId?: string
) {
const workoutId = crypto.randomUUID();
const totalDistance = setsData.reduce((acc, s) => acc + s.distancePerRep * s.repCount, 0);
await db.insert(workouts).values({
id: workoutId,
name,
templateId: templateId ?? null,
poolLength,
startedAt: new Date().toISOString(),
totalDistance,
source: "manual",
});
for (let i = 0; i < setsData.length; i++) {
const s = setsData[i];
await db.insert(sets).values({
id: crypto.randomUUID(),
workoutId,
position: i + 1,
stroke: s.stroke,
distancePerRep: s.distancePerRep,
repCount: s.repCount,
restType: s.restType,
restDuration: s.restDuration,
targetTime: s.targetTime ?? null,
});
}
return workoutId;
}
export async function finishWorkout(
workoutId: string,
duration: number,
calories: number,
avgHeartRate: number
) {
await db
.update(workouts)
.set({
finishedAt: new Date().toISOString(),
totalDuration: duration,
calories,
avgHeartRate,
})
.where(eq(workouts.id, workoutId));
}
export async function getWorkoutWithSets(workoutId: string) {
const workout = await db.select().from(workouts).where(eq(workouts.id, workoutId)).get();
if (!workout) return null;
const workoutSets = await db.select().from(sets).where(eq(sets.workoutId, workoutId));
const workoutLaps = await db.select().from(laps).where(eq(laps.workoutId, workoutId));
return { ...workout, sets: workoutSets, laps: workoutLaps };
}
export async function getAllWorkouts() {
return db.select().from(workouts).orderBy(desc(workouts.startedAt));
}
export async function deleteWorkout(id: string) {
await db.delete(laps).where(eq(laps.workoutId, id));
await db.delete(sets).where(eq(sets.workoutId, id));
await db.delete(workouts).where(eq(workouts.id, id));
}
// ─── Laps ────────────────────────────────────────────────────
export async function saveLap(workoutId: string, lap: LapData) {
const swolf =
lap.strokeCount != null ? lap.strokeCount + Math.round(lap.duration) : null;
await db.insert(laps).values({
id: crypto.randomUUID(),
workoutId,
setPosition: lap.setPosition,
repNumber: lap.repNumber,
distance: lap.distance,
duration: lap.duration,
strokeCount: lap.strokeCount ?? null,
heartRate: lap.heartRate ?? null,
swolf,
});
}
export async function saveLaps(workoutId: string, lapsData: LapData[]) {
for (const lap of lapsData) {
await saveLap(workoutId, lap);
}
}
export async function getWorkoutLaps(workoutId: string) {
return db.select().from(laps).where(eq(laps.workoutId, workoutId));
}
// ─── Stats ───────────────────────────────────────────────────
export async function getWeeklyStats() {
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return db
.select({
count: sql<number>`count(*)`,
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`,
avgCalories: sql<number>`coalesce(avg(${workouts.calories}), 0)`,
})
.from(workouts)
.where(sql`${workouts.startedAt} >= ${weekAgo.toISOString()}`)
.get();
}
export async function getPersonalBests() {
return db
.select({
distance: laps.distance,
bestTime: sql<number>`min(${laps.duration})`,
bestSwolf: sql<number>`min(${laps.swolf})`,
})
.from(laps)
.groupBy(laps.distance)
.having(sql`${laps.distance} > 0`);
}
export async function getTotalStats() {
return db
.select({
totalWorkouts: sql<number>`count(*)`,
totalDistance: sql<number>`coalesce(sum(${workouts.totalDistance}), 0)`,
totalDuration: sql<number>`coalesce(sum(${workouts.totalDuration}), 0)`,
})
.from(workouts)
.where(sql`${workouts.finishedAt} IS NOT NULL`)
.get();
}
+29
View File
@@ -0,0 +1,29 @@
import { relations } from "drizzle-orm";
import { workouts, sets, laps, templates } from "./schema";
export const templatesRelations = relations(templates, ({ many }) => ({
workouts: many(workouts),
}));
export const workoutsRelations = relations(workouts, ({ one, many }) => ({
template: one(templates, {
fields: [workouts.templateId],
references: [templates.id],
}),
sets: many(sets),
laps: many(laps),
}));
export const setsRelations = relations(sets, ({ one }) => ({
workout: one(workouts, {
fields: [sets.workoutId],
references: [workouts.id],
}),
}));
export const lapsRelations = relations(laps, ({ one }) => ({
workout: one(workouts, {
fields: [laps.workoutId],
references: [workouts.id],
}),
}));
+53
View File
@@ -0,0 +1,53 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
export const templates = sqliteTable("templates", {
id: text("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
poolLength: real("pool_length").notNull().default(25),
jsonData: text("json_data").notNull(),
isFavorite: integer("is_favorite", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at").notNull(),
});
export const workouts = sqliteTable("workouts", {
id: text("id").primaryKey(),
name: text("name"),
templateId: text("template_id"),
poolLength: real("pool_length").notNull().default(25),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"),
totalDistance: integer("total_distance"),
totalDuration: real("total_duration"),
calories: real("calories"),
avgHeartRate: integer("avg_heart_rate"),
source: text("source").notNull().default("manual"),
});
export const sets = sqliteTable("sets", {
id: text("id").primaryKey(),
workoutId: text("workout_id")
.notNull()
.references(() => workouts.id),
position: integer("position").notNull(),
stroke: text("stroke").notNull(),
distancePerRep: integer("distance_per_rep").notNull(),
repCount: integer("rep_count").notNull(),
restType: text("rest_type").notNull().default("fixed_rest"),
restDuration: real("rest_duration").notNull().default(0),
targetTime: real("target_time"),
});
export const laps = sqliteTable("laps", {
id: text("id").primaryKey(),
workoutId: text("workout_id")
.notNull()
.references(() => workouts.id),
setPosition: integer("set_position").notNull(),
repNumber: integer("rep_number").notNull(),
distance: integer("distance").notNull(),
duration: real("duration").notNull(),
strokeCount: integer("stroke_count"),
heartRate: integer("heart_rate"),
swolf: real("swolf"),
});
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable } 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, getWorkoutLaps } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
export function PostSwimScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "PostSwim">>();
const [summary, setSummary] = useState<{
distance: number;
duration: number;
laps: number;
avgSwolf: number | null;
} | null>(null);
useEffect(() => {
loadSummary();
}, []);
const loadSummary = async () => {
const workout = await getWorkoutWithSets(route.params.workoutId);
const laps = await getWorkoutLaps(route.params.workoutId);
if (!workout) return;
const totalDuration = workout.laps?.reduce((acc: number, l: any) => acc + l.duration, 0) ?? 0;
const avgSwolf =
laps.length > 0
? Math.round(laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / laps.length)
: null;
setSummary({
distance: workout.totalDistance ?? 0,
duration: totalDuration,
laps: laps.length,
avgSwolf,
});
};
return (
<View className="flex-1 bg-white p-4">
<View className="items-center mb-8">
<Text className="text-3xl mb-2">🏊</Text>
<Text className="text-2xl font-bold text-slate-900 mb-1">Swim Complete!</Text>
<Text className="text-slate-500">Great job getting in the water</Text>
</View>
{summary ? (
<View className="gap-3 mb-8">
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
<Text className="text-slate-600">Distance</Text>
<Text className="text-xl font-bold text-sky-600">
{formatDistance(summary.distance)}
</Text>
</View>
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
<Text className="text-slate-600">Duration</Text>
<Text className="text-xl font-bold text-sky-600">
{formatTime(summary.duration)}
</Text>
</View>
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
<Text className="text-slate-600">Laps</Text>
<Text className="text-xl font-bold text-sky-600">{summary.laps}</Text>
</View>
{summary.avgSwolf && (
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
<Text className="text-slate-600">Avg SWOLF</Text>
<Text className="text-xl font-bold text-sky-600">{summary.avgSwolf}</Text>
</View>
)}
</View>
) : (
<View className="py-8 items-center">
<Text className="text-slate-400">Loading summary...</Text>
</View>
)}
<Pressable
onPress={() => navigation.popToTop()}
className="bg-slate-900 rounded-2xl py-4 items-center"
>
<Text className="text-white font-bold text-lg">Done</Text>
</Pressable>
</View>
);
}
@@ -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>
);
}
@@ -0,0 +1,98 @@
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 { HistoryStackParamList } from "@navigation/types";
import { getAllWorkouts } 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;
calories: number | null;
startedAt: string;
finishedAt: string | null;
};
export function HistoryScreen() {
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
useEffect(() => {
loadWorkouts();
}, []);
const loadWorkouts = async () => {
const data = await getAllWorkouts();
setWorkouts((data as WorkoutRow[]).filter((w) => w.finishedAt));
};
const grouped = workouts.reduce<Record<string, WorkoutRow[]>>((acc, w) => {
const date = new Date(w.startedAt).toLocaleDateString();
if (!acc[date]) acc[date] = [];
acc[date].push(w);
return acc;
}, {});
const sections = Object.entries(grouped);
return (
<View className="flex-1 bg-white">
<View className="p-4 pb-2">
<Pressable
onPress={() => navigation.navigate("Stats")}
className="bg-slate-100 rounded-2xl p-3 items-center mb-4"
>
<Text className="text-sky-600 font-semibold">View Statistics</Text>
</Pressable>
</View>
<FlatList
data={sections}
keyExtractor={([date]) => date}
contentContainerClassName="px-4 pb-8"
ListEmptyComponent={
<View className="py-12 items-center">
<Text className="text-slate-400 text-lg">No completed workouts yet</Text>
</View>
}
renderItem={({ item: [date, rows] }) => (
<View className="mb-4">
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2 px-1">
{date}
</Text>
{rows.map((w) => (
<Pressable
key={w.id}
onPress={() => navigation.navigate("SessionDetail", { sessionId: w.id })}
className="bg-slate-50 rounded-2xl p-4 mb-2 border border-slate-200"
>
<View className="flex-row justify-between items-center">
<View>
<Text className="font-semibold text-slate-900">
{w.name || "Swim Session"}
</Text>
<Text className="text-slate-500 text-sm">
{formatDistance(w.totalDistance ?? 0)}
{w.totalDuration ? ` · ${formatTime(w.totalDuration)}` : ""}
{w.calories ? ` · ${Math.round(w.calories)} kcal` : ""}
</Text>
</View>
<Text className="text-slate-400 text-xs">
{new Date(w.startedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</View>
</Pressable>
))}
</View>
)}
/>
</View>
);
}
@@ -0,0 +1,147 @@
import { useEffect, useState } from "react";
import { View, Text, ScrollView } from "react-native";
import type { HistoryStackProps } from "@navigation/types";
import { getWorkoutWithSets } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
import { STROKE_COLORS } from "@constants/strokes";
type WorkoutDetail = {
id: string;
name: string | null;
poolLength: number;
totalDistance: number | null;
totalDuration: number | null;
calories: number | null;
avgHeartRate: number | null;
startedAt: string;
finishedAt: string | null;
sets: {
stroke: string;
distancePerRep: number;
repCount: number;
position: number;
}[];
laps: {
setPosition: number;
repNumber: number;
distance: number;
duration: number;
strokeCount: number | null;
heartRate: number | null;
swolf: number | null;
}[];
};
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
useEffect(() => {
loadWorkout();
}, []);
const loadWorkout = async () => {
const data = await getWorkoutWithSets(route.params.sessionId);
setWorkout(data as WorkoutDetail | null);
};
if (!workout) {
return (
<View className="flex-1 bg-white items-center justify-center">
<Text className="text-slate-400">Loading...</Text>
</View>
);
}
const avgSwolf =
workout.laps.length > 0
? Math.round(
workout.laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / workout.laps.length
)
: null;
return (
<ScrollView 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 || "Swim Session"}
</Text>
<Text className="text-slate-500">
{new Date(workout.startedAt).toLocaleDateString()}
</Text>
</View>
<View className="flex-row gap-3 mb-6">
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">
{formatDistance(workout.totalDistance ?? 0)}
</Text>
<Text className="text-xs text-slate-500 mt-1">Distance</Text>
</View>
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">
{formatTime(workout.totalDuration ?? 0)}
</Text>
<Text className="text-xs text-slate-500 mt-1">Duration</Text>
</View>
{workout.calories && (
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">
{Math.round(workout.calories)}
</Text>
<Text className="text-xs text-slate-500 mt-1">kcal</Text>
</View>
)}
</View>
{avgSwolf && (
<View className="bg-slate-50 rounded-2xl p-4 mb-4">
<Text className="text-sm font-semibold text-slate-700">Avg SWOLF</Text>
<Text className="text-xl font-bold text-slate-900">{avgSwolf}</Text>
</View>
)}
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-2">
Sets
</Text>
{workout.sets.map((set, i) => (
<View
key={i}
className="flex-row items-center justify-between py-3 border-b border-slate-100"
>
<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>
))}
{workout.laps.length > 0 && (
<>
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mt-6 mb-2">
Laps
</Text>
{workout.laps.map((lap, i) => (
<View
key={i}
className="flex-row justify-between py-2 border-b border-slate-100"
>
<Text className="text-slate-600">
Lap {i + 1} · {lap.distance}m
</Text>
<Text className="text-slate-600 font-medium">
{formatTime(lap.duration)}
</Text>
</View>
))}
</>
)}
</ScrollView>
);
}
@@ -0,0 +1,129 @@
import { useEffect, useState } from "react";
import { View, Text, ScrollView } from "react-native";
import { getWeeklyStats, getTotalStats, getPersonalBests } from "@db/operations";
import { formatDistance } from "@utils/formatDistance";
import { formatTime } from "@utils/formatTime";
type WeeklyStats = {
count: number;
totalDistance: number;
totalDuration: number;
avgCalories: number;
};
type TotalStats = {
totalWorkouts: number;
totalDistance: number;
totalDuration: number;
};
type PersonalBest = {
distance: number;
bestTime: number;
bestSwolf: number | null;
};
export function StatsScreen() {
const [weekly, setWeekly] = useState<WeeklyStats | null>(null);
const [total, setTotal] = useState<TotalStats | null>(null);
const [bests, setBests] = useState<PersonalBest[]>([]);
useEffect(() => {
loadStats();
}, []);
const loadStats = async () => {
const [w, t, b] = await Promise.all([
getWeeklyStats(),
getTotalStats(),
getPersonalBests(),
]);
setWeekly(w as WeeklyStats);
setTotal(t as TotalStats);
setBests(b as PersonalBest[]);
};
return (
<ScrollView className="flex-1 bg-white p-4">
<Text className="text-2xl font-bold text-slate-900 mb-6">Statistics</Text>
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
This Week
</Text>
<View className="bg-sky-50 rounded-2xl p-4 mb-6">
{weekly ? (
<View className="flex-row justify-between">
<View className="items-center flex-1">
<Text className="text-2xl font-bold text-sky-600">{weekly.count}</Text>
<Text className="text-xs text-slate-500">Workouts</Text>
</View>
<View className="items-center flex-1">
<Text className="text-2xl font-bold text-sky-600">
{formatDistance(weekly.totalDistance)}
</Text>
<Text className="text-xs text-slate-500">Distance</Text>
</View>
<View className="items-center flex-1">
<Text className="text-2xl font-bold text-sky-600">
{formatTime(weekly.totalDuration)}
</Text>
<Text className="text-xs text-slate-500">Duration</Text>
</View>
</View>
) : (
<Text className="text-slate-400 text-center py-4">No data yet</Text>
)}
</View>
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
All Time
</Text>
<View className="bg-slate-50 rounded-2xl p-4 mb-6">
{total ? (
<View className="flex-row justify-between">
<View className="items-center flex-1">
<Text className="text-2xl font-bold text-slate-700">{total.totalWorkouts}</Text>
<Text className="text-xs text-slate-500">Workouts</Text>
</View>
<View className="items-center flex-1">
<Text className="text-2xl font-bold text-slate-700">
{formatDistance(total.totalDistance)}
</Text>
<Text className="text-xs text-slate-500">Distance</Text>
</View>
<View className="items-center flex-1">
<Text className="text-2xl font-bold text-slate-700">
{formatTime(total.totalDuration)}
</Text>
<Text className="text-xs text-slate-500">Duration</Text>
</View>
</View>
) : (
<Text className="text-slate-400 text-center py-4">No data yet</Text>
)}
</View>
{bests.length > 0 && (
<>
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
Personal Bests
</Text>
{bests.map((best) => (
<View
key={best.distance}
className="bg-green-50 rounded-2xl p-4 mb-2 flex-row justify-between items-center"
>
<Text className="text-green-700 font-medium">{best.distance}m</Text>
<View className="items-end">
<Text className="text-green-800 font-bold">{formatTime(best.bestTime)}</Text>
{best.bestSwolf && (
<Text className="text-green-600 text-xs">SWOLF: {best.bestSwolf}</Text>
)}
</View>
</View>
))}
</>
)}
</ScrollView>
);
}
@@ -0,0 +1,39 @@
import { View, Text, Pressable } from "react-native";
import { useSettings } from "../../providers/WatchProvider";
export function SettingsScreen() {
const { poolLength, setPoolLength } = useSettings();
return (
<View className="flex-1 bg-white p-4">
<Text className="text-2xl font-bold text-slate-900 mb-6">Settings</Text>
<View className="mb-6">
<Text className="text-sm font-medium text-slate-500 mb-2">Default Pool Length</Text>
<View className="flex-row gap-3">
{[25, 50].map((len) => (
<Pressable
key={len}
onPress={() => setPoolLength(len)}
className={`flex-1 py-3 rounded-xl items-center ${
poolLength === len ? "bg-sky-500" : "bg-slate-100"
}`}
>
<Text
className={`font-semibold ${
poolLength === len ? "text-white" : "text-slate-600"
}`}
>
{len}m
</Text>
</Pressable>
))}
</View>
</View>
<View className="border-t border-slate-200 pt-4 mt-2">
<Text className="text-sm text-slate-400">SwimBuddy v1.0.0</Text>
</View>
</View>
);
}
@@ -0,0 +1,60 @@
import { useEffect, useState, useCallback } from "react";
import { getTemplates, deleteTemplate, toggleTemplateFavorite } from "@db/operations";
import type { WorkoutSet } from "@types/workout";
type Template = {
id: string;
name: string;
description: string | null;
poolLength: number;
jsonData: string;
isFavorite: boolean;
createdAt: string;
};
export function useTemplates() {
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoading, setIsLoading] = useState(true);
const load = useCallback(async () => {
setIsLoading(true);
try {
const data = await getTemplates();
setTemplates(data as Template[]);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const remove = useCallback(
async (id: string) => {
await deleteTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== id));
},
[]
);
const toggleFavorite = useCallback(
async (id: string, current: boolean) => {
await toggleTemplateFavorite(id, !current);
setTemplates((prev) =>
prev.map((t) => (t.id === id ? { ...t, isFavorite: !current } : t))
);
},
[]
);
const parseSets = (template: Template): WorkoutSet[] => {
try {
return JSON.parse(template.jsonData);
} catch {
return [];
}
};
return { templates, isLoading, remove, toggleFavorite, parseSets, reload: load };
}
@@ -0,0 +1,64 @@
import { View, Text, Pressable, FlatList, Alert } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { WorkoutStackParamList } from "@navigation/types";
import { useTemplates } from "../hooks/useTemplates";
export function TemplatesScreen() {
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
const { templates, remove, toggleFavorite } = useTemplates();
const handleDelete = (id: string, name: string) => {
Alert.alert("Delete Template", `Delete "${name}"?`, [
{ text: "Cancel", style: "cancel" },
{ text: "Delete", style: "destructive", onPress: () => remove(id) },
]);
};
return (
<View className="flex-1 bg-white">
<FlatList
data={templates}
keyExtractor={(item) => item.id}
contentContainerClassName="p-4"
ListEmptyComponent={
<View className="py-12 items-center">
<Text className="text-slate-400 text-lg">No templates yet</Text>
<Text className="text-slate-400 text-sm mt-1">
Save a workout as a template to reuse it
</Text>
</View>
}
renderItem={({ item }) => (
<Pressable
onPress={() => navigation.navigate("Builder", { templateId: 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}</Text>
<Text className="text-slate-500 text-sm mt-1">
{item.poolLength}m pool
</Text>
</View>
<View className="flex-row gap-2">
<Pressable
onPress={() => toggleFavorite(item.id, item.isFavorite)}
className="p-1"
>
<Text className="text-lg">{item.isFavorite ? "★" : "☆"}</Text>
</Pressable>
<Pressable
onPress={() => handleDelete(item.id, item.name)}
className="p-1"
>
<Text className="text-red-400 text-lg">×</Text>
</Pressable>
</View>
</View>
</Pressable>
)}
/>
</View>
);
}
@@ -0,0 +1,42 @@
import { useState } from "react";
import WatchConnectivity from "../../modules/watch-connectivity";
import type { WorkoutSet } from "@types/workout";
type SyncResult = { status: string } | { error: string };
export function useSyncWorkout() {
const [isSyncing, setIsSyncing] = useState(false);
const sendWorkoutToWatch = async (
workoutId: string,
name: string | null,
poolLength: number,
sets: WorkoutSet[]
): Promise<SyncResult> => {
setIsSyncing(true);
try {
const plan = {
id: workoutId,
name: name ?? "Workout",
poolLength,
sets: sets.map((s) => ({
stroke: s.stroke,
distancePerRep: s.distancePerRep,
repCount: s.repCount,
restType: s.restType,
restDuration: s.restDuration,
targetTime: s.targetTime ?? null,
})),
};
const result = await WatchConnectivity.sendWorkoutPlan(plan);
return result as SyncResult;
} catch (error: any) {
return { error: error.message ?? "Failed to sync" };
} finally {
setIsSyncing(false);
}
};
return { isSyncing, sendWorkoutToWatch };
}
@@ -0,0 +1,38 @@
import { useState, useEffect } from "react";
import WatchConnectivity from "../../modules/watch-connectivity";
type WatchConnectionStatus = "connected" | "disconnected" | "pending";
export function useWatchConnection() {
const [status, setStatus] = useState<WatchConnectionStatus>("pending");
const [isPaired, setIsPaired] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
checkStatus();
const interval = setInterval(checkStatus, 5000);
return () => clearInterval(interval);
}, []);
const checkStatus = async () => {
try {
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" : "disconnected");
} catch {
setStatus("disconnected");
}
};
return { status, isPaired, isInstalled };
}
@@ -0,0 +1,26 @@
import { useState, useEffect } from "react";
import WatchConnectivity from "../../modules/watch-connectivity";
export function useWatchReachability() {
const [isReachable, setIsReachable] = useState(false);
useEffect(() => {
checkReachability();
const interval = setInterval(checkReachability, 5000);
return () => clearInterval(interval);
}, []);
const checkReachability = async () => {
try {
const supported = await WatchConnectivity.isSupported();
if (supported) {
const reachable = await WatchConnectivity.isReachable();
setIsReachable(reachable);
}
} catch {
setIsReachable(false);
}
};
return isReachable;
}
@@ -0,0 +1,83 @@
import { useState, useCallback } from "react";
import type { WorkoutSet, StrokeType, RestType } from "@types/workout";
import { saveWorkout, saveTemplate } from "@db/operations";
const DEFAULT_SET: WorkoutSet = {
stroke: "Freestyle",
distancePerRep: 200,
repCount: 1,
restType: "fixed_rest",
restDuration: 30,
};
export function useWorkoutBuilder(initialSets?: WorkoutSet[]) {
const [sets, setSets] = useState<WorkoutSet[]>(initialSets ?? [{ ...DEFAULT_SET }]);
const [workoutName, setWorkoutName] = useState("");
const [isSaving, setIsSaving] = useState(false);
const addSet = useCallback(() => {
setSets((prev) => [...prev, { ...DEFAULT_SET }]);
}, []);
const updateSet = useCallback((index: number, updates: Partial<WorkoutSet>) => {
setSets((prev) => prev.map((s, i) => (i === index ? { ...s, ...updates } : s)));
}, []);
const removeSet = useCallback((index: number) => {
setSets((prev) => prev.filter((_, i) => i !== index));
}, []);
const totalDistance = sets.reduce((acc, s) => acc + s.distancePerRep * s.repCount, 0);
const save = useCallback(
async (poolLength: number, templateId?: string) => {
setIsSaving(true);
try {
const name = workoutName.trim() || `Workout ${new Date().toLocaleDateString()}`;
const id = await saveWorkout(name, poolLength, sets, templateId);
return id;
} finally {
setIsSaving(false);
}
},
[workoutName, sets]
);
const saveAsTemplate = useCallback(
async (templateName: string, poolLength: number) => {
setIsSaving(true);
try {
const id = await saveTemplate(templateName, poolLength, sets);
return id;
} finally {
setIsSaving(false);
}
},
[sets]
);
const loadTemplate = useCallback((templateSets: WorkoutSet[], name?: string) => {
setSets(templateSets);
if (name) setWorkoutName(name);
}, []);
const reset = useCallback(() => {
setSets([{ ...DEFAULT_SET }]);
setWorkoutName("");
}, []);
return {
sets,
workoutName,
setWorkoutName,
totalDistance,
isSaving,
addSet,
updateSet,
removeSet,
save,
saveAsTemplate,
loadTemplate,
reset,
};
}
@@ -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>
);
}
@@ -0,0 +1,63 @@
import ExpoModulesCore
public class WatchConnectivityModule: Module {
public func definition() -> Definition {
Name("WatchConnectivity")
AsyncFunction("isSupported") { () -> Bool in
return WCSession.isSupported()
}
AsyncFunction("isPaired") { () -> Bool in
return WCSession.default.isPaired
}
AsyncFunction("isWatchAppInstalled") { () -> Bool in
return WCSession.default.isWatchAppInstalled
}
AsyncFunction("isReachable") { () -> Bool in
return WCSession.default.isReachable
}
AsyncFunction("sendWorkoutPlan") { (plan: [String: Any], promise: Promise) in
var message = plan
message["type"] = "workoutPlan"
if WCSession.default.isReachable {
WCSession.default.sendMessage(message, replyHandler: { reply in
promise.resolve(reply)
}, errorHandler: { error in
promise.reject(error)
})
} else {
WCSession.default.transferUserInfo(message)
promise.resolve(["status": "queued"])
}
}
AsyncFunction("sendWorkoutCompletion") { (data: [String: Any], promise: Promise) in
var message = data
message["type"] = "workoutCompleted"
if WCSession.default.isReachable {
WCSession.default.sendMessage(message, replyHandler: { reply in
promise.resolve(reply)
}, errorHandler: { error in
promise.reject(error)
})
} else {
WCSession.default.transferUserInfo(message)
promise.resolve(["status": "queued"])
}
}
AsyncFunction("getApplicationContext") { () -> [String: Any]? in
return WCSession.default.receivedApplicationContext as? [String: Any]
}
AsyncFunction("updateApplicationContext") { (context: [String: Any]) in
try? WCSession.default.updateApplicationContext(context)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import { requireNativeModule } from "expo-modules-core";
export default requireNativeModule("WatchConnectivity");
+35
View File
@@ -0,0 +1,35 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { HistoryStackParamList } from "./types";
import { HistoryScreen } from "../features/history/screens/HistoryScreen";
import { SessionDetailScreen } from "../features/history/screens/SessionDetailScreen";
import { StatsScreen } from "../features/history/screens/StatsScreen";
const Stack = createNativeStackNavigator<HistoryStackParamList>();
export function HistoryNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: "#0ea5e9" },
headerTintColor: "#fff",
headerTitleStyle: { fontWeight: "bold" },
}}
>
<Stack.Screen
name="HistoryList"
component={HistoryScreen}
options={{ title: "History" }}
/>
<Stack.Screen
name="SessionDetail"
component={SessionDetailScreen}
options={{ title: "Session Detail" }}
/>
<Stack.Screen
name="Stats"
component={StatsScreen}
options={{ title: "Statistics" }}
/>
</Stack.Navigator>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { MainTabParamList } from "./types";
import { WorkoutNavigator } from "./WorkoutNavigator";
import { HistoryNavigator } from "./HistoryNavigator";
import { SettingsScreen } from "../features/settings/screens/SettingsScreen";
const Tab = createBottomTabNavigator<MainTabParamList>();
export function MainTabNavigator() {
return (
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: "#0ea5e9",
tabBarInactiveTintColor: "#94a3b8",
headerStyle: { backgroundColor: "#0ea5e9" },
headerTintColor: "#fff",
headerTitleStyle: { fontWeight: "bold" },
}}
>
<Tab.Screen
name="Workouts"
component={WorkoutNavigator}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => {
// Placeholder — replace with Expo vector icons later
return null;
},
}}
/>
<Tab.Screen
name="History"
component={HistoryNavigator}
options={{
headerShown: false,
tabBarIcon: ({ color, size }) => {
return null;
},
}}
/>
<Tab.Screen
name="Settings"
component={SettingsScreen}
options={{
tabBarIcon: ({ color, size }) => {
return null;
},
}}
/>
</Tab.Navigator>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { MainTabNavigator } from "./MainTabNavigator";
import { RootStackParamList } from "./types";
import { BuilderScreen } from "../features/workout-builder/screens/BuilderScreen";
import { PreSwimScreen } from "../features/active-workout/screens/PreSwimScreen";
import { PostSwimScreen } from "../features/active-workout/screens/PostSwimScreen";
import { SessionDetailScreen } from "../features/history/screens/SessionDetailScreen";
import { StatsScreen } from "../features/history/screens/StatsScreen";
const Stack = createNativeStackNavigator<RootStackParamList>();
export function RootNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: "#0ea5e9" },
headerTintColor: "#fff",
headerTitleStyle: { fontWeight: "bold" },
}}
>
<Stack.Screen
name="Main"
component={MainTabNavigator}
options={{ headerShown: false }}
/>
<Stack.Screen
name="WorkoutBuilder"
component={BuilderScreen}
options={{ title: "New Workout" }}
/>
<Stack.Screen
name="PreSwim"
component={PreSwimScreen}
options={{ title: "Ready to Swim" }}
/>
<Stack.Screen
name="PostSwim"
component={PostSwimScreen}
options={{ title: "Swim Complete", headerLeft: () => null }}
/>
<Stack.Screen
name="SessionDetail"
component={SessionDetailScreen}
options={{ title: "Session" }}
/>
<Stack.Screen
name="Stats"
component={StatsScreen}
options={{ title: "Statistics" }}
/>
</Stack.Navigator>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { WorkoutStackParamList } from "./types";
import { WorkoutListScreen } from "../features/workout-builder/screens/WorkoutListScreen";
import { BuilderScreen } from "../features/workout-builder/screens/BuilderScreen";
import { TemplatesScreen } from "../features/templates/screens/TemplatesScreen";
import { PreSwimScreen } from "../features/active-workout/screens/PreSwimScreen";
const Stack = createNativeStackNavigator<WorkoutStackParamList>();
export function WorkoutNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: "#0ea5e9" },
headerTintColor: "#fff",
headerTitleStyle: { fontWeight: "bold" },
}}
>
<Stack.Screen
name="WorkoutList"
component={WorkoutListScreen}
options={{ title: "Workouts" }}
/>
<Stack.Screen
name="Builder"
component={BuilderScreen}
options={{ title: "Build Workout" }}
/>
<Stack.Screen
name="Templates"
component={TemplatesScreen}
options={{ title: "Templates" }}
/>
<Stack.Screen
name="PreSwim"
component={PreSwimScreen}
options={{ title: "Start Swim" }}
/>
</Stack.Navigator>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { NativeStackScreenProps } from "@react-navigation/native-stack";
export type RootStackParamList = {
Main: undefined;
WorkoutBuilder: { templateId?: string } | undefined;
PreSwim: { workoutId: string };
PostSwim: { workoutId: string };
SessionDetail: { sessionId: string };
Stats: undefined;
};
export type MainTabParamList = {
Dashboard: undefined;
Workouts: undefined;
History: undefined;
Settings: undefined;
};
export type WorkoutStackParamList = {
WorkoutList: undefined;
Builder: { templateId?: string } | undefined;
Templates: undefined;
PreSwim: { workoutId: string };
};
export type HistoryStackParamList = {
HistoryList: undefined;
SessionDetail: { sessionId: string };
Stats: undefined;
};
export type RootStackProps<T extends keyof RootStackParamList> =
NativeStackScreenProps<RootStackParamList, T>;
export type WorkoutStackProps<T extends keyof WorkoutStackParamList> =
NativeStackScreenProps<WorkoutStackParamList, T>;
export type HistoryStackProps<T extends keyof HistoryStackParamList> =
NativeStackScreenProps<HistoryStackParamList, T>;
+10
View File
@@ -0,0 +1,10 @@
import { SQLiteProvider } from "expo-sqlite";
import { DATABASE_NAME } from "../db/client";
export function DatabaseProvider({ children }: { children: React.ReactNode }) {
return (
<SQLiteProvider databaseName={DATABASE_NAME} useSuspense>
{children}
</SQLiteProvider>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { MMKV } from "react-native-mmkv";
const storage = new MMKV();
type SettingsContextType = {
poolLength: number;
setPoolLength: (length: number) => void;
};
const SettingsContext = createContext<SettingsContextType>({
poolLength: 25,
setPoolLength: () => {},
});
export function useSettings() {
return useContext(SettingsContext);
}
export function SettingsProvider({ children }: { children: ReactNode }) {
const [poolLength, setPoolLengthState] = useState<number>(() => {
const stored = storage.getNumber("poolLength");
return stored ?? 25;
});
const setPoolLength = (length: number) => {
setPoolLengthState(length);
storage.set("poolLength", length);
};
return (
<SettingsContext.Provider value={{ poolLength, setPoolLength }}>
{children}
</SettingsContext.Provider>
);
}
+1
View File
@@ -0,0 +1 @@
export type WorkoutSource = "watch" | "manual";
+28
View File
@@ -0,0 +1,28 @@
export type StrokeType = "Freestyle" | "Backstroke" | "Breaststroke" | "Butterfly" | "Mixed";
export type RestType = "fixed_rest" | "leave_time" | "none";
export type WorkoutSet = {
stroke: StrokeType;
distancePerRep: number;
repCount: number;
restType: RestType;
restDuration: number;
targetTime?: number;
};
export type WorkoutPlan = {
name?: string;
poolLength: number;
sets: WorkoutSet[];
};
export type LapData = {
setPosition: number;
repNumber: number;
distance: number;
duration: number;
strokeCount?: number;
heartRate?: number;
swolf?: number;
};
+15
View File
@@ -0,0 +1,15 @@
import { type WorkoutSet } from "@types/workout";
export function calculateTotalDistance(sets: WorkoutSet[]): number {
return sets.reduce((acc, set) => acc + set.distancePerRep * set.repCount, 0);
}
export function calculateTotalDuration(sets: WorkoutSet[]): number {
return sets.reduce((acc, set) => {
const swimTime = set.targetTime
? set.targetTime * set.repCount
: 0;
const restTime = set.restType === "none" ? 0 : set.restDuration * (set.repCount - 1);
return acc + swimTime + restTime;
}, 0);
}
+15
View File
@@ -0,0 +1,15 @@
export function calculateSwolf(strokeCount: number, durationSeconds: number): number {
return strokeCount + Math.round(durationSeconds);
}
export function calculateAvgSwolf(
laps: { strokeCount?: number; duration: number }[]
): number {
const validLaps = laps.filter((l) => l.strokeCount != null);
if (validLaps.length === 0) return 0;
const total = validLaps.reduce(
(acc, l) => acc + calculateSwolf(l.strokeCount!, l.duration),
0
);
return Math.round(total / validLaps.length);
}
+6
View File
@@ -0,0 +1,6 @@
export function formatDistance(meters: number): string {
if (meters >= 1000) {
return `${(meters / 1000).toFixed(1)}km`;
}
return `${meters}m`;
}
+18
View File
@@ -0,0 +1,18 @@
export function formatTime(seconds: number): string {
const hrs = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hrs > 0) {
return `${hrs}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
}
return `${mins}:${String(secs).padStart(2, "0")}`;
}
export function formatPace(distanceMeters: number, durationSeconds: number): string {
if (distanceMeters === 0) return "--:--";
const paceSeconds = (durationSeconds / distanceMeters) * 100;
const mins = Math.floor(paceSeconds / 60);
const secs = Math.floor(paceSeconds % 60);
return `${mins}:${String(secs).padStart(2, "0")}/100m`;
}