Compare commits

..
2 Commits
13 changed files with 133 additions and 34 deletions
+18 -20
View File
@@ -1,6 +1,6 @@
import { useRef } from "react"; import { useRef } from "react";
import { View, Text, Pressable, Animated, StyleSheet } from "react-native"; import { View, Text, Pressable, Animated, StyleSheet } from "react-native";
import { PanGestureHandler, State } from "react-native-gesture-handler"; import { PanGestureHandler, State, PanGestureHandlerGestureEvent } from "react-native-gesture-handler";
type SwipeableRowProps = { type SwipeableRowProps = {
children: React.ReactNode; children: React.ReactNode;
@@ -8,36 +8,33 @@ type SwipeableRowProps = {
deleteLabel?: string; deleteLabel?: string;
}; };
const MAX_LEFT = -80;
export function SwipeableRow({ children, onDelete, deleteLabel = "Delete" }: SwipeableRowProps) { export function SwipeableRow({ children, onDelete, deleteLabel = "Delete" }: SwipeableRowProps) {
const translateX = useRef(new Animated.Value(0)).current; const translateX = useRef(new Animated.Value(0)).current;
const lastOffset = useRef(0); const offset = useRef(0);
const onGestureEvent = Animated.event([{ nativeEvent: { translationX: translateX } }], { const onGestureEvent = (event: PanGestureHandlerGestureEvent) => {
useNativeDriver: true, const raw = event.nativeEvent.translationX;
}); const clamped = Math.max(MAX_LEFT, Math.min(0, offset.current + raw));
translateX.setValue(clamped);
};
const onHandlerStateChange = (event: any) => { const onHandlerStateChange = (event: PanGestureHandlerGestureEvent) => {
if (event.oldState === State.ACTIVE) { if (event.oldState === State.ACTIVE) {
const { translationX } = event.nativeEvent; const { translationX } = event.nativeEvent;
const newOffset = lastOffset.current + translationX; const final = Math.max(MAX_LEFT, Math.min(0, offset.current + translationX));
if (newOffset < -80) { Animated.spring(translateX, {
Animated.spring(translateX, { toValue: final,
toValue: -80, useNativeDriver: true,
useNativeDriver: true, }).start();
}).start(); offset.current = final;
lastOffset.current = -80;
} else {
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: true,
}).start();
lastOffset.current = 0;
}
} }
}; };
const handleDelete = () => { const handleDelete = () => {
offset.current = -400;
Animated.timing(translateX, { Animated.timing(translateX, {
toValue: -400, toValue: -400,
duration: 200, duration: 200,
@@ -57,6 +54,7 @@ export function SwipeableRow({ children, onDelete, deleteLabel = "Delete" }: Swi
<PanGestureHandler <PanGestureHandler
onGestureEvent={onGestureEvent} onGestureEvent={onGestureEvent}
onHandlerStateChange={onHandlerStateChange} onHandlerStateChange={onHandlerStateChange}
failOffsetY={[-5, 5]}
> >
<Animated.View style={[styles.foreground, { transform: [{ translateX }] }]}> <Animated.View style={[styles.foreground, { transform: [{ translateX }] }]}>
{children} {children}
@@ -1,14 +1,23 @@
import { View, Text, ScrollView, Pressable, type ReactNode } from "react-native"; import { View, Text, ScrollView, Pressable } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatTime, formatPace } from "@utils/formatTime"; import { formatTime, formatPace } from "@utils/formatTime";
import { formatDistance } from "@utils/formatDistance"; import { formatDistance } from "@utils/formatDistance";
import { STROKE_COLORS } from "@constants/strokes"; import { STROKE_COLORS } from "@constants/strokes";
function formatPoolPace(seconds: number, poolLength: number): string {
if (poolLength === 0) return "--:--";
const pace = seconds / poolLength;
const mins = Math.floor(pace / 60);
const secs = Math.floor(pace % 60);
return `${mins}:${String(secs).padStart(2, "0")}`;
}
type Set = { type Set = {
stroke: string; stroke: string;
distancePerRep: number; distancePerRep: number;
repCount: number; repCount: number;
position: number; position: number;
poolPace?: number | null;
}; };
type Lap = { type Lap = {
@@ -25,14 +34,15 @@ type Props = {
date?: string; date?: string;
totalDistance: number; totalDistance: number;
totalDuration: number; totalDuration: number;
totalLengths?: number | null;
calories?: number | null; calories?: number | null;
avgHeartRate?: number | null; avgHeartRate?: number | null;
avgPace?: number | null;
poolLength: number; poolLength: number;
sets: Set[]; sets: Set[];
laps: Lap[]; laps: Lap[];
avgSwolf: number | null; avgSwolf: number | null;
onDone?: () => void; onDone?: () => void;
rightAction?: ReactNode;
}; };
export function WorkoutSummary({ export function WorkoutSummary({
@@ -40,14 +50,15 @@ export function WorkoutSummary({
date, date,
totalDistance, totalDistance,
totalDuration, totalDuration,
totalLengths,
calories, calories,
avgHeartRate, avgHeartRate,
avgPace,
poolLength, poolLength,
sets, sets,
laps, laps,
avgSwolf, avgSwolf,
onDone, onDone,
rightAction,
}: Props) { }: Props) {
const { t } = useTranslation("active-workout"); const { t } = useTranslation("active-workout");
const { t: tc } = useTranslation("common"); const { t: tc } = useTranslation("common");
@@ -65,7 +76,6 @@ export function WorkoutSummary({
{date || tc("pool", { length: poolLength })} {date || tc("pool", { length: poolLength })}
</Text> </Text>
</View> </View>
{rightAction}
</View> </View>
{/* Stats grid */} {/* Stats grid */}
@@ -99,8 +109,25 @@ export function WorkoutSummary({
<Text className="text-xs text-slate-500 mt-1">{t("avgHeartRate")}</Text> <Text className="text-xs text-slate-500 mt-1">{t("avgHeartRate")}</Text>
</View> </View>
)} )}
{totalLengths != null && totalLengths > 0 && (
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
<Text className="text-2xl font-bold text-sky-600">{totalLengths}</Text>
<Text className="text-xs text-slate-500 mt-1">{t("lengths")}</Text>
</View>
)}
</View> </View>
{avgPace != null && avgPace > 0 && (
<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">
{formatPace(totalDistance, totalDuration)}
</Text>
<Text className="text-xs text-slate-500 mt-1">{t("avgPace")}</Text>
</View>
</View>
)}
{/* SWOLF */} {/* SWOLF */}
{avgSwolf != null && avgSwolf > 0 && ( {avgSwolf != null && avgSwolf > 0 && (
<View className="bg-slate-50 rounded-2xl p-4 mb-4 flex-row justify-between items-center"> <View className="bg-slate-50 rounded-2xl p-4 mb-4 flex-row justify-between items-center">
@@ -129,9 +156,16 @@ export function WorkoutSummary({
{tc(`stroke.${set.stroke.toLowerCase()}`)} {tc(`stroke.${set.stroke.toLowerCase()}`)}
</Text> </Text>
</View> </View>
<Text className="text-slate-600"> <View className="items-end">
{set.repCount}×{set.distancePerRep}m <Text className="text-slate-600">
</Text> {set.repCount}×{set.distancePerRep}m
</Text>
{set.poolPace != null && set.poolPace > 0 && (
<Text className="text-slate-400 text-xs">
{formatPoolPace(set.poolPace, poolLength)}/pool
</Text>
)}
</View>
</View> </View>
))} ))}
</> </>
@@ -56,6 +56,24 @@ export function PostSwimScreen() {
? Math.round(laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / laps.length) ? Math.round(laps.reduce((acc, l) => acc + (l.swolf ?? 0), 0) / laps.length)
: null; : null;
const totalLengths =
workout.totalDistance && workout.poolLength
? Math.round(workout.totalDistance / workout.poolLength)
: null;
const avgPace =
workout.totalDistance && totalDuration
? workout.totalDistance / totalDuration
: null;
const setsWithPace = (workout.sets ?? []).map((set: any) => {
const setLaps = laps.filter((l: any) => l.setPosition === set.position);
const setDuration = setLaps.reduce((acc: number, l: any) => acc + l.duration, 0);
const setDistance = setLaps.reduce((acc: number, l: any) => acc + l.distance, 0);
const poolPace = setDistance > 0 ? setDuration / setDistance : null;
return { ...set, poolPace };
});
setData({ setData({
name: workout.name, name: workout.name,
poolLength: workout.poolLength, poolLength: workout.poolLength,
@@ -63,9 +81,11 @@ export function PostSwimScreen() {
totalDuration, totalDuration,
calories: workout.calories, calories: workout.calories,
avgHeartRate: workout.avgHeartRate, avgHeartRate: workout.avgHeartRate,
sets: workout.sets ?? [], sets: setsWithPace,
laps, laps,
avgSwolf, avgSwolf,
totalLengths,
avgPace,
}); });
}; };
@@ -82,8 +102,10 @@ export function PostSwimScreen() {
name={data.name} name={data.name}
totalDistance={data.totalDistance} totalDistance={data.totalDistance}
totalDuration={data.totalDuration} totalDuration={data.totalDuration}
totalLengths={data.totalLengths}
calories={data.calories} calories={data.calories}
avgHeartRate={data.avgHeartRate} avgHeartRate={data.avgHeartRate}
avgPace={data.avgPace}
poolLength={data.poolLength} poolLength={data.poolLength}
sets={data.sets} sets={data.sets}
laps={data.laps} laps={data.laps}
@@ -60,6 +60,18 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
]); ]);
}; };
useEffect(() => {
if (workout) {
navigation.setOptions({
headerRight: () => (
<Pressable onPress={handleDelete} hitSlop={8}>
<Text style={{ color: "#fff", fontSize: 18 }}>🗑</Text>
</Pressable>
),
});
}
}, [workout]);
if (!workout) { if (!workout) {
return ( return (
<View className="flex-1 bg-white items-center justify-center"> <View className="flex-1 bg-white items-center justify-center">
@@ -75,23 +87,38 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
) )
: null; : null;
const totalLengths =
workout.totalDistance && workout.poolLength
? Math.round(workout.totalDistance / workout.poolLength)
: null;
const avgPace =
workout.totalDistance && workout.totalDuration
? workout.totalDistance / workout.totalDuration
: null;
const setsWithPace = workout.sets.map((set) => {
const setLaps = workout.laps.filter((l) => l.setPosition === set.position);
const setDuration = setLaps.reduce((acc, l) => acc + l.duration, 0);
const setDistance = setLaps.reduce((acc, l) => acc + l.distance, 0);
const poolPace = setDistance > 0 ? setDuration / setDistance : null;
return { ...set, poolPace };
});
return ( return (
<WorkoutSummary <WorkoutSummary
name={workout.name} name={workout.name}
date={new Date(workout.startedAt).toLocaleDateString()} date={new Date(workout.startedAt).toLocaleDateString()}
totalDistance={workout.totalDistance ?? 0} totalDistance={workout.totalDistance ?? 0}
totalDuration={workout.totalDuration ?? 0} totalDuration={workout.totalDuration ?? 0}
totalLengths={totalLengths}
calories={workout.calories} calories={workout.calories}
avgHeartRate={workout.avgHeartRate} avgHeartRate={workout.avgHeartRate}
avgPace={avgPace}
poolLength={workout.poolLength} poolLength={workout.poolLength}
sets={workout.sets} sets={setsWithPace}
laps={workout.laps} laps={workout.laps}
avgSwolf={avgSwolf} avgSwolf={avgSwolf}
rightAction={
<Pressable onPress={handleDelete} className="bg-red-50 rounded-xl p-2.5">
<Text className="text-red-500 text-lg">🗑</Text>
</Pressable>
}
/> />
); );
} }
+2
View File
@@ -17,6 +17,8 @@
"avgHeartRate": "Durchschn. Herzfrequenz", "avgHeartRate": "Durchschn. Herzfrequenz",
"calories": "Kalorien", "calories": "Kalorien",
"pace": "Tempo", "pace": "Tempo",
"lengths": "Bahnen",
"avgPace": "Ø Tempo",
"setRep": "Set {{set}} · Wiederholung {{rep}}", "setRep": "Set {{set}} · Wiederholung {{rep}}",
"watchStatus": { "watchStatus": {
"ready": "Watch bereit", "ready": "Watch bereit",
+2
View File
@@ -17,6 +17,8 @@
"avgHeartRate": "Gns. puls", "avgHeartRate": "Gns. puls",
"calories": "Kalorier", "calories": "Kalorier",
"pace": "Tempo", "pace": "Tempo",
"lengths": "Baner",
"avgPace": "Snit-tempo",
"setRep": "Sæt {{set}} · Gentagelse {{rep}}", "setRep": "Sæt {{set}} · Gentagelse {{rep}}",
"watchStatus": { "watchStatus": {
"ready": "Watch klar", "ready": "Watch klar",
+2
View File
@@ -17,6 +17,8 @@
"avgHeartRate": "Avg Heart Rate", "avgHeartRate": "Avg Heart Rate",
"calories": "Calories", "calories": "Calories",
"pace": "Pace", "pace": "Pace",
"lengths": "Lengths",
"avgPace": "Avg pace",
"setRep": "Set {{set}} · Rep {{rep}}", "setRep": "Set {{set}} · Rep {{rep}}",
"watchStatus": { "watchStatus": {
"ready": "Watch ready", "ready": "Watch ready",
+2
View File
@@ -17,6 +17,8 @@
"avgHeartRate": "Freq. card. media", "avgHeartRate": "Freq. card. media",
"calories": "Calorías", "calories": "Calorías",
"pace": "Ritmo", "pace": "Ritmo",
"lengths": "Tiradas",
"avgPace": "Ritmo medio",
"setRep": "Serie {{set}} · Repetición {{rep}}", "setRep": "Serie {{set}} · Repetición {{rep}}",
"watchStatus": { "watchStatus": {
"ready": "Watch lista", "ready": "Watch lista",
+2
View File
@@ -17,6 +17,8 @@
"avgHeartRate": "Fréq. cardiaque moy.", "avgHeartRate": "Fréq. cardiaque moy.",
"calories": "Calories", "calories": "Calories",
"pace": "Rythme", "pace": "Rythme",
"lengths": "Longueurs",
"avgPace": "Rythme moy.",
"setRep": "Série {{set}} · Répétition {{rep}}", "setRep": "Série {{set}} · Répétition {{rep}}",
"watchStatus": { "watchStatus": {
"ready": "Watch prête", "ready": "Watch prête",
+2
View File
@@ -17,6 +17,8 @@
"avgHeartRate": "Freq. card. media", "avgHeartRate": "Freq. card. media",
"calories": "Calorie", "calories": "Calorie",
"pace": "Ritmo", "pace": "Ritmo",
"lengths": "Vasche",
"avgPace": "Passo medio",
"setRep": "Serie {{set}} · Ripetizione {{rep}}", "setRep": "Serie {{set}} · Ripetizione {{rep}}",
"watchStatus": { "watchStatus": {
"ready": "Watch pronta", "ready": "Watch pronta",
+2
View File
@@ -17,6 +17,8 @@
"avgHeartRate": "Gem. hartslag", "avgHeartRate": "Gem. hartslag",
"calories": "Calorieën", "calories": "Calorieën",
"pace": "Tempo", "pace": "Tempo",
"lengths": "Banen",
"avgPace": "Gem. tempo",
"setRep": "Set {{set}} · Herhaling {{rep}}", "setRep": "Set {{set}} · Herhaling {{rep}}",
"watchStatus": { "watchStatus": {
"ready": "Watch gereed", "ready": "Watch gereed",
+2
View File
@@ -17,6 +17,8 @@
"avgHeartRate": "Śr. tętno", "avgHeartRate": "Śr. tętno",
"calories": "Kalorie", "calories": "Kalorie",
"pace": "Tempo", "pace": "Tempo",
"lengths": "Długości",
"avgPace": "Śr. tempo",
"setRep": "Serie {{set}} · Powtórzenie {{rep}}", "setRep": "Serie {{set}} · Powtórzenie {{rep}}",
"watchStatus": { "watchStatus": {
"ready": "Watch gotowa", "ready": "Watch gotowa",
+2
View File
@@ -17,6 +17,8 @@
"avgHeartRate": "Freq. card. média", "avgHeartRate": "Freq. card. média",
"calories": "Calorias", "calories": "Calorias",
"pace": "Ritmo", "pace": "Ritmo",
"lengths": "Comprimentos",
"avgPace": "Ritmo médio",
"setRep": "Série {{set}} · Repetição {{rep}}", "setRep": "Série {{set}} · Repetição {{rep}}",
"watchStatus": { "watchStatus": {
"ready": "Watch pronta", "ready": "Watch pronta",