Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df3030ae72 | ||
|
|
819d26e0f6 |
@@ -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: -80,
|
toValue: final,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}).start();
|
}).start();
|
||||||
lastOffset.current = -80;
|
offset.current = final;
|
||||||
} 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,7 +109,24 @@ 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 && (
|
||||||
@@ -129,9 +156,16 @@ export function WorkoutSummary({
|
|||||||
{tc(`stroke.${set.stroke.toLowerCase()}`)}
|
{tc(`stroke.${set.stroke.toLowerCase()}`)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<View className="items-end">
|
||||||
<Text className="text-slate-600">
|
<Text className="text-slate-600">
|
||||||
{set.repCount}×{set.distancePerRep}m
|
{set.repCount}×{set.distancePerRep}m
|
||||||
</Text>
|
</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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user