From 20e6e73c816353afb73fe3267e261b1835e02429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB?= Date: Sat, 8 Aug 2026 12:27:59 +0200 Subject: [PATCH] refactor(workout): extract shared WorkoutSummary component from PostSwim and SessionDetail screens --- .../components/WorkoutSummary.tsx | 187 ++++++++++++++++++ .../active-workout/screens/PostSwimScreen.tsx | 113 ++++++----- .../history/screens/SessionDetailScreen.tsx | 111 ++--------- src/i18n/de/active-workout.json | 6 + src/i18n/dk/active-workout.json | 6 + src/i18n/en/active-workout.json | 6 + src/i18n/es/active-workout.json | 6 + src/i18n/fr/active-workout.json | 6 + src/i18n/it/active-workout.json | 6 + src/i18n/nl/active-workout.json | 6 + src/i18n/pl/active-workout.json | 6 + src/i18n/pt/active-workout.json | 6 + 12 files changed, 314 insertions(+), 151 deletions(-) create mode 100644 src/features/active-workout/components/WorkoutSummary.tsx diff --git a/src/features/active-workout/components/WorkoutSummary.tsx b/src/features/active-workout/components/WorkoutSummary.tsx new file mode 100644 index 0000000..abe7725 --- /dev/null +++ b/src/features/active-workout/components/WorkoutSummary.tsx @@ -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 ( + + + {/* Header */} + + + + {name || tc("swimSession")} + + + {date || tc("pool", { length: poolLength })} + + + {rightAction} + + + {/* Stats grid */} + + + + {formatDistance(totalDistance)} + + {tc("distance")} + + + + {formatTime(totalDuration)} + + {tc("duration")} + + + + + {calories != null && calories > 0 && ( + + + {Math.round(calories)} + + {t("calories")} + + )} + {avgHeartRate != null && avgHeartRate > 0 && ( + + {avgHeartRate} + {t("avgHeartRate")} + + )} + + + {/* SWOLF */} + {avgSwolf != null && avgSwolf > 0 && ( + + {tc("avgSwolf")} + {avgSwolf} + + )} + + {/* Sets */} + {sets.length > 0 && ( + <> + + {t("sets")} + + {sets.map((set, i) => ( + + + + + {tc(`stroke.${set.stroke.toLowerCase()}`)} + + + + {set.repCount}×{set.distancePerRep}m + + + ))} + + )} + + {/* Laps */} + {laps.length > 0 && ( + <> + + {t("lapsDetail")} + + {laps.map((lap, i) => ( + + + + {t("setRep", { set: lap.setPosition, rep: lap.repNumber })} + + {lap.distance}m + + + {formatTime(lap.duration)} + + {formatPace(lap.distance, lap.duration)} + + + {lap.heartRate != null && lap.heartRate > 0 && ( + + ♥ {lap.heartRate} + + )} + + ))} + + )} + + + {/* Done button */} + {onDone && ( + + + {tc("done")} + + + )} + + ); +} diff --git a/src/features/active-workout/screens/PostSwimScreen.tsx b/src/features/active-workout/screens/PostSwimScreen.tsx index dad8f8f..364abcd 100644 --- a/src/features/active-workout/screens/PostSwimScreen.tsx +++ b/src/features/active-workout/screens/PostSwimScreen.tsx @@ -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>(); const route = useRoute>(); - 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 ( - - - 🏊 - {t("swimComplete")} - {t("greatJob")} + if (!data) { + return ( + + {t("loadingSummary")} + ); + } - {summary ? ( - - - {tc("distance")} - - {formatDistance(summary.distance)} - - - - {tc("duration")} - - {formatTime(summary.duration)} - - - - {tc("laps")} - {summary.laps} - - {summary.avgSwolf && ( - - {tc("avgSwolf")} - {summary.avgSwolf} - - )} - - ) : ( - - {t("loadingSummary")} - - )} - - navigation.popToTop()} - className="bg-slate-900 rounded-2xl py-4 items-center" - > - {tc("done")} - - + return ( + navigation.popToTop()} + /> ); } diff --git a/src/features/history/screens/SessionDetailScreen.tsx b/src/features/history/screens/SessionDetailScreen.tsx index 0d3068d..c525bec 100644 --- a/src/features/history/screens/SessionDetailScreen.tsx +++ b/src/features/history/screens/SessionDetailScreen.tsx @@ -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(null); @@ -80,95 +76,22 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail" : null; return ( - - - - - {workout.name || tc("swimSession")} - - - {new Date(workout.startedAt).toLocaleDateString()} - - - + 🗑 - - - - - - {formatDistance(workout.totalDistance ?? 0)} - - {tc("distance")} - - - - {formatTime(workout.totalDuration ?? 0)} - - {tc("duration")} - - {workout.calories && ( - - - {Math.round(workout.calories)} - - kcal - - )} - - - {avgSwolf && ( - - {tc("avgSwolf")} - {avgSwolf} - - )} - - - {tc("sets")} - - {workout.sets.map((set, i) => ( - - - - {tc(`stroke.${set.stroke.toLowerCase()}`)} - - - {set.repCount}×{set.distancePerRep}m - - - ))} - - {workout.laps.length > 0 && ( - <> - - {tc("laps")} - - {workout.laps.map((lap, i) => ( - - - {t("lap", { number: i + 1 })} · {lap.distance}m - - - {formatTime(lap.duration)} - - - ))} - - )} - + } + /> ); } diff --git a/src/i18n/de/active-workout.json b/src/i18n/de/active-workout.json index b4e682d..40651c9 100644 --- a/src/i18n/de/active-workout.json +++ b/src/i18n/de/active-workout.json @@ -12,6 +12,12 @@ "swimComplete": "Schwimmen abgeschlossen!", "greatJob": "Gut gemacht! Du bist heute ins Wasser gegangen!", "loadingSummary": "Zusammenfassung laden...", + "sets": "Sets", + "lapsDetail": "Bahnen", + "avgHeartRate": "Durchschn. Herzfrequenz", + "calories": "Kalorien", + "pace": "Tempo", + "setRep": "Set {{set}} · Wiederholung {{rep}}", "watchStatus": { "ready": "Watch bereit", "pairedNotInRange": "Watch gekoppelt, aber nicht in Reichweite", diff --git a/src/i18n/dk/active-workout.json b/src/i18n/dk/active-workout.json index 04eb387..b412591 100644 --- a/src/i18n/dk/active-workout.json +++ b/src/i18n/dk/active-workout.json @@ -12,6 +12,12 @@ "swimComplete": "Svømning fuldført!", "greatJob": "Godt gået! Du var i vandet i dag!", "loadingSummary": "Indlæser opsummering...", + "sets": "Sæt", + "lapsDetail": "Baner", + "avgHeartRate": "Gns. puls", + "calories": "Kalorier", + "pace": "Tempo", + "setRep": "Sæt {{set}} · Gentagelse {{rep}}", "watchStatus": { "ready": "Watch klar", "pairedNotInRange": "Watch parret, men ikke inden for rækkevidde", diff --git a/src/i18n/en/active-workout.json b/src/i18n/en/active-workout.json index e9809c8..848d7f0 100644 --- a/src/i18n/en/active-workout.json +++ b/src/i18n/en/active-workout.json @@ -12,6 +12,12 @@ "swimComplete": "Swim Complete!", "greatJob": "Great job getting in the water today!", "loadingSummary": "Loading summary...", + "sets": "Sets", + "lapsDetail": "Laps", + "avgHeartRate": "Avg Heart Rate", + "calories": "Calories", + "pace": "Pace", + "setRep": "Set {{set}} · Rep {{rep}}", "watchStatus": { "ready": "Watch ready", "pairedNotInRange": "Watch paired but not in range", diff --git a/src/i18n/es/active-workout.json b/src/i18n/es/active-workout.json index b831861..de1cc71 100644 --- a/src/i18n/es/active-workout.json +++ b/src/i18n/es/active-workout.json @@ -12,6 +12,12 @@ "swimComplete": "¡Natación completada!", "greatJob": "¡Buen trabajo! ¡Hoy has estado en el agua!", "loadingSummary": "Cargando resumen...", + "sets": "Series", + "lapsDetail": "Largos", + "avgHeartRate": "Freq. card. media", + "calories": "Calorías", + "pace": "Ritmo", + "setRep": "Serie {{set}} · Repetición {{rep}}", "watchStatus": { "ready": "Watch lista", "pairedNotInRange": "Watch emparejada pero fuera de alcance", diff --git a/src/i18n/fr/active-workout.json b/src/i18n/fr/active-workout.json index 395d135..aca57a7 100644 --- a/src/i18n/fr/active-workout.json +++ b/src/i18n/fr/active-workout.json @@ -12,6 +12,12 @@ "swimComplete": "Nage terminée !", "greatJob": "Bravo ! Vous êtes allé à l'eau aujourd'hui !", "loadingSummary": "Chargement du résumé...", + "sets": "Séries", + "lapsDetail": "Longueurs", + "avgHeartRate": "Fréq. cardiaque moy.", + "calories": "Calories", + "pace": "Rythme", + "setRep": "Série {{set}} · Répétition {{rep}}", "watchStatus": { "ready": "Watch prête", "pairedNotInRange": "Watch appariée mais hors de portée", diff --git a/src/i18n/it/active-workout.json b/src/i18n/it/active-workout.json index 7f2d3e4..c65810a 100644 --- a/src/i18n/it/active-workout.json +++ b/src/i18n/it/active-workout.json @@ -12,6 +12,12 @@ "swimComplete": "Nuoto completato!", "greatJob": "Ottimo lavoro! Sei entrato in acqua oggi!", "loadingSummary": "Caricamento riepilogo...", + "sets": "Serie", + "lapsDetail": "Lunghezze", + "avgHeartRate": "Freq. card. media", + "calories": "Calorie", + "pace": "Ritmo", + "setRep": "Serie {{set}} · Ripetizione {{rep}}", "watchStatus": { "ready": "Watch pronta", "pairedNotInRange": "Watch associata ma fuori portata", diff --git a/src/i18n/nl/active-workout.json b/src/i18n/nl/active-workout.json index 936cb24..5d668a4 100644 --- a/src/i18n/nl/active-workout.json +++ b/src/i18n/nl/active-workout.json @@ -12,6 +12,12 @@ "swimComplete": "Zwemmen voltooid!", "greatJob": "Goed gedaan! Je bent vandaag het water in geweest!", "loadingSummary": "Samenvatting laden...", + "sets": "Sets", + "lapsDetail": "Banen", + "avgHeartRate": "Gem. hartslag", + "calories": "Calorieën", + "pace": "Tempo", + "setRep": "Set {{set}} · Herhaling {{rep}}", "watchStatus": { "ready": "Watch gereed", "pairedNotInRange": "Watch gekoppeld maar niet in bereik", diff --git a/src/i18n/pl/active-workout.json b/src/i18n/pl/active-workout.json index eb13c47..5d9ff12 100644 --- a/src/i18n/pl/active-workout.json +++ b/src/i18n/pl/active-workout.json @@ -12,6 +12,12 @@ "swimComplete": "Pływanie zakończone!", "greatJob": "Świetna robota! Dziś byłeś w wodzie!", "loadingSummary": "Ładowanie podsumowania...", + "sets": "Serie", + "lapsDetail": "Długości", + "avgHeartRate": "Śr. tętno", + "calories": "Kalorie", + "pace": "Tempo", + "setRep": "Serie {{set}} · Powtórzenie {{rep}}", "watchStatus": { "ready": "Watch gotowa", "pairedNotInRange": "Watch sparowana, ale poza zasięgiem", diff --git a/src/i18n/pt/active-workout.json b/src/i18n/pt/active-workout.json index ee6b5d3..13da4fc 100644 --- a/src/i18n/pt/active-workout.json +++ b/src/i18n/pt/active-workout.json @@ -12,6 +12,12 @@ "swimComplete": "Natação concluída!", "greatJob": "Bom trabalho! Esteve na água hoje!", "loadingSummary": "A carregar resumo...", + "sets": "Séries", + "lapsDetail": "Comprimentos", + "avgHeartRate": "Freq. card. média", + "calories": "Calorias", + "pace": "Ritmo", + "setRep": "Série {{set}} · Repetição {{rep}}", "watchStatus": { "ready": "Watch pronta", "pairedNotInRange": "Watch associada, mas fora de alcance",