feat: initial project scaffold with Watch companion app
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user