refactor(workout): extract shared WorkoutSummary component from PostSwim and SessionDetail screens

This commit is contained in:
2026-08-08 12:27:59 +02:00
parent 38082afdb9
commit 20e6e73c81
12 changed files with 314 additions and 151 deletions
@@ -0,0 +1,187 @@
import { View, Text, ScrollView, Pressable, type ReactNode } from "react-native";
import { useTranslation } from "react-i18next";
import { formatTime, formatPace } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
import { STROKE_COLORS } from "@constants/strokes";
type Set = {
stroke: string;
distancePerRep: number;
repCount: number;
position: number;
};
type Lap = {
setPosition: number;
repNumber: number;
distance: number;
duration: number;
heartRate: number | null;
swolf: number | null;
};
type Props = {
name?: string | null;
date?: string;
totalDistance: number;
totalDuration: number;
calories?: number | null;
avgHeartRate?: number | null;
poolLength: number;
sets: Set[];
laps: Lap[];
avgSwolf: number | null;
onDone?: () => void;
rightAction?: ReactNode;
};
export function WorkoutSummary({
name,
date,
totalDistance,
totalDuration,
calories,
avgHeartRate,
poolLength,
sets,
laps,
avgSwolf,
onDone,
rightAction,
}: Props) {
const { t } = useTranslation("active-workout");
const { t: tc } = useTranslation("common");
return (
<ScrollView className="flex-1 bg-white">
<View className="p-4">
{/* Header */}
<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">
{name || tc("swimSession")}
</Text>
<Text className="text-slate-500 text-sm">
{date || tc("pool", { length: poolLength })}
</Text>
</View>
{rightAction}
</View>
{/* Stats grid */}
<View className="flex-row gap-3 mb-4">
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">
{formatDistance(totalDistance)}
</Text>
<Text className="text-xs text-slate-500 mt-1">{tc("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(totalDuration)}
</Text>
<Text className="text-xs text-slate-500 mt-1">{tc("duration")}</Text>
</View>
</View>
<View className="flex-row gap-3 mb-4">
{calories != null && calories > 0 && (
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">
{Math.round(calories)}
</Text>
<Text className="text-xs text-slate-500 mt-1">{t("calories")}</Text>
</View>
)}
{avgHeartRate != null && avgHeartRate > 0 && (
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">{avgHeartRate}</Text>
<Text className="text-xs text-slate-500 mt-1">{t("avgHeartRate")}</Text>
</View>
)}
</View>
{/* SWOLF */}
{avgSwolf != null && avgSwolf > 0 && (
<View className="bg-slate-50 rounded-2xl p-4 mb-4 flex-row justify-between items-center">
<Text className="text-sm font-semibold text-slate-700">{tc("avgSwolf")}</Text>
<Text className="text-xl font-bold text-slate-900">{avgSwolf}</Text>
</View>
)}
{/* Sets */}
{sets.length > 0 && (
<>
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-2">
{t("sets")}
</Text>
{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">
{tc(`stroke.${set.stroke.toLowerCase()}`)}
</Text>
</View>
<Text className="text-slate-600">
{set.repCount}×{set.distancePerRep}m
</Text>
</View>
))}
</>
)}
{/* Laps */}
{laps.length > 0 && (
<>
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mt-6 mb-2">
{t("lapsDetail")}
</Text>
{laps.map((lap, i) => (
<View
key={i}
className="flex-row items-center justify-between py-2.5 border-b border-slate-100"
>
<View className="flex-1">
<Text className="text-slate-700 text-sm">
{t("setRep", { set: lap.setPosition, rep: lap.repNumber })}
</Text>
<Text className="text-slate-400 text-xs">{lap.distance}m</Text>
</View>
<View className="items-end">
<Text className="text-slate-900 font-medium">{formatTime(lap.duration)}</Text>
<Text className="text-slate-400 text-xs">
{formatPace(lap.distance, lap.duration)}
</Text>
</View>
{lap.heartRate != null && lap.heartRate > 0 && (
<View className="ml-3 items-end">
<Text className="text-red-500 text-xs">♥ {lap.heartRate}</Text>
</View>
)}
</View>
))}
</>
)}
</View>
{/* Done button */}
{onDone && (
<View className="p-4 pb-8">
<Pressable
onPress={onDone}
className="bg-slate-900 rounded-2xl py-4 items-center"
>
<Text className="text-white font-bold text-lg">{tc("done")}</Text>
</Pressable>
</View>
)}
</ScrollView>
);
}
@@ -1,23 +1,42 @@
import { useEffect, useState } from "react";
import { View, Text, Pressable } from "react-native";
import { View, Text } from "react-native";
import { useTranslation } from "react-i18next";
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 type { RootStackParamList } from "@navigation/types";
import { getWorkoutWithSets, getWorkoutLaps } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
import { WorkoutSummary } from "../components/WorkoutSummary";
type Set = {
stroke: string;
distancePerRep: number;
repCount: number;
position: number;
};
type Lap = {
setPosition: number;
repNumber: number;
distance: number;
duration: number;
heartRate: number | null;
swolf: number | null;
};
export function PostSwimScreen() {
const { t } = useTranslation("active-workout");
const { t: tc } = useTranslation("common");
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, "PostSwim">>();
const [summary, setSummary] = useState<{
distance: number;
duration: number;
laps: number;
const [data, setData] = useState<{
name: string | null;
poolLength: number;
totalDistance: number;
totalDuration: number;
calories: number | null;
avgHeartRate: number | null;
sets: Set[];
laps: Lap[];
avgSwolf: number | null;
} | null>(null);
@@ -37,59 +56,39 @@ export function PostSwimScreen() {
? Math.round(laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / laps.length)
: null;
setSummary({
distance: workout.totalDistance ?? 0,
duration: totalDuration,
laps: laps.length,
setData({
name: workout.name,
poolLength: workout.poolLength,
totalDistance: workout.totalDistance ?? 0,
totalDuration,
calories: workout.calories,
avgHeartRate: workout.avgHeartRate,
sets: workout.sets ?? [],
laps,
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">{t("swimComplete")}</Text>
<Text className="text-slate-500">{t("greatJob")}</Text>
if (!data) {
return (
<View className="flex-1 bg-white items-center justify-center">
<Text className="text-slate-400">{t("loadingSummary")}</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">{tc("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">{tc("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">{tc("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">{tc("avgSwolf")}</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">{t("loadingSummary")}</Text>
</View>
)}
<Pressable
onPress={() => navigation.popToTop()}
className="bg-slate-900 rounded-2xl py-4 items-center"
>
<Text className="text-white font-bold text-lg">{tc("done")}</Text>
</Pressable>
</View>
return (
<WorkoutSummary
name={data.name}
totalDistance={data.totalDistance}
totalDuration={data.totalDuration}
calories={data.calories}
avgHeartRate={data.avgHeartRate}
poolLength={data.poolLength}
sets={data.sets}
laps={data.laps}
avgSwolf={data.avgSwolf}
onDone={() => navigation.popToTop()}
/>
);
}
@@ -1,12 +1,10 @@
import { useEffect, useState } from "react";
import { View, Text, ScrollView, Pressable, Alert } from "react-native";
import { View, Text, Pressable, Alert } from "react-native";
import { useTranslation } from "react-i18next";
import { useNavigation } from "@react-navigation/native";
import type { HistoryStackProps } from "@navigation/types";
import { getWorkoutWithSets, deleteWorkout } from "@db/operations";
import { formatTime } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance";
import { STROKE_COLORS } from "@constants/strokes";
import { WorkoutSummary } from "../../active-workout/components/WorkoutSummary";
type WorkoutDetail = {
id: string;
@@ -29,14 +27,12 @@ type WorkoutDetail = {
repNumber: number;
distance: number;
duration: number;
strokeCount: number | null;
heartRate: number | null;
swolf: number | null;
}[];
};
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
const { t } = useTranslation("history");
const { t: tc } = useTranslation("common");
const navigation = useNavigation();
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
@@ -80,95 +76,22 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
: null;
return (
<ScrollView className="flex-1 bg-white p-4">
<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 || tc("swimSession")}
</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"
>
<WorkoutSummary
name={workout.name}
date={new Date(workout.startedAt).toLocaleDateString()}
totalDistance={workout.totalDistance ?? 0}
totalDuration={workout.totalDuration ?? 0}
calories={workout.calories}
avgHeartRate={workout.avgHeartRate}
poolLength={workout.poolLength}
sets={workout.sets}
laps={workout.laps}
avgSwolf={avgSwolf}
rightAction={
<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">
<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">{tc("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">{tc("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">{tc("avgSwolf")}</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">
{tc("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">{tc(`stroke.${set.stroke.toLowerCase()}`)}</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">
{tc("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">
{t("lap", { number: i + 1 })} · {lap.distance}m
</Text>
<Text className="text-slate-600 font-medium">
{formatTime(lap.duration)}
</Text>
</View>
))}
</>
)}
</ScrollView>
}
/>
);
}