feat(workouts): add swipe-to-delete with gesture handler and pull-to-refresh

This commit is contained in:
2026-08-07 16:55:18 +02:00
parent 48286041c6
commit c14ccf1d2c
12 changed files with 365 additions and 110 deletions
+57 -38
View File
@@ -1,11 +1,13 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable, FlatList } from "react-native";
import { useCallback, useState } from "react";
import { View, Text, Pressable, FlatList, Alert } from "react-native";
import { useNavigation } from "@react-navigation/native";
import { useFocusEffect } from "@react-navigation/native";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
import type { HistoryStackParamList } from "@navigation/types";
import { getAllWorkouts } from "@db/operations";
import { getAllWorkouts, deleteWorkout } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
import { SwipeableRow } from "@components/SwipeableRow";
type WorkoutRow = {
id: string;
@@ -20,16 +22,39 @@ type WorkoutRow = {
export function HistoryScreen() {
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
useEffect(() => {
loadWorkouts();
}, []);
const [refreshing, setRefreshing] = useState(false);
const loadWorkouts = async () => {
const data = await getAllWorkouts();
setWorkouts((data as WorkoutRow[]).filter((w) => w.finishedAt));
};
useFocusEffect(
useCallback(() => {
loadWorkouts();
}, [])
);
const onRefresh = async () => {
setRefreshing(true);
await loadWorkouts();
setRefreshing(false);
};
const onDelete = (id: string) => {
Alert.alert("Delete Workout", "Are you sure?", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
await deleteWorkout(id);
await loadWorkouts();
},
},
]);
};
const grouped = workouts.reduce<Record<string, WorkoutRow[]>>((acc, w) => {
const date = new Date(w.startedAt).toLocaleDateString();
if (!acc[date]) acc[date] = [];
@@ -41,19 +66,12 @@ export function HistoryScreen() {
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"
refreshing={refreshing}
onRefresh={onRefresh}
ListEmptyComponent={
<View className="py-12 items-center">
<Text className="text-slate-400 text-lg">No completed workouts yet</Text>
@@ -65,30 +83,31 @@ export function HistoryScreen() {
{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` : ""}
<SwipeableRow key={w.id} onDelete={() => onDelete(w.id)}>
<Pressable
onPress={() => navigation.navigate("SessionDetail", { sessionId: w.id })}
className="p-4"
>
<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>
<Text className="text-slate-400 text-xs">
{new Date(w.startedAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</Text>
</View>
</Pressable>
</Pressable>
</SwipeableRow>
))}
</View>
)}
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { View, Text, ScrollView } from "react-native";
import { View, Text, ScrollView, Pressable, Alert } from "react-native";
import { useNavigation } from "@react-navigation/native";
import type { HistoryStackProps } from "@navigation/types";
import { getWorkoutWithSets } from "@db/operations";
import { getWorkoutWithSets, deleteWorkout } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
import { STROKE_COLORS } from "@constants/strokes";
@@ -34,6 +35,7 @@ type WorkoutDetail = {
};
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
const navigation = useNavigation();
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
useEffect(() => {
@@ -45,6 +47,20 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
setWorkout(data as WorkoutDetail | null);
};
const handleDelete = () => {
Alert.alert("Delete Workout", "Are you sure?", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
await deleteWorkout(route.params.sessionId);
navigation.goBack();
},
},
]);
};
if (!workout) {
return (
<View className="flex-1 bg-white items-center justify-center">
@@ -62,13 +78,21 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
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 className="flex-row justify-between items-start mb-6">
<View className="flex-1">
<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>
<Pressable
onPress={handleDelete}
className="bg-red-50 rounded-xl p-2.5"
>
<Text className="text-red-500 text-lg">🗑</Text>
</Pressable>
</View>
<View className="flex-row gap-3 mb-6">
@@ -11,7 +11,8 @@ const STROKES: StrokeType[] = ["Freestyle", "Backstroke", "Breaststroke", "Butte
export function BuilderScreen() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { poolLength } = useSettings();
const { poolLength: defaultPoolLength } = useSettings();
const [poolLength, setPoolLength] = useState(defaultPoolLength);
const {
sets,
workoutName,
@@ -53,6 +54,29 @@ export function BuilderScreen() {
/>
</View>
<View className="mb-4">
<Text className="text-sm font-medium text-slate-500 mb-1">Pool Length</Text>
<View className="flex-row gap-2">
{[25, 50].map((length) => (
<Pressable
key={length}
onPress={() => setPoolLength(length)}
className={`flex-1 py-2.5 rounded-xl items-center ${
poolLength === length ? "bg-sky-500" : "bg-slate-100"
}`}
>
<Text
className={`font-semibold ${
poolLength === length ? "text-white" : "text-slate-600"
}`}
>
{length}m
</Text>
</Pressable>
))}
</View>
</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">
@@ -1,11 +1,13 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable, FlatList } from "react-native";
import { useCallback, useState } from "react";
import { View, Text, Pressable, FlatList, Alert } from "react-native";
import { useNavigation } from "@react-navigation/native";
import { useFocusEffect } 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";
import { SwipeableRow } from "@components/SwipeableRow";
type WorkoutRow = {
id: string;
@@ -19,37 +21,56 @@ type WorkoutRow = {
export function WorkoutListScreen() {
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
useEffect(() => {
loadWorkouts();
}, []);
const [refreshing, setRefreshing] = useState(false);
const loadWorkouts = async () => {
const data = await getAllWorkouts();
setWorkouts(data as WorkoutRow[]);
};
useFocusEffect(
useCallback(() => {
loadWorkouts();
}, [])
);
const onRefresh = async () => {
setRefreshing(true);
await loadWorkouts();
setRefreshing(false);
};
const onDelete = (id: string) => {
Alert.alert("Delete Workout", "Are you sure?", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
await deleteWorkout(id);
await loadWorkouts();
},
},
]);
};
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"
className="bg-sky-500 rounded-2xl p-4 items-center mb-4"
>
<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"
refreshing={refreshing}
onRefresh={onRefresh}
ListEmptyComponent={
<View className="py-12 items-center">
<Text className="text-slate-400 text-lg">No workouts yet</Text>
@@ -57,30 +78,32 @@ export function WorkoutListScreen() {
</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>
<SwipeableRow onDelete={() => onDelete(item.id)}>
<Pressable
onPress={() => navigation.navigate("PreSwim", { workoutId: item.id })}
className="p-4"
>
<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>
)}
</View>
</Pressable>
{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>
</SwipeableRow>
)}
/>
</View>