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",