feat(i18n): add internationalization with i18next and expo-localization
This commit is contained in:
@@ -1,21 +1,29 @@
|
||||
import "./global.css";
|
||||
import "./src/i18n";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { NavigationContainer } from "@react-navigation/native";
|
||||
import { RootNavigator } from "./src/navigation/RootNavigator";
|
||||
import { DatabaseProvider } from "./src/providers/DatabaseProvider";
|
||||
import { SettingsProvider } from "./src/providers/WatchProvider";
|
||||
import { useWatchWorkoutSync } from "./src/features/watch-connectivity/hooks/useWatchWorkoutSync";
|
||||
import { useSystemLanguageListener } from "./src/i18n";
|
||||
|
||||
function WatchSync() {
|
||||
useWatchWorkoutSync();
|
||||
return null;
|
||||
}
|
||||
|
||||
function SystemLanguageListener() {
|
||||
useSystemLanguageListener();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<DatabaseProvider>
|
||||
<SettingsProvider>
|
||||
<NavigationContainer>
|
||||
<SystemLanguageListener />
|
||||
<WatchSync />
|
||||
<RootNavigator />
|
||||
<StatusBar style="light" />
|
||||
|
||||
@@ -33,7 +33,10 @@
|
||||
},
|
||||
"plugins": [
|
||||
"expo-sqlite",
|
||||
"@bacons/apple-targets"
|
||||
"@bacons/apple-targets",
|
||||
["expo-localization", {
|
||||
"supportedLocales": ["en", "nl", "de", "fr", "it", "es", "pl", "pt", "dk"]
|
||||
}]
|
||||
],
|
||||
"extra": {
|
||||
"eas": {
|
||||
|
||||
Generated
+86
@@ -17,11 +17,14 @@
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"expo": "~57.0.10",
|
||||
"expo-localization": "~57.0.1",
|
||||
"expo-sqlite": "~57.0.1",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"expo-system-ui": "~57.0.2",
|
||||
"i18next": "^26.3.6",
|
||||
"nativewind": "^5.0.0-preview.4",
|
||||
"react": "19.2.3",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-native": "0.86.2",
|
||||
"react-native-css": "^3.0.7",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
@@ -5186,6 +5189,19 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-localization": {
|
||||
"version": "57.0.1",
|
||||
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-57.0.1.tgz",
|
||||
"integrity": "sha512-8Ffl4UTbOsQeGT0v5fxMbyPHyPMPnhSPDFQJa8p9rjJrthFoAtNi+fL6Ssmrvf1/7dmPq1mVY52MEt0TMEfgjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rtl-detect": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-modules-autolinking": {
|
||||
"version": "57.0.9",
|
||||
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.9.tgz",
|
||||
@@ -6051,6 +6067,15 @@
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://locize.com"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -6093,6 +6118,34 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -7881,6 +7934,33 @@
|
||||
"react": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.11",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.11.tgz",
|
||||
"integrity": "sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
@@ -8248,6 +8328,12 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/rtl-detect": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/rtl-detect/-/rtl-detect-1.1.2.tgz",
|
||||
"integrity": "sha512-PGMBq03+TTG/p/cRB7HCLKJ1MgDIi07+QU1faSjiYRfmY5UsAttV9Hs08jDAHVwcOwmVLcSJkpwyfXszVjWfIQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"expo": "~57.0.10",
|
||||
"expo-localization": "~57.0.1",
|
||||
"expo-sqlite": "~57.0.1",
|
||||
"expo-status-bar": "~57.0.1",
|
||||
"expo-system-ui": "~57.0.2",
|
||||
"i18next": "^26.3.6",
|
||||
"nativewind": "^5.0.0-preview.4",
|
||||
"react": "19.2.3",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-native": "0.86.2",
|
||||
"react-native-css": "^3.0.7",
|
||||
"react-native-gesture-handler": "~2.32.0",
|
||||
|
||||
@@ -5,9 +5,10 @@ import { PanGestureHandler, State } from "react-native-gesture-handler";
|
||||
type SwipeableRowProps = {
|
||||
children: React.ReactNode;
|
||||
onDelete: () => void;
|
||||
deleteLabel?: string;
|
||||
};
|
||||
|
||||
export function SwipeableRow({ children, onDelete }: SwipeableRowProps) {
|
||||
export function SwipeableRow({ children, onDelete, deleteLabel = "Delete" }: SwipeableRowProps) {
|
||||
const translateX = useRef(new Animated.Value(0)).current;
|
||||
const lastOffset = useRef(0);
|
||||
|
||||
@@ -21,14 +22,12 @@ export function SwipeableRow({ children, onDelete }: SwipeableRowProps) {
|
||||
const newOffset = lastOffset.current + translationX;
|
||||
|
||||
if (newOffset < -80) {
|
||||
// Swiped left enough — show delete
|
||||
Animated.spring(translateX, {
|
||||
toValue: -80,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
lastOffset.current = -80;
|
||||
} else {
|
||||
// Snap back
|
||||
Animated.spring(translateX, {
|
||||
toValue: 0,
|
||||
useNativeDriver: true,
|
||||
@@ -52,7 +51,7 @@ export function SwipeableRow({ children, onDelete }: SwipeableRowProps) {
|
||||
<View style={styles.container}>
|
||||
<View style={styles.deleteBackground}>
|
||||
<Pressable onPress={handleDelete} style={styles.deleteButton}>
|
||||
<Text style={styles.deleteText}>Delete</Text>
|
||||
<Text style={styles.deleteText}>{deleteLabel}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<PanGestureHandler
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, Pressable } 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";
|
||||
@@ -9,6 +10,8 @@ import { formatTime } from "@utils/formatTime";
|
||||
import { formatDistance } from "@utils/formatDistance";
|
||||
|
||||
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<{
|
||||
@@ -46,38 +49,38 @@ export function PostSwimScreen() {
|
||||
<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">Swim Complete!</Text>
|
||||
<Text className="text-slate-500">Great job getting in the water</Text>
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-1">{t("swimComplete")}</Text>
|
||||
<Text className="text-slate-500">{t("greatJob")}</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">Distance</Text>
|
||||
<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">Duration</Text>
|
||||
<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">Laps</Text>
|
||||
<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">Avg SWOLF</Text>
|
||||
<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">Loading summary...</Text>
|
||||
<Text className="text-slate-400">{t("loadingSummary")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -85,7 +88,7 @@ export function PostSwimScreen() {
|
||||
onPress={() => navigation.popToTop()}
|
||||
className="bg-slate-900 rounded-2xl py-4 items-center"
|
||||
>
|
||||
<Text className="text-white font-bold text-lg">Done</Text>
|
||||
<Text className="text-white font-bold text-lg">{tc("done")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, Pressable, ActivityIndicator } 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";
|
||||
@@ -20,6 +21,8 @@ type WorkoutWithSets = {
|
||||
};
|
||||
|
||||
export function PreSwimScreen() {
|
||||
const { t } = useTranslation("active-workout");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const route = useRoute<RouteProp<RootStackParamList, "PreSwim">>();
|
||||
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
|
||||
@@ -72,16 +75,16 @@ export function PreSwimScreen() {
|
||||
<View className="flex-1 bg-white p-4 items-center justify-center">
|
||||
<View className="bg-green-50 rounded-2xl p-6 items-center mb-6 w-full">
|
||||
<Text className="text-4xl mb-3">⌚</Text>
|
||||
<Text className="text-xl font-bold text-green-800 mb-2">Workout sent to Watch!</Text>
|
||||
<Text className="text-xl font-bold text-green-800 mb-2">{t("workoutSentToWatch")}</Text>
|
||||
<Text className="text-green-600 text-center">
|
||||
Open the SwimBuddy app on your Apple Watch to start tracking.
|
||||
{t("openWatchApp")}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => navigation.popToTop()}
|
||||
className="bg-slate-900 rounded-2xl py-4 items-center w-full"
|
||||
>
|
||||
<Text className="text-white font-bold text-lg">Done</Text>
|
||||
<Text className="text-white font-bold text-lg">{tc("done")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -92,16 +95,16 @@ export function PreSwimScreen() {
|
||||
<View className="flex-1 bg-white p-4 items-center justify-center">
|
||||
<View className="bg-red-50 rounded-2xl p-6 items-center mb-6 w-full">
|
||||
<Text className="text-4xl mb-3">⚠️</Text>
|
||||
<Text className="text-xl font-bold text-red-800 mb-2">Failed to send</Text>
|
||||
<Text className="text-xl font-bold text-red-800 mb-2">{t("failedToSend")}</Text>
|
||||
<Text className="text-red-600 text-center">
|
||||
Could not send workout to Watch. Starting without Watch instead.
|
||||
{t("couldNotSend")}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate("PostSwim", { workoutId: route.params.workoutId })}
|
||||
className="bg-sky-500 rounded-2xl py-4 items-center w-full"
|
||||
>
|
||||
<Text className="text-white font-bold text-lg">Start Without Watch</Text>
|
||||
<Text className="text-white font-bold text-lg">{t("startWithoutWatch")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -111,16 +114,16 @@ export function PreSwimScreen() {
|
||||
<View className="flex-1 bg-white p-4">
|
||||
<View className="items-center mb-6">
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-1">
|
||||
{workout.name || "Workout"}
|
||||
{workout.name || tc("workout")}
|
||||
</Text>
|
||||
<Text className="text-slate-500">
|
||||
{formatDistance(workout.totalDistance ?? 0)} · {workout.poolLength}m pool
|
||||
{formatDistance(workout.totalDistance ?? 0)} · {tc("pool", { length: workout.poolLength })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="bg-sky-50 rounded-2xl p-4 mb-4 border border-sky-100">
|
||||
<Text className="text-xs font-semibold text-sky-700 mb-3 uppercase tracking-wide">
|
||||
Sets
|
||||
{tc("sets")}
|
||||
</Text>
|
||||
{workout.sets.map((set, i) => (
|
||||
<View
|
||||
@@ -132,7 +135,7 @@ export function PreSwimScreen() {
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: STROKE_COLORS[set.stroke] ?? "#94a3b8" }}
|
||||
/>
|
||||
<Text className="text-slate-700 font-medium">{set.stroke}</Text>
|
||||
<Text className="text-slate-700 font-medium">{tc(`stroke.${set.stroke.toLowerCase()}`)}</Text>
|
||||
</View>
|
||||
<Text className="text-slate-600">
|
||||
{set.repCount}×{set.distancePerRep}m
|
||||
@@ -143,9 +146,9 @@ export function PreSwimScreen() {
|
||||
|
||||
<View className="bg-slate-50 rounded-2xl p-4 mb-6">
|
||||
<View className="flex-row justify-between mb-2">
|
||||
<Text className="text-slate-500">Rest</Text>
|
||||
<Text className="text-slate-500">{t("rest")}</Text>
|
||||
<Text className="text-slate-900 font-medium">
|
||||
{workout.sets[0]?.restDuration ?? 0}s between sets
|
||||
{t("betweenSets", { duration: workout.sets[0]?.restDuration ?? 0 })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -158,12 +161,12 @@ export function PreSwimScreen() {
|
||||
/>
|
||||
<Text className="text-sm text-slate-600">
|
||||
{isReady
|
||||
? "Apple Watch ready"
|
||||
? t("watchStatus.ready")
|
||||
: isPaired
|
||||
? isInstalled
|
||||
? "Watch paired but not in range"
|
||||
: "Watch paired, app not installed"
|
||||
: "No Watch paired"}
|
||||
? t("watchStatus.pairedNotInRange")
|
||||
: t("watchStatus.pairedNotInstalled")
|
||||
: t("watchStatus.noWatch")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -178,15 +181,15 @@ export function PreSwimScreen() {
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text className="text-white font-bold text-lg">
|
||||
{isReady ? "Send to Watch & Start" : "Start Without Watch"}
|
||||
{isReady ? t("sendToWatchAndStart") : t("startWithoutWatch")}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text className="text-slate-400 text-xs text-center mt-3">
|
||||
{isReady
|
||||
? "Workout will be synced to your Apple Watch"
|
||||
: "Workout will be tracked on this device"}
|
||||
? t("syncToWatch")
|
||||
: t("trackedOnDevice")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { View, Text, Pressable, FlatList, Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
@@ -20,6 +21,8 @@ type WorkoutRow = {
|
||||
};
|
||||
|
||||
export function HistoryScreen() {
|
||||
const { t } = useTranslation("history");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
|
||||
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -42,10 +45,10 @@ export function HistoryScreen() {
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
Alert.alert("Delete Workout", "Are you sure?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
Alert.alert(tc("alert.deleteWorkout"), tc("alert.areYouSure"), [
|
||||
{ text: tc("cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
text: tc("delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteWorkout(id);
|
||||
@@ -74,7 +77,7 @@ export function HistoryScreen() {
|
||||
onRefresh={onRefresh}
|
||||
ListEmptyComponent={
|
||||
<View className="py-12 items-center">
|
||||
<Text className="text-slate-400 text-lg">No completed workouts yet</Text>
|
||||
<Text className="text-slate-400 text-lg">{t("noCompletedYet")}</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item: [date, rows] }) => (
|
||||
@@ -83,7 +86,7 @@ export function HistoryScreen() {
|
||||
{date}
|
||||
</Text>
|
||||
{rows.map((w) => (
|
||||
<SwipeableRow key={w.id} onDelete={() => onDelete(w.id)}>
|
||||
<SwipeableRow key={w.id} onDelete={() => onDelete(w.id)} deleteLabel={tc("delete")}>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate("SessionDetail", { sessionId: w.id })}
|
||||
className="p-4"
|
||||
@@ -91,12 +94,12 @@ export function HistoryScreen() {
|
||||
<View className="flex-row justify-between items-center">
|
||||
<View>
|
||||
<Text className="font-semibold text-slate-900">
|
||||
{w.name || "Swim Session"}
|
||||
{w.name || tc("swimSession")}
|
||||
</Text>
|
||||
<Text className="text-slate-500 text-sm">
|
||||
{formatDistance(w.totalDistance ?? 0)}
|
||||
{w.totalDuration ? ` · ${formatTime(w.totalDuration)}` : ""}
|
||||
{w.calories ? ` · ${Math.round(w.calories)} kcal` : ""}
|
||||
{w.calories ? ` · ${tc("kcal", { count: Math.round(w.calories) })}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-slate-400 text-xs">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView, 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";
|
||||
@@ -35,6 +36,8 @@ type WorkoutDetail = {
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
@@ -48,10 +51,10 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert("Delete Workout", "Are you sure?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
Alert.alert(tc("alert.deleteWorkout"), tc("alert.areYouSure"), [
|
||||
{ text: tc("cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
text: tc("delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteWorkout(route.params.sessionId);
|
||||
@@ -64,7 +67,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
if (!workout) {
|
||||
return (
|
||||
<View className="flex-1 bg-white items-center justify-center">
|
||||
<Text className="text-slate-400">Loading...</Text>
|
||||
<Text className="text-slate-400">{tc("loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +84,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
<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 || "Swim Session"}
|
||||
{workout.name || tc("swimSession")}
|
||||
</Text>
|
||||
<Text className="text-slate-500">
|
||||
{new Date(workout.startedAt).toLocaleDateString()}
|
||||
@@ -100,13 +103,13 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
<Text className="text-2xl font-bold text-sky-600">
|
||||
{formatDistance(workout.totalDistance ?? 0)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500 mt-1">Distance</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">Duration</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">
|
||||
@@ -120,13 +123,13 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
|
||||
{avgSwolf && (
|
||||
<View className="bg-slate-50 rounded-2xl p-4 mb-4">
|
||||
<Text className="text-sm font-semibold text-slate-700">Avg SWOLF</Text>
|
||||
<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">
|
||||
Sets
|
||||
{tc("sets")}
|
||||
</Text>
|
||||
{workout.sets.map((set, i) => (
|
||||
<View
|
||||
@@ -138,7 +141,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: STROKE_COLORS[set.stroke] ?? "#94a3b8" }}
|
||||
/>
|
||||
<Text className="text-slate-700 font-medium">{set.stroke}</Text>
|
||||
<Text className="text-slate-700 font-medium">{tc(`stroke.${set.stroke.toLowerCase()}`)}</Text>
|
||||
</View>
|
||||
<Text className="text-slate-600">
|
||||
{set.repCount}×{set.distancePerRep}m
|
||||
@@ -149,7 +152,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
{workout.laps.length > 0 && (
|
||||
<>
|
||||
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mt-6 mb-2">
|
||||
Laps
|
||||
{tc("laps")}
|
||||
</Text>
|
||||
{workout.laps.map((lap, i) => (
|
||||
<View
|
||||
@@ -157,7 +160,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
className="flex-row justify-between py-2 border-b border-slate-100"
|
||||
>
|
||||
<Text className="text-slate-600">
|
||||
Lap {i + 1} · {lap.distance}m
|
||||
{t("lap", { number: i + 1 })} · {lap.distance}m
|
||||
</Text>
|
||||
<Text className="text-slate-600 font-medium">
|
||||
{formatTime(lap.duration)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getWeeklyStats, getTotalStats, getPersonalBests } from "@db/operations";
|
||||
import { formatDistance } from "@utils/formatDistance";
|
||||
import { formatTime } from "@utils/formatTime";
|
||||
@@ -24,6 +25,8 @@ type PersonalBest = {
|
||||
};
|
||||
|
||||
export function StatsScreen() {
|
||||
const { t } = useTranslation("history");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const [weekly, setWeekly] = useState<WeeklyStats | null>(null);
|
||||
const [total, setTotal] = useState<TotalStats | null>(null);
|
||||
const [bests, setBests] = useState<PersonalBest[]>([]);
|
||||
@@ -45,79 +48,79 @@ export function StatsScreen() {
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white p-4">
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-6">Statistics</Text>
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-6">{t("statistics")}</Text>
|
||||
|
||||
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
This Week
|
||||
{t("thisWeek")}
|
||||
</Text>
|
||||
<View className="bg-sky-50 rounded-2xl p-4 mb-6">
|
||||
{weekly ? (
|
||||
<View className="flex-row justify-between">
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-sky-600">{weekly.count}</Text>
|
||||
<Text className="text-xs text-slate-500">Workouts</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("workouts")}</Text>
|
||||
</View>
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-sky-600">
|
||||
{formatDistance(weekly.totalDistance)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500">Distance</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("distance")}</Text>
|
||||
</View>
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-sky-600">
|
||||
{formatTime(weekly.totalDuration)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500">Duration</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("duration")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-slate-400 text-center py-4">No data yet</Text>
|
||||
<Text className="text-slate-400 text-center py-4">{t("noDataYet")}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
All Time
|
||||
{t("allTime")}
|
||||
</Text>
|
||||
<View className="bg-slate-50 rounded-2xl p-4 mb-6">
|
||||
{total ? (
|
||||
<View className="flex-row justify-between">
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-slate-700">{total.totalWorkouts}</Text>
|
||||
<Text className="text-xs text-slate-500">Workouts</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("workouts")}</Text>
|
||||
</View>
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-slate-700">
|
||||
{formatDistance(total.totalDistance)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500">Distance</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("distance")}</Text>
|
||||
</View>
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-slate-700">
|
||||
{formatTime(total.totalDuration)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500">Duration</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("duration")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-slate-400 text-center py-4">No data yet</Text>
|
||||
<Text className="text-slate-400 text-center py-4">{t("noDataYet")}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{bests.length > 0 && (
|
||||
<>
|
||||
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Personal Bests
|
||||
{t("personalBests")}
|
||||
</Text>
|
||||
{bests.map((best) => (
|
||||
<View
|
||||
key={best.distance}
|
||||
className="bg-green-50 rounded-2xl p-4 mb-2 flex-row justify-between items-center"
|
||||
>
|
||||
<Text className="text-green-700 font-medium">{best.distance}m</Text>
|
||||
<Text className="text-green-700 font-medium">{tc("meter", { count: best.distance })}</Text>
|
||||
<View className="items-end">
|
||||
<Text className="text-green-800 font-bold">{formatTime(best.bestTime)}</Text>
|
||||
{best.bestSwolf && (
|
||||
<Text className="text-green-600 text-xs">SWOLF: {best.bestSwolf}</Text>
|
||||
<Text className="text-green-600 text-xs">{t("swolf", { value: best.bestSwolf })}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSettings } from "../../../providers/WatchProvider";
|
||||
import { LANGUAGES } from "@i18n/index";
|
||||
|
||||
export function LanguagePicker() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { language, setLanguage } = useSettings();
|
||||
|
||||
return (
|
||||
<View className="mb-6">
|
||||
<Text className="text-sm font-medium text-slate-500 mb-2">{t("language")}</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<Pressable
|
||||
onPress={() => setLanguage("system")}
|
||||
className={`px-3 py-2 rounded-xl ${
|
||||
language === "system" ? "bg-sky-500" : "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm font-medium ${
|
||||
language === "system" ? "text-white" : "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{t("systemLanguage")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<Pressable
|
||||
key={lang.code}
|
||||
onPress={() => setLanguage(lang.code)}
|
||||
className={`px-3 py-2 rounded-xl ${
|
||||
language === lang.code ? "bg-sky-500" : "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm font-medium ${
|
||||
language === lang.code ? "text-white" : "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{lang.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSettings } from "../../../providers/WatchProvider";
|
||||
import { LanguagePicker } from "../components/LanguagePicker";
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { poolLength, setPoolLength } = useSettings();
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white p-4">
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-6">Settings</Text>
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-6">{t("settings")}</Text>
|
||||
|
||||
<LanguagePicker />
|
||||
|
||||
<View className="mb-6">
|
||||
<Text className="text-sm font-medium text-slate-500 mb-2">Default Pool Length</Text>
|
||||
<Text className="text-sm font-medium text-slate-500 mb-2">{t("defaultPoolLength")}</Text>
|
||||
<View className="flex-row gap-3">
|
||||
{[25, 50].map((len) => (
|
||||
<Pressable
|
||||
@@ -32,7 +37,7 @@ export function SettingsScreen() {
|
||||
</View>
|
||||
|
||||
<View className="border-t border-slate-200 pt-4 mt-2">
|
||||
<Text className="text-sm text-slate-400">SwimBuddy v1.0.0</Text>
|
||||
<Text className="text-sm text-slate-400">{t("version")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable, FlatList, Alert } from "react-native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
@@ -5,13 +6,14 @@ import type { WorkoutStackParamList } from "@navigation/types";
|
||||
import { useTemplates } from "../hooks/useTemplates";
|
||||
|
||||
export function TemplatesScreen() {
|
||||
const { t } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
|
||||
const { templates, remove, toggleFavorite } = useTemplates();
|
||||
|
||||
const handleDelete = (id: string, name: string) => {
|
||||
Alert.alert("Delete Template", `Delete "${name}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Delete", style: "destructive", onPress: () => remove(id) },
|
||||
Alert.alert(t("alert.deleteTemplate"), t("alert.deleteConfirm", { name }), [
|
||||
{ text: t("cancel"), style: "cancel" },
|
||||
{ text: t("delete"), style: "destructive", onPress: () => remove(id) },
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -23,9 +25,9 @@ export function TemplatesScreen() {
|
||||
contentContainerClassName="p-4"
|
||||
ListEmptyComponent={
|
||||
<View className="py-12 items-center">
|
||||
<Text className="text-slate-400 text-lg">No templates yet</Text>
|
||||
<Text className="text-slate-400 text-lg">{t("alert.deleteTemplate")}</Text>
|
||||
<Text className="text-slate-400 text-sm mt-1">
|
||||
Save a workout as a template to reuse it
|
||||
{t("noTemplatesYet")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
@@ -38,7 +40,7 @@ export function TemplatesScreen() {
|
||||
<View className="flex-1">
|
||||
<Text className="font-semibold text-slate-900 text-base">{item.name}</Text>
|
||||
<Text className="text-slate-500 text-sm mt-1">
|
||||
{item.poolLength}m pool
|
||||
{t("pool", { length: item.poolLength })}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row gap-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { WorkoutSet, StrokeType, RestType } from "@types/workout";
|
||||
import { saveWorkout, saveTemplate } from "@db/operations";
|
||||
import i18n from "@i18n/index";
|
||||
|
||||
const DEFAULT_SET: WorkoutSet = {
|
||||
stroke: "Freestyle",
|
||||
@@ -33,7 +34,7 @@ export function useWorkoutBuilder(initialSets?: WorkoutSet[]) {
|
||||
async (poolLength: number, templateId?: string) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const name = workoutName.trim() || `Workout ${new Date().toLocaleDateString()}`;
|
||||
const name = workoutName.trim() || i18n.t("workout:untitledWorkout");
|
||||
const id = await saveWorkout(name, poolLength, sets, templateId);
|
||||
return id;
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, TextInput, Pressable, ScrollView, Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "@navigation/types";
|
||||
@@ -10,6 +11,8 @@ import type { StrokeType } from "@types/workout";
|
||||
const STROKES: StrokeType[] = ["Freestyle", "Backstroke", "Breaststroke", "Butterfly", "Mixed"];
|
||||
|
||||
export function BuilderScreen() {
|
||||
const { t } = useTranslation("workout");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const { poolLength: defaultPoolLength } = useSettings();
|
||||
const [poolLength, setPoolLength] = useState(defaultPoolLength);
|
||||
@@ -31,31 +34,31 @@ export function BuilderScreen() {
|
||||
navigation.navigate("PreSwim", { workoutId });
|
||||
} catch (e) {
|
||||
console.error("Failed to save workout:", e);
|
||||
Alert.alert("Error", `Failed to save workout: ${e}`);
|
||||
Alert.alert(tc("validation.error"), tc("validation.failedToSave", { error: String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white">
|
||||
<View className="p-4">
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-1">Build Workout</Text>
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-1">{t("buildWorkout")}</Text>
|
||||
<Text className="text-slate-500 mb-4">
|
||||
{totalDistance}m total · {poolLength}m pool
|
||||
{totalDistance}m total · {tc("pool", { length: poolLength })}
|
||||
</Text>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm font-medium text-slate-500 mb-1">Workout Name</Text>
|
||||
<Text className="text-sm font-medium text-slate-500 mb-1">{t("workoutName")}</Text>
|
||||
<TextInput
|
||||
value={workoutName}
|
||||
onChangeText={setWorkoutName}
|
||||
placeholder="Morning Swim"
|
||||
placeholder={t("placeholder.workoutName")}
|
||||
placeholderTextColor="#94a3b8"
|
||||
className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-slate-900"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm font-medium text-slate-500 mb-1">Pool Length</Text>
|
||||
<Text className="text-sm font-medium text-slate-500 mb-1">{t("poolLength")}</Text>
|
||||
<View className="flex-row gap-2">
|
||||
{[25, 50].map((length) => (
|
||||
<Pressable
|
||||
@@ -80,16 +83,16 @@ export function BuilderScreen() {
|
||||
{sets.map((set, index) => (
|
||||
<View key={index} className="bg-slate-50 rounded-2xl p-4 mb-3 border border-slate-200">
|
||||
<View className="flex-row justify-between items-center mb-3">
|
||||
<Text className="font-semibold text-slate-700">Set {index + 1}</Text>
|
||||
<Text className="font-semibold text-slate-700">{t("setNumber", { number: index + 1 })}</Text>
|
||||
{sets.length > 1 && (
|
||||
<Pressable onPress={() => removeSet(index)}>
|
||||
<Text className="text-red-500 text-sm font-medium">Remove</Text>
|
||||
<Text className="text-red-500 text-sm font-medium">{t("remove")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1.5 uppercase tracking-wide">
|
||||
Stroke
|
||||
{t("stroke")}
|
||||
</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-3">
|
||||
{STROKES.map((stroke) => (
|
||||
@@ -105,7 +108,7 @@ export function BuilderScreen() {
|
||||
set.stroke === stroke ? "text-white" : "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{stroke}
|
||||
{tc(`stroke.${stroke.toLowerCase()}`)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
@@ -113,29 +116,29 @@ export function BuilderScreen() {
|
||||
|
||||
<View className="flex-row gap-2">
|
||||
<View className="flex-1">
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">Distance (m)</Text>
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">{t("distanceM")}</Text>
|
||||
<TextInput
|
||||
keyboardType="numeric"
|
||||
value={String(set.distancePerRep)}
|
||||
onChangeText={(t) => updateSet(index, { distancePerRep: Number(t) || 0 })}
|
||||
onChangeText={(val) => updateSet(index, { distancePerRep: Number(val) || 0 })}
|
||||
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">Reps</Text>
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">{t("reps")}</Text>
|
||||
<TextInput
|
||||
keyboardType="numeric"
|
||||
value={String(set.repCount)}
|
||||
onChangeText={(t) => updateSet(index, { repCount: Number(t) || 1 })}
|
||||
onChangeText={(val) => updateSet(index, { repCount: Number(val) || 1 })}
|
||||
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">Rest (s)</Text>
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">{t("restS")}</Text>
|
||||
<TextInput
|
||||
keyboardType="numeric"
|
||||
value={String(set.restDuration)}
|
||||
onChangeText={(t) => updateSet(index, { restDuration: Number(t) || 0 })}
|
||||
onChangeText={(val) => updateSet(index, { restDuration: Number(val) || 0 })}
|
||||
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
</View>
|
||||
@@ -147,7 +150,7 @@ export function BuilderScreen() {
|
||||
onPress={addSet}
|
||||
className="bg-slate-100 rounded-2xl py-3 items-center mb-6 border border-slate-200"
|
||||
>
|
||||
<Text className="text-sky-600 font-semibold">+ Add Set</Text>
|
||||
<Text className="text-sky-600 font-semibold">{t("addSet")}</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
@@ -156,7 +159,7 @@ export function BuilderScreen() {
|
||||
className={`rounded-2xl py-4 items-center mb-8 ${isSaving ? "bg-sky-300" : "bg-sky-500"}`}
|
||||
>
|
||||
<Text className="text-white font-bold text-lg">
|
||||
{isSaving ? "Saving..." : "Start Swim"}
|
||||
{isSaving ? tc("saving") : t("startSwim")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { View, Text, Pressable, FlatList, Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
@@ -19,6 +20,8 @@ type WorkoutRow = {
|
||||
};
|
||||
|
||||
export function WorkoutListScreen() {
|
||||
const { t } = useTranslation("workout");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
|
||||
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -41,10 +44,10 @@ export function WorkoutListScreen() {
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
Alert.alert("Delete Workout", "Are you sure?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
Alert.alert(tc("alert.deleteWorkout"), tc("alert.areYouSure"), [
|
||||
{ text: tc("cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
text: tc("delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteWorkout(id);
|
||||
@@ -61,7 +64,7 @@ export function WorkoutListScreen() {
|
||||
onPress={() => navigation.navigate("Builder")}
|
||||
className="bg-sky-500 rounded-2xl p-4 items-center mb-4"
|
||||
>
|
||||
<Text className="text-white font-bold text-lg">+ New Workout</Text>
|
||||
<Text className="text-white font-bold text-lg">{t("newWorkout")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -73,12 +76,12 @@ export function WorkoutListScreen() {
|
||||
onRefresh={onRefresh}
|
||||
ListEmptyComponent={
|
||||
<View className="py-12 items-center">
|
||||
<Text className="text-slate-400 text-lg">No workouts yet</Text>
|
||||
<Text className="text-slate-400 text-sm mt-1">Tap "New Workout" to get started</Text>
|
||||
<Text className="text-slate-400 text-lg">{t("noWorkoutsYet")}</Text>
|
||||
<Text className="text-slate-400 text-sm mt-1">{t("tapNewWorkout")}</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<SwipeableRow onDelete={() => onDelete(item.id)}>
|
||||
<SwipeableRow onDelete={() => onDelete(item.id)} deleteLabel={tc("delete")}>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate("PreSwim", { workoutId: item.id })}
|
||||
className="p-4"
|
||||
@@ -86,7 +89,7 @@ export function WorkoutListScreen() {
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="flex-1">
|
||||
<Text className="font-semibold text-slate-900 text-base">
|
||||
{item.name || "Untitled Workout"}
|
||||
{item.name || tc("untitledWorkout")}
|
||||
</Text>
|
||||
<Text className="text-slate-500 text-sm mt-1">
|
||||
{formatDistance(item.totalDistance ?? 0)}
|
||||
@@ -98,7 +101,7 @@ export function WorkoutListScreen() {
|
||||
</View>
|
||||
{item.finishedAt && (
|
||||
<View className="bg-green-100 rounded-full px-2 py-0.5">
|
||||
<Text className="text-green-700 text-xs font-medium">Done</Text>
|
||||
<Text className="text-green-700 text-xs font-medium">{tc("done")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"workoutSentToWatch": "Workout an Watch gesendet!",
|
||||
"openWatchApp": "Öffne die SwimBuddy-App auf deiner Apple Watch, um mit der Verfolgung zu beginnen.",
|
||||
"failedToSend": "Senden fehlgeschlagen",
|
||||
"couldNotSend": "Workout konnte nicht an Watch gesendet werden. Starte ohne Watch.",
|
||||
"startWithoutWatch": "Ohne Watch starten",
|
||||
"sendToWatchAndStart": "An Watch senden & starten",
|
||||
"syncToWatch": "Workout wird mit deiner Apple Watch synchronisiert",
|
||||
"trackedOnDevice": "Workout wird auf diesem Gerät verfolgt",
|
||||
"rest": "Pause",
|
||||
"betweenSets": "{{duration}}s Pause zwischen Sets",
|
||||
"swimComplete": "Schwimmen abgeschlossen!",
|
||||
"greatJob": "Gut gemacht! Du bist heute ins Wasser gegangen!",
|
||||
"loadingSummary": "Zusammenfassung laden...",
|
||||
"watchStatus": {
|
||||
"ready": "Apple Watch bereit",
|
||||
"pairedNotInRange": "Watch gekoppelt, aber nicht in Reichweite",
|
||||
"pairedNotInstalled": "Watch gekoppelt, App nicht installiert",
|
||||
"noWatch": "Keine Watch gekoppelt"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"done": "Fertig",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"start": "Starten",
|
||||
"back": "Zurück",
|
||||
"loading": "Laden...",
|
||||
"error": "Fehler",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern...",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"confirm": "Bestätigen",
|
||||
"ok": "OK",
|
||||
"distance": "Distanz",
|
||||
"duration": "Dauer",
|
||||
"workouts": "Workouts",
|
||||
"sets": "Sets",
|
||||
"laps": "Bahnen",
|
||||
"avgSwolf": "Durchschn. SWOLF",
|
||||
"pool": "{{length}}m Becken",
|
||||
"untitledWorkout": "Unbenannter Workout",
|
||||
"swimSession": "Schwimmsitzung",
|
||||
"workout": "Workout",
|
||||
"kcal": "{{count}} kcal",
|
||||
"meter": "{{count}}m",
|
||||
"kilometer": "{{count}}km",
|
||||
"pace": "{{pace}}/100m",
|
||||
"noPace": "--:--",
|
||||
"stroke": {
|
||||
"freestyle": "Freistil",
|
||||
"backstroke": "Rücken",
|
||||
"breaststroke": "Brust",
|
||||
"butterfly": "Schmetterling",
|
||||
"mixed": "Gemischt"
|
||||
},
|
||||
"alert": {
|
||||
"deleteWorkout": "Workout löschen",
|
||||
"deleteTemplate": "Vorlage löschen",
|
||||
"areYouSure": "Bist du sicher?",
|
||||
"deleteConfirm": "\"{{name}}\" löschen?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"noCompletedYet": "Noch keine abgeschlossenen Workouts",
|
||||
"lap": "Bahn {{number}}",
|
||||
"statistics": "Statistiken",
|
||||
"thisWeek": "Diese Woche",
|
||||
"allTime": "Gesamtzeit",
|
||||
"personalBests": "Persönliche Bestleistungen",
|
||||
"noDataYet": "Noch keine Daten",
|
||||
"swolf": "SWOLF: {{value}}"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"workouts": "Workouts",
|
||||
"history": "Verlauf",
|
||||
"settings": "Einstellungen",
|
||||
"newWorkout": "Neues Workout",
|
||||
"buildWorkout": "Workout erstellen",
|
||||
"startSwim": "Schwimmen starten",
|
||||
"readyToSwim": "Bereit zum Schwimmen",
|
||||
"swimComplete": "Schwimmen abgeschlossen",
|
||||
"session": "Sitzung",
|
||||
"sessionDetail": "Sitzungsdetails"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"settings": "Einstellungen",
|
||||
"defaultPoolLength": "Standard-Beckenlänge",
|
||||
"language": "Sprache",
|
||||
"systemLanguage": "Systemsprache",
|
||||
"version": "SwimBuddy v1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workoutName": "Workout-Name",
|
||||
"poolLength": "Beckenlänge",
|
||||
"stroke": "Lage",
|
||||
"distanceM": "Distanz (m)",
|
||||
"reps": "Wiederholungen",
|
||||
"restS": "Pause (s)",
|
||||
"addSet": "+ Set hinzufügen",
|
||||
"remove": "Entfernen",
|
||||
"setNumber": "Set {{number}}",
|
||||
"startSwim": "Schwimmen starten",
|
||||
"newWorkout": "+ Neues Workout",
|
||||
"noWorkoutsYet": "Noch keine Workouts",
|
||||
"tapNewWorkout": "Tippe auf \"Neues Workout\" um zu beginnen",
|
||||
"morningSwim": "Morgenschwimmen",
|
||||
"placeholder": {
|
||||
"workoutName": "Morgenschwimmen"
|
||||
},
|
||||
"validation": {
|
||||
"error": "Fehler",
|
||||
"failedToSave": "Workout konnte nicht gespeichert werden: {{error}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"workoutSentToWatch": "Træning sendt til Watch!",
|
||||
"openWatchApp": "Åbn SwimBuddy-appen på din Apple Watch for at begynde sporing.",
|
||||
"failedToSend": "Sendning mislykkedes",
|
||||
"couldNotSend": "Kunne ikke sende træning til Watch. Starter uden Watch.",
|
||||
"startWithoutWatch": "Start uden Watch",
|
||||
"sendToWatchAndStart": "Send til Watch & start",
|
||||
"syncToWatch": "Træningen synkroniseres med din Apple Watch",
|
||||
"trackedOnDevice": "Træningen spores på denne enhed",
|
||||
"rest": "Hvile",
|
||||
"betweenSets": "{{duration}}s hvile mellem sæt",
|
||||
"swimComplete": "Svømning fuldført!",
|
||||
"greatJob": "Godt gået! Du var i vandet i dag!",
|
||||
"loadingSummary": "Indlæser opsummering...",
|
||||
"watchStatus": {
|
||||
"ready": "Apple Watch klar",
|
||||
"pairedNotInRange": "Watch parret, men ikke inden for rækkevidde",
|
||||
"pairedNotInstalled": "Watch parret, app ikke installeret",
|
||||
"noWatch": "Ingen Watch parret"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"done": "Færdig",
|
||||
"cancel": "Annuller",
|
||||
"delete": "Slet",
|
||||
"start": "Start",
|
||||
"back": "Tilbage",
|
||||
"loading": "Indlæser...",
|
||||
"error": "Fejl",
|
||||
"save": "Gem",
|
||||
"saving": "Gemmer...",
|
||||
"yes": "Ja",
|
||||
"no": "Nej",
|
||||
"confirm": "Bekræft",
|
||||
"ok": "OK",
|
||||
"distance": "Distance",
|
||||
"duration": "Varighed",
|
||||
"workouts": "Træninger",
|
||||
"sets": "Sæt",
|
||||
"laps": "Baner",
|
||||
"avgSwolf": "Gns. SWOLF",
|
||||
"pool": "{{length}}m pool",
|
||||
"untitledWorkout": "Unavngiven træning",
|
||||
"swimSession": "Svømmesession",
|
||||
"workout": "Træning",
|
||||
"kcal": "{{count}} kcal",
|
||||
"meter": "{{count}}m",
|
||||
"kilometer": "{{count}}km",
|
||||
"pace": "{{pace}}/100m",
|
||||
"noPace": "--:--",
|
||||
"stroke": {
|
||||
"freestyle": "Fri crawl",
|
||||
"backstroke": "Rygcrawl",
|
||||
"breaststroke": "Brystsvømning",
|
||||
"butterfly": "Butterfly",
|
||||
"mixed": "Blandet"
|
||||
},
|
||||
"alert": {
|
||||
"deleteWorkout": "Slet træning",
|
||||
"deleteTemplate": "Slet skabelon",
|
||||
"areYouSure": "Er du sikker?",
|
||||
"deleteConfirm": "Slet \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"noCompletedYet": "Ingen fuldførte træninger endnu",
|
||||
"lap": "Bane {{number}}",
|
||||
"statistics": "Statistikker",
|
||||
"thisWeek": "Denne uge",
|
||||
"allTime": "Alt tid",
|
||||
"personalBests": "Personlige rekorder",
|
||||
"noDataYet": "Ingen data endnu",
|
||||
"swolf": "SWOLF: {{value}}"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"workouts": "Træninger",
|
||||
"history": "Historik",
|
||||
"settings": "Indstillinger",
|
||||
"newWorkout": "Ny træning",
|
||||
"buildWorkout": "Opret træning",
|
||||
"startSwim": "Start svømning",
|
||||
"readyToSwim": "Klar til at svømme",
|
||||
"swimComplete": "Svømning fuldført",
|
||||
"session": "Session",
|
||||
"sessionDetail": "Sessionsdetaljer"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"settings": "Indstillinger",
|
||||
"defaultPoolLength": "Standard poolængde",
|
||||
"language": "Sprog",
|
||||
"systemLanguage": "Systemssprog",
|
||||
"version": "SwimBuddy v1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workoutName": "Træningsnavn",
|
||||
"poolLength": "Poolængde",
|
||||
"stroke": "Svømmearm",
|
||||
"distanceM": "Distance (m)",
|
||||
"reps": "Gentagelser",
|
||||
"restS": "Hvile (s)",
|
||||
"addSet": "+ Tilføj sæt",
|
||||
"remove": "Fjern",
|
||||
"setNumber": "Sæt {{number}}",
|
||||
"startSwim": "Start svømning",
|
||||
"newWorkout": "+ Ny træning",
|
||||
"noWorkoutsYet": "Ingen træninger endnu",
|
||||
"tapNewWorkout": "Tryk på \"Ny træning\" for at komme i gang",
|
||||
"morningSwim": "Morgensvømning",
|
||||
"placeholder": {
|
||||
"workoutName": "Morgensvømning"
|
||||
},
|
||||
"validation": {
|
||||
"error": "Fejl",
|
||||
"failedToSave": "Kunne ikke gemme træningen: {{error}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"workoutSentToWatch": "Workout sent to Watch!",
|
||||
"openWatchApp": "Open the SwimBuddy app on your Apple Watch to start tracking.",
|
||||
"failedToSend": "Failed to send",
|
||||
"couldNotSend": "Could not send workout to Watch. Starting without Watch instead.",
|
||||
"startWithoutWatch": "Start Without Watch",
|
||||
"sendToWatchAndStart": "Send to Watch & Start",
|
||||
"syncToWatch": "Workout will be synced to your Apple Watch",
|
||||
"trackedOnDevice": "Workout will be tracked on this device",
|
||||
"rest": "Rest",
|
||||
"betweenSets": "{{duration}}s between sets",
|
||||
"swimComplete": "Swim Complete!",
|
||||
"greatJob": "Great job getting in the water today!",
|
||||
"loadingSummary": "Loading summary...",
|
||||
"watchStatus": {
|
||||
"ready": "Apple Watch ready",
|
||||
"pairedNotInRange": "Watch paired but not in range",
|
||||
"pairedNotInstalled": "Watch paired, app not installed",
|
||||
"noWatch": "No Watch paired"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"done": "Done",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"start": "Start",
|
||||
"back": "Back",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"save": "Save",
|
||||
"saving": "Saving...",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"confirm": "Confirm",
|
||||
"ok": "OK",
|
||||
"distance": "Distance",
|
||||
"duration": "Duration",
|
||||
"workouts": "Workouts",
|
||||
"sets": "Sets",
|
||||
"laps": "Laps",
|
||||
"avgSwolf": "Avg SWOLF",
|
||||
"pool": "{{length}}m pool",
|
||||
"untitledWorkout": "Untitled Workout",
|
||||
"swimSession": "Swim Session",
|
||||
"workout": "Workout",
|
||||
"kcal": "{{count}} kcal",
|
||||
"meter": "{{count}}m",
|
||||
"kilometer": "{{count}}km",
|
||||
"pace": "{{pace}}/100m",
|
||||
"noPace": "--:--",
|
||||
"stroke": {
|
||||
"freestyle": "Freestyle",
|
||||
"backstroke": "Backstroke",
|
||||
"breaststroke": "Breaststroke",
|
||||
"butterfly": "Butterfly",
|
||||
"mixed": "Mixed"
|
||||
},
|
||||
"alert": {
|
||||
"deleteWorkout": "Delete Workout",
|
||||
"deleteTemplate": "Delete Template",
|
||||
"areYouSure": "Are you sure?",
|
||||
"deleteConfirm": "Delete \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"noCompletedYet": "No completed workouts yet",
|
||||
"lap": "Lap {{number}}",
|
||||
"statistics": "Statistics",
|
||||
"thisWeek": "This Week",
|
||||
"allTime": "All Time",
|
||||
"personalBests": "Personal Bests",
|
||||
"noDataYet": "No data yet",
|
||||
"swolf": "SWOLF: {{value}}"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"workouts": "Workouts",
|
||||
"history": "History",
|
||||
"settings": "Settings",
|
||||
"newWorkout": "New Workout",
|
||||
"buildWorkout": "Build Workout",
|
||||
"startSwim": "Start Swim",
|
||||
"readyToSwim": "Ready to Swim",
|
||||
"swimComplete": "Swim Complete",
|
||||
"session": "Session",
|
||||
"sessionDetail": "Session Detail"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"settings": "Settings",
|
||||
"defaultPoolLength": "Default Pool Length",
|
||||
"language": "Language",
|
||||
"systemLanguage": "System Language",
|
||||
"version": "SwimBuddy v1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workoutName": "Workout Name",
|
||||
"poolLength": "Pool Length",
|
||||
"stroke": "Stroke",
|
||||
"distanceM": "Distance (m)",
|
||||
"reps": "Reps",
|
||||
"restS": "Rest (s)",
|
||||
"addSet": "+ Add Set",
|
||||
"remove": "Remove",
|
||||
"setNumber": "Set {{number}}",
|
||||
"startSwim": "Start Swim",
|
||||
"newWorkout": "+ New Workout",
|
||||
"noWorkoutsYet": "No workouts yet",
|
||||
"tapNewWorkout": "Tap \"New Workout\" to get started",
|
||||
"morningSwim": "Morning Swim",
|
||||
"placeholder": {
|
||||
"workoutName": "Morning Swim"
|
||||
},
|
||||
"validation": {
|
||||
"error": "Error",
|
||||
"failedToSave": "Failed to save workout: {{error}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"workoutSentToWatch": "¡Entrenamiento enviado al Watch!",
|
||||
"openWatchApp": "Abre la app SwimBuddy en tu Apple Watch para empezar el seguimiento.",
|
||||
"failedToSend": "Error al enviar",
|
||||
"couldNotSend": "No se pudo enviar el entrenamiento al Watch. Iniciando sin Watch.",
|
||||
"startWithoutWatch": "Iniciar sin Watch",
|
||||
"sendToWatchAndStart": "Enviar al Watch e iniciar",
|
||||
"syncToWatch": "El entrenamiento se sincronizará con tu Apple Watch",
|
||||
"trackedOnDevice": "El entrenamiento se rastreará en este dispositivo",
|
||||
"rest": "Descanso",
|
||||
"betweenSets": "{{duration}}s de descanso entre series",
|
||||
"swimComplete": "¡Natación completada!",
|
||||
"greatJob": "¡Buen trabajo! ¡Hoy has estado en el agua!",
|
||||
"loadingSummary": "Cargando resumen...",
|
||||
"watchStatus": {
|
||||
"ready": "Apple Watch lista",
|
||||
"pairedNotInRange": "Watch emparejada pero fuera de alcance",
|
||||
"pairedNotInstalled": "Watch emparejada, app no instalada",
|
||||
"noWatch": "Ninguna Watch emparejada"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"done": "Listo",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"start": "Iniciar",
|
||||
"back": "Volver",
|
||||
"loading": "Cargando...",
|
||||
"error": "Error",
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando...",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"confirm": "Confirmar",
|
||||
"ok": "OK",
|
||||
"distance": "Distancia",
|
||||
"duration": "Duración",
|
||||
"workouts": "Entrenamientos",
|
||||
"sets": "Series",
|
||||
"laps": "Largos",
|
||||
"avgSwolf": "SWOLF prom.",
|
||||
"pool": "Piscina de {{length}}m",
|
||||
"untitledWorkout": "Entrenamiento sin nombre",
|
||||
"swimSession": "Sesión de natación",
|
||||
"workout": "Entrenamiento",
|
||||
"kcal": "{{count}} kcal",
|
||||
"meter": "{{count}}m",
|
||||
"kilometer": "{{count}}km",
|
||||
"pace": "{{pace}}/100m",
|
||||
"noPace": "--:--",
|
||||
"stroke": {
|
||||
"freestyle": "Libre",
|
||||
"backstroke": "Espalda",
|
||||
"breaststroke": "Pecho",
|
||||
"butterfly": "Mariposa",
|
||||
"mixed": "Mixto"
|
||||
},
|
||||
"alert": {
|
||||
"deleteWorkout": "Eliminar entrenamiento",
|
||||
"deleteTemplate": "Eliminar plantilla",
|
||||
"areYouSure": "¿Estás seguro?",
|
||||
"deleteConfirm": "¿Eliminar \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"noCompletedYet": "Aún no hay entrenamientos completados",
|
||||
"lap": "Largo {{number}}",
|
||||
"statistics": "Estadísticas",
|
||||
"thisWeek": "Esta semana",
|
||||
"allTime": "Todos los tiempos",
|
||||
"personalBests": "Récords personales",
|
||||
"noDataYet": "Aún no hay datos",
|
||||
"swolf": "SWOLF: {{value}}"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"workouts": "Entrenamientos",
|
||||
"history": "Historial",
|
||||
"settings": "Ajustes",
|
||||
"newWorkout": "Nuevo entrenamiento",
|
||||
"buildWorkout": "Crear entrenamiento",
|
||||
"startSwim": "Iniciar natación",
|
||||
"readyToSwim": "Listo para nadar",
|
||||
"swimComplete": "Natación completada",
|
||||
"session": "Sesión",
|
||||
"sessionDetail": "Detalle de sesión"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"settings": "Ajustes",
|
||||
"defaultPoolLength": "Longitud de piscina predeterminada",
|
||||
"language": "Idioma",
|
||||
"systemLanguage": "Idioma del sistema",
|
||||
"version": "SwimBuddy v1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workoutName": "Nombre del entrenamiento",
|
||||
"poolLength": "Longitud de la piscina",
|
||||
"stroke": "Estilo",
|
||||
"distanceM": "Distancia (m)",
|
||||
"reps": "Repeticiones",
|
||||
"restS": "Descanso (s)",
|
||||
"addSet": "+ Añadir serie",
|
||||
"remove": "Eliminar",
|
||||
"setNumber": "Serie {{number}}",
|
||||
"startSwim": "Iniciar natación",
|
||||
"newWorkout": "+ Nuevo entrenamiento",
|
||||
"noWorkoutsYet": "Aún no hay entrenamientos",
|
||||
"tapNewWorkout": "Toca \"Nuevo entrenamiento\" para empezar",
|
||||
"morningSwim": "Natación matutina",
|
||||
"placeholder": {
|
||||
"workoutName": "Natación matutina"
|
||||
},
|
||||
"validation": {
|
||||
"error": "Error",
|
||||
"failedToSave": "No se pudo guardar el entrenamiento: {{error}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"workoutSentToWatch": "Entraînement envoyé à la Watch !",
|
||||
"openWatchApp": "Ouvrez l'application SwimBuddy sur votre Apple Watch pour commencer le suivi.",
|
||||
"failedToSend": "Échec de l'envoi",
|
||||
"couldNotSend": "Impossible d'envoyer l'entraînement à la Watch. Démarrage sans Watch.",
|
||||
"startWithoutWatch": "Démarrer sans Watch",
|
||||
"sendToWatchAndStart": "Envoyer à la Watch et commencer",
|
||||
"syncToWatch": "L'entraînement sera synchronisé avec votre Apple Watch",
|
||||
"trackedOnDevice": "L'entraînement sera suivi sur cet appareil",
|
||||
"rest": "Repos",
|
||||
"betweenSets": "{{duration}}s de repos entre les séries",
|
||||
"swimComplete": "Nage terminée !",
|
||||
"greatJob": "Bravo ! Vous êtes allé à l'eau aujourd'hui !",
|
||||
"loadingSummary": "Chargement du résumé...",
|
||||
"watchStatus": {
|
||||
"ready": "Apple Watch prête",
|
||||
"pairedNotInRange": "Watch appariée mais hors de portée",
|
||||
"pairedNotInstalled": "Watch appariée, application non installée",
|
||||
"noWatch": "Aucune Watch appariée"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"done": "Terminé",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"start": "Commencer",
|
||||
"back": "Retour",
|
||||
"loading": "Chargement...",
|
||||
"error": "Erreur",
|
||||
"save": "Enregistrer",
|
||||
"saving": "Enregistrement...",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"confirm": "Confirmer",
|
||||
"ok": "OK",
|
||||
"distance": "Distance",
|
||||
"duration": "Durée",
|
||||
"workouts": "Entraînements",
|
||||
"sets": "Séries",
|
||||
"laps": "Longueurs",
|
||||
"avgSwolf": "SWOLF moy.",
|
||||
"pool": "Bassin de {{length}}m",
|
||||
"untitledWorkout": "Entraînement sans titre",
|
||||
"swimSession": "Séance de natation",
|
||||
"workout": "Entraînement",
|
||||
"kcal": "{{count}} kcal",
|
||||
"meter": "{{count}}m",
|
||||
"kilometer": "{{count}}km",
|
||||
"pace": "{{pace}}/100m",
|
||||
"noPace": "--:--",
|
||||
"stroke": {
|
||||
"freestyle": "Nage libre",
|
||||
"backstroke": "Dos",
|
||||
"breaststroke": "Brasse",
|
||||
"butterfly": "Papillon",
|
||||
"mixed": "Mixte"
|
||||
},
|
||||
"alert": {
|
||||
"deleteWorkout": "Supprimer l'entraînement",
|
||||
"deleteTemplate": "Supprimer le modèle",
|
||||
"areYouSure": "Êtes-vous sûr ?",
|
||||
"deleteConfirm": "Supprimer \"{{name}}\" ?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"noCompletedYet": "Aucun entraînement terminé pour le moment",
|
||||
"lap": "Longueur {{number}}",
|
||||
"statistics": "Statistiques",
|
||||
"thisWeek": "Cette semaine",
|
||||
"allTime": "Tout le temps",
|
||||
"personalBests": "Records personnels",
|
||||
"noDataYet": "Pas encore de données",
|
||||
"swolf": "SWOLF : {{value}}"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"workouts": "Entraînements",
|
||||
"history": "Historique",
|
||||
"settings": "Paramètres",
|
||||
"newWorkout": "Nouvel entraînement",
|
||||
"buildWorkout": "Créer un entraînement",
|
||||
"startSwim": "Commencer la nage",
|
||||
"readyToSwim": "Prêt à nager",
|
||||
"swimComplete": "Nage terminée",
|
||||
"session": "Séance",
|
||||
"sessionDetail": "Détails de la séance"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"settings": "Paramètres",
|
||||
"defaultPoolLength": "Longueur du bassin par défaut",
|
||||
"language": "Langue",
|
||||
"systemLanguage": "Langue du système",
|
||||
"version": "SwimBuddy v1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workoutName": "Nom de l'entraînement",
|
||||
"poolLength": "Longueur du bassin",
|
||||
"stroke": "Style",
|
||||
"distanceM": "Distance (m)",
|
||||
"reps": "Répétitions",
|
||||
"restS": "Repos (s)",
|
||||
"addSet": "+ Ajouter une série",
|
||||
"remove": "Retirer",
|
||||
"setNumber": "Série {{number}}",
|
||||
"startSwim": "Commencer la nage",
|
||||
"newWorkout": "+ Nouvel entraînement",
|
||||
"noWorkoutsYet": "Aucun entraînement pour le moment",
|
||||
"tapNewWorkout": "Appuyez sur \"Nouvel entraînement\" pour commencer",
|
||||
"morningSwim": "Matinée de natation",
|
||||
"placeholder": {
|
||||
"workoutName": "Matinée de natation"
|
||||
},
|
||||
"validation": {
|
||||
"error": "Erreur",
|
||||
"failedToSave": "Échec de l'enregistrement : {{error}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { AppState, type AppStateStatus } from "react-native";
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { getLocales } from "expo-localization";
|
||||
import { createMMKV } from "react-native-mmkv";
|
||||
|
||||
import enCommon from "./en/common.json";
|
||||
import enNavigation from "./en/navigation.json";
|
||||
import enWorkout from "./en/workout.json";
|
||||
import enActiveWorkout from "./en/active-workout.json";
|
||||
import enHistory from "./en/history.json";
|
||||
import enSettings from "./en/settings.json";
|
||||
|
||||
import nlCommon from "./nl/common.json";
|
||||
import nlNavigation from "./nl/navigation.json";
|
||||
import nlWorkout from "./nl/workout.json";
|
||||
import nlActiveWorkout from "./nl/active-workout.json";
|
||||
import nlHistory from "./nl/history.json";
|
||||
import nlSettings from "./nl/settings.json";
|
||||
|
||||
import deCommon from "./de/common.json";
|
||||
import deNavigation from "./de/navigation.json";
|
||||
import deWorkout from "./de/workout.json";
|
||||
import deActiveWorkout from "./de/active-workout.json";
|
||||
import deHistory from "./de/history.json";
|
||||
import deSettings from "./de/settings.json";
|
||||
|
||||
import frCommon from "./fr/common.json";
|
||||
import frNavigation from "./fr/navigation.json";
|
||||
import frWorkout from "./fr/workout.json";
|
||||
import frActiveWorkout from "./fr/active-workout.json";
|
||||
import frHistory from "./fr/history.json";
|
||||
import frSettings from "./fr/settings.json";
|
||||
|
||||
import itCommon from "./it/common.json";
|
||||
import itNavigation from "./it/navigation.json";
|
||||
import itWorkout from "./it/workout.json";
|
||||
import itActiveWorkout from "./it/active-workout.json";
|
||||
import itHistory from "./it/history.json";
|
||||
import itSettings from "./it/settings.json";
|
||||
|
||||
import esCommon from "./es/common.json";
|
||||
import esNavigation from "./es/navigation.json";
|
||||
import esWorkout from "./es/workout.json";
|
||||
import esActiveWorkout from "./es/active-workout.json";
|
||||
import esHistory from "./es/history.json";
|
||||
import esSettings from "./es/settings.json";
|
||||
|
||||
import plCommon from "./pl/common.json";
|
||||
import plNavigation from "./pl/navigation.json";
|
||||
import plWorkout from "./pl/workout.json";
|
||||
import plActiveWorkout from "./pl/active-workout.json";
|
||||
import plHistory from "./pl/history.json";
|
||||
import plSettings from "./pl/settings.json";
|
||||
|
||||
import ptCommon from "./pt/common.json";
|
||||
import ptNavigation from "./pt/navigation.json";
|
||||
import ptWorkout from "./pt/workout.json";
|
||||
import ptActiveWorkout from "./pt/active-workout.json";
|
||||
import ptHistory from "./pt/history.json";
|
||||
import ptSettings from "./pt/settings.json";
|
||||
|
||||
import dkCommon from "./dk/common.json";
|
||||
import dkNavigation from "./dk/navigation.json";
|
||||
import dkWorkout from "./dk/workout.json";
|
||||
import dkActiveWorkout from "./dk/active-workout.json";
|
||||
import dkHistory from "./dk/history.json";
|
||||
import dkSettings from "./dk/settings.json";
|
||||
|
||||
export const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "nl", label: "Nederlands" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "it", label: "Italiano" },
|
||||
{ code: "es", label: "Español" },
|
||||
{ code: "pl", label: "Polski" },
|
||||
{ code: "pt", label: "Português" },
|
||||
{ code: "dk", label: "Dansk" },
|
||||
] as const;
|
||||
|
||||
export type LanguageCode = (typeof LANGUAGES)[number]["code"];
|
||||
|
||||
export type StoredLanguage = LanguageCode | "system";
|
||||
|
||||
const namespaces = [
|
||||
"common",
|
||||
"navigation",
|
||||
"workout",
|
||||
"active-workout",
|
||||
"history",
|
||||
"settings",
|
||||
] as const;
|
||||
|
||||
export type Namespace = (typeof namespaces)[number];
|
||||
|
||||
const storage = createMMKV({ id: "i18n-settings" });
|
||||
|
||||
export function getStoredLanguage(): StoredLanguage | undefined {
|
||||
const stored = storage.getString("language");
|
||||
if (stored === "system") return "system";
|
||||
if (stored && LANGUAGES.some((l) => l.code === stored)) {
|
||||
return stored as LanguageCode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function setStoredLanguage(code: StoredLanguage) {
|
||||
storage.set("language", code);
|
||||
}
|
||||
|
||||
export function getDeviceLanguage(): LanguageCode {
|
||||
const deviceLocale = getLocales()[0]?.languageCode ?? "en";
|
||||
if (LANGUAGES.some((l) => l.code === deviceLocale)) {
|
||||
return deviceLocale as LanguageCode;
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function resolveLanguage(stored: StoredLanguage | undefined): LanguageCode {
|
||||
if (stored === "system" || stored === undefined) return getDeviceLanguage();
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function useSystemLanguageListener() {
|
||||
const appState = useRef(AppState.currentState);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
|
||||
if (appState.current.match(/inactive|background/) && nextState === "active") {
|
||||
const stored = getStoredLanguage();
|
||||
if (stored === "system") {
|
||||
const deviceLang = getDeviceLanguage();
|
||||
if (i18n.language !== deviceLang) {
|
||||
i18n.changeLanguage(deviceLang);
|
||||
}
|
||||
}
|
||||
}
|
||||
appState.current = nextState;
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, []);
|
||||
}
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
en: {
|
||||
common: enCommon,
|
||||
navigation: enNavigation,
|
||||
workout: enWorkout,
|
||||
"active-workout": enActiveWorkout,
|
||||
history: enHistory,
|
||||
settings: enSettings,
|
||||
},
|
||||
nl: {
|
||||
common: nlCommon,
|
||||
navigation: nlNavigation,
|
||||
workout: nlWorkout,
|
||||
"active-workout": nlActiveWorkout,
|
||||
history: nlHistory,
|
||||
settings: nlSettings,
|
||||
},
|
||||
de: {
|
||||
common: deCommon,
|
||||
navigation: deNavigation,
|
||||
workout: deWorkout,
|
||||
"active-workout": deActiveWorkout,
|
||||
history: deHistory,
|
||||
settings: deSettings,
|
||||
},
|
||||
fr: {
|
||||
common: frCommon,
|
||||
navigation: frNavigation,
|
||||
workout: frWorkout,
|
||||
"active-workout": frActiveWorkout,
|
||||
history: frHistory,
|
||||
settings: frSettings,
|
||||
},
|
||||
it: {
|
||||
common: itCommon,
|
||||
navigation: itNavigation,
|
||||
workout: itWorkout,
|
||||
"active-workout": itActiveWorkout,
|
||||
history: itHistory,
|
||||
settings: itSettings,
|
||||
},
|
||||
es: {
|
||||
common: esCommon,
|
||||
navigation: esNavigation,
|
||||
workout: esWorkout,
|
||||
"active-workout": esActiveWorkout,
|
||||
history: esHistory,
|
||||
settings: esSettings,
|
||||
},
|
||||
pl: {
|
||||
common: plCommon,
|
||||
navigation: plNavigation,
|
||||
workout: plWorkout,
|
||||
"active-workout": plActiveWorkout,
|
||||
history: plHistory,
|
||||
settings: plSettings,
|
||||
},
|
||||
pt: {
|
||||
common: ptCommon,
|
||||
navigation: ptNavigation,
|
||||
workout: ptWorkout,
|
||||
"active-workout": ptActiveWorkout,
|
||||
history: ptHistory,
|
||||
settings: ptSettings,
|
||||
},
|
||||
dk: {
|
||||
common: dkCommon,
|
||||
navigation: dkNavigation,
|
||||
workout: dkWorkout,
|
||||
"active-workout": dkActiveWorkout,
|
||||
history: dkHistory,
|
||||
settings: dkSettings,
|
||||
},
|
||||
},
|
||||
lng: resolveLanguage(getStoredLanguage()),
|
||||
fallbackLng: "en",
|
||||
ns: [...namespaces],
|
||||
defaultNS: "common",
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"workoutSentToWatch": "Allenamento inviato alla Watch!",
|
||||
"openWatchApp": "Apri l'app SwimBuddy sulla tua Apple Watch per iniziare il monitoraggio.",
|
||||
"failedToSend": "Invio fallito",
|
||||
"couldNotSend": "Impossibile inviare l'allenamento alla Watch. Avvio senza Watch.",
|
||||
"startWithoutWatch": "Avvia senza Watch",
|
||||
"sendToWatchAndStart": "Invia alla Watch e avvia",
|
||||
"syncToWatch": "L'allenamento verrà sincronizzato con la tua Apple Watch",
|
||||
"trackedOnDevice": "L'allenamento verrà monitorato su questo dispositivo",
|
||||
"rest": "Recupero",
|
||||
"betweenSets": "{{duration}}s di recupero tra le serie",
|
||||
"swimComplete": "Nuoto completato!",
|
||||
"greatJob": "Ottimo lavoro! Sei entrato in acqua oggi!",
|
||||
"loadingSummary": "Caricamento riepilogo...",
|
||||
"watchStatus": {
|
||||
"ready": "Apple Watch pronta",
|
||||
"pairedNotInRange": "Watch associata ma fuori portata",
|
||||
"pairedNotInstalled": "Watch associata, app non installata",
|
||||
"noWatch": "Nessuna Watch associata"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"done": "Fatto",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"start": "Inizia",
|
||||
"back": "Indietro",
|
||||
"loading": "Caricamento...",
|
||||
"error": "Errore",
|
||||
"save": "Salva",
|
||||
"saving": "Salvataggio...",
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
"confirm": "Conferma",
|
||||
"ok": "OK",
|
||||
"distance": "Distanza",
|
||||
"duration": "Durata",
|
||||
"workouts": "Allenamenti",
|
||||
"sets": "Serie",
|
||||
"laps": "Lunghezze",
|
||||
"avgSwolf": "SWOLF media",
|
||||
"pool": "Piscina di {{length}}m",
|
||||
"untitledWorkout": "Allenamento senza nome",
|
||||
"swimSession": "Sessione di nuoto",
|
||||
"workout": "Allenamento",
|
||||
"kcal": "{{count}} kcal",
|
||||
"meter": "{{count}}m",
|
||||
"kilometer": "{{count}}km",
|
||||
"pace": "{{pace}}/100m",
|
||||
"noPace": "--:--",
|
||||
"stroke": {
|
||||
"freestyle": "Stile libero",
|
||||
"backstroke": "Dorso",
|
||||
"breaststroke": "Rana",
|
||||
"butterfly": "Farfalla",
|
||||
"mixed": "Misto"
|
||||
},
|
||||
"alert": {
|
||||
"deleteWorkout": "Elimina allenamento",
|
||||
"deleteTemplate": "Elimina modello",
|
||||
"areYouSure": "Sei sicuro?",
|
||||
"deleteConfirm": "Eliminare \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"noCompletedYet": "Nessun allenamento completato ancora",
|
||||
"lap": "Lunghezza {{number}}",
|
||||
"statistics": "Statistiche",
|
||||
"thisWeek": "Questa settimana",
|
||||
"allTime": "Tutti i tempi",
|
||||
"personalBests": "Record personali",
|
||||
"noDataYet": "Nessun dato ancora",
|
||||
"swolf": "SWOLF: {{value}}"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"workouts": "Allenamenti",
|
||||
"history": "Cronologia",
|
||||
"settings": "Impostazioni",
|
||||
"newWorkout": "Nuovo allenamento",
|
||||
"buildWorkout": "Crea allenamento",
|
||||
"startSwim": "Inizia nuoto",
|
||||
"readyToSwim": "Pronto per nuotare",
|
||||
"swimComplete": "Nuoto completato",
|
||||
"session": "Sessione",
|
||||
"sessionDetail": "Dettagli sessione"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"settings": "Impostazioni",
|
||||
"defaultPoolLength": "Lunghezza piscina predefinita",
|
||||
"language": "Lingua",
|
||||
"systemLanguage": "Lingua di sistema",
|
||||
"version": "SwimBuddy v1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workoutName": "Nome allenamento",
|
||||
"poolLength": "Lunghezza piscina",
|
||||
"stroke": "Stile",
|
||||
"distanceM": "Distanza (m)",
|
||||
"reps": "Ripetizioni",
|
||||
"restS": "Recupero (s)",
|
||||
"addSet": "+ Aggiungi serie",
|
||||
"remove": "Rimuovi",
|
||||
"setNumber": "Serie {{number}}",
|
||||
"startSwim": "Inizia nuoto",
|
||||
"newWorkout": "+ Nuovo allenamento",
|
||||
"noWorkoutsYet": "Nessun allenamento ancora",
|
||||
"tapNewWorkout": "Tocca \"Nuovo allenamento\" per iniziare",
|
||||
"morningSwim": "Nuoto mattutino",
|
||||
"placeholder": {
|
||||
"workoutName": "Nuoto mattutino"
|
||||
},
|
||||
"validation": {
|
||||
"error": "Errore",
|
||||
"failedToSave": "Impossibile salvare l'allenamento: {{error}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"workoutSentToWatch": "Workout verzonden naar Watch!",
|
||||
"openWatchApp": "Open de SwimBuddy-app op je Apple Watch om te beginnen met bijhouden.",
|
||||
"failedToSend": "Verzenden mislukt",
|
||||
"couldNotSend": "Kon workout niet naar Watch verzenden. Start zonder Watch.",
|
||||
"startWithoutWatch": "Start zonder Watch",
|
||||
"sendToWatchAndStart": "Verzenden naar Watch & Start",
|
||||
"syncToWatch": "Workout wordt gesynchroniseerd naar je Apple Watch",
|
||||
"trackedOnDevice": "Workout wordt op dit apparaat bijgehouden",
|
||||
"rest": "Rust",
|
||||
"betweenSets": "{{duration}}s rust tussen sets",
|
||||
"swimComplete": "Zwemmen voltooid!",
|
||||
"greatJob": "Goed gedaan! Je bent vandaag het water in geweest!",
|
||||
"loadingSummary": "Samenvatting laden...",
|
||||
"watchStatus": {
|
||||
"ready": "Apple Watch gereed",
|
||||
"pairedNotInRange": "Watch gekoppeld maar niet in bereik",
|
||||
"pairedNotInstalled": "Watch gekoppeld, app niet geïnstalleerd",
|
||||
"noWatch": "Geen Watch gekoppeld"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"done": "Gereed",
|
||||
"cancel": "Annuleren",
|
||||
"delete": "Verwijderen",
|
||||
"start": "Start",
|
||||
"back": "Terug",
|
||||
"loading": "Laden...",
|
||||
"error": "Fout",
|
||||
"save": "Opslaan",
|
||||
"saving": "Opslaan...",
|
||||
"yes": "Ja",
|
||||
"no": "Nee",
|
||||
"confirm": "Bevestigen",
|
||||
"ok": "OK",
|
||||
"distance": "Afstand",
|
||||
"duration": "Duur",
|
||||
"workouts": "Workouts",
|
||||
"sets": "Sets",
|
||||
"laps": "Banen",
|
||||
"avgSwolf": "Gem. SWOLF",
|
||||
"pool": "{{length}}m bad",
|
||||
"untitledWorkout": "Naamloze Workout",
|
||||
"swimSession": "Zwemsessie",
|
||||
"workout": "Workout",
|
||||
"kcal": "{{count}} kcal",
|
||||
"meter": "{{count}}m",
|
||||
"kilometer": "{{count}}km",
|
||||
"pace": "{{pace}}/100m",
|
||||
"noPace": "--:--",
|
||||
"stroke": {
|
||||
"freestyle": "Vrije slag",
|
||||
"backstroke": "Rugslag",
|
||||
"breaststroke": "Schoolslag",
|
||||
"butterfly": "Vlinderslag",
|
||||
"mixed": "Gemengd"
|
||||
},
|
||||
"alert": {
|
||||
"deleteWorkout": "Workout verwijderen",
|
||||
"deleteTemplate": "Sjabloon verwijderen",
|
||||
"areYouSure": "Weet je het zeker?",
|
||||
"deleteConfirm": "\"{{name}}\" verwijderen?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"noCompletedYet": "Nog geen voltooide workouts",
|
||||
"lap": "Baan {{number}}",
|
||||
"statistics": "Statistieken",
|
||||
"thisWeek": "Deze week",
|
||||
"allTime": "Alle tijd",
|
||||
"personalBests": "Persoonlijke records",
|
||||
"noDataYet": "Nog geen gegevens",
|
||||
"swolf": "SWOLF: {{value}}"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"workouts": "Workouts",
|
||||
"history": "Geschiedenis",
|
||||
"settings": "Instellingen",
|
||||
"newWorkout": "Nieuwe Workout",
|
||||
"buildWorkout": "Workout samenstellen",
|
||||
"startSwim": "Start Zwemmen",
|
||||
"readyToSwim": "Klaar om te zwemmen",
|
||||
"swimComplete": "Zwemmen voltooid",
|
||||
"session": "Sessie",
|
||||
"sessionDetail": "Sessiedetails"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"settings": "Instellingen",
|
||||
"defaultPoolLength": "Standaard badlengte",
|
||||
"language": "Taal",
|
||||
"systemLanguage": "Systeemtaal",
|
||||
"version": "SwimBuddy v1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workoutName": "Workoutnaam",
|
||||
"poolLength": "Badlengte",
|
||||
"stroke": "Slag",
|
||||
"distanceM": "Afstand (m)",
|
||||
"reps": "Herhalingen",
|
||||
"restS": "Rust (s)",
|
||||
"addSet": "+ Set toevoegen",
|
||||
"remove": "Verwijderen",
|
||||
"setNumber": "Set {{number}}",
|
||||
"startSwim": "Start Zwemmen",
|
||||
"newWorkout": "+ Nieuwe Workout",
|
||||
"noWorkoutsYet": "Nog geen workouts",
|
||||
"tapNewWorkout": "Tik op \"Nieuwe Workout\" om te beginnen",
|
||||
"morningSwim": "Ochtendzwemmen",
|
||||
"placeholder": {
|
||||
"workoutName": "Ochtendzwemmen"
|
||||
},
|
||||
"validation": {
|
||||
"error": "Fout",
|
||||
"failedToSave": "Workout opslaan mislukt: {{error}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"workoutSentToWatch": "Trening wysłany do Watch!",
|
||||
"openWatchApp": "Otwórz aplikację SwimBuddy na swojej Apple Watch, aby rozpocząć śledzenie.",
|
||||
"failedToSend": "Nie udało się wysłać",
|
||||
"couldNotSend": "Nie udało się wysłać treningu do Watch. Uruchamianie bez Watch.",
|
||||
"startWithoutWatch": "Uruchom bez Watch",
|
||||
"sendToWatchAndStart": "Wyślij do Watch i rozpocznij",
|
||||
"syncToWatch": "Trening zostanie zsynchronizowany z Apple Watch",
|
||||
"trackedOnDevice": "Trening będzie śledzony na tym urządzeniu",
|
||||
"rest": "Odpoczynek",
|
||||
"betweenSets": "{{duration}}s odpoczynku między seriami",
|
||||
"swimComplete": "Pływanie zakończone!",
|
||||
"greatJob": "Świetna robota! Dziś byłeś w wodzie!",
|
||||
"loadingSummary": "Ładowanie podsumowania...",
|
||||
"watchStatus": {
|
||||
"ready": "Apple Watch gotowa",
|
||||
"pairedNotInRange": "Watch sparowana, ale poza zasięgiem",
|
||||
"pairedNotInstalled": "Watch sparowana, aplikacja nie zainstalowana",
|
||||
"noWatch": "Brak sparowanej Watch"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"done": "Gotowe",
|
||||
"cancel": "Anuluj",
|
||||
"delete": "Usuń",
|
||||
"start": "Rozpocznij",
|
||||
"back": "Wstecz",
|
||||
"loading": "Ładowanie...",
|
||||
"error": "Błąd",
|
||||
"save": "Zapisz",
|
||||
"saving": "Zapisywanie...",
|
||||
"yes": "Tak",
|
||||
"no": "Nie",
|
||||
"confirm": "Potwierdź",
|
||||
"ok": "OK",
|
||||
"distance": "Dystans",
|
||||
"duration": "Czas",
|
||||
"workouts": "Treningi",
|
||||
"sets": "Serie",
|
||||
"laps": "Długości",
|
||||
"avgSwolf": "Śr. SWOLF",
|
||||
"pool": "Basen {{length}}m",
|
||||
"untitledWorkout": "Trening bez nazwy",
|
||||
"swimSession": "Sesja pływacka",
|
||||
"workout": "Trening",
|
||||
"kcal": "{{count}} kcal",
|
||||
"meter": "{{count}}m",
|
||||
"kilometer": "{{count}}km",
|
||||
"pace": "{{pace}}/100m",
|
||||
"noPace": "--:--",
|
||||
"stroke": {
|
||||
"freestyle": "Styl dowolny",
|
||||
"backstroke": "Grzbietowy",
|
||||
"breaststroke": "Klasyk",
|
||||
"butterfly": "Delfin",
|
||||
"mixed": "Mieszany"
|
||||
},
|
||||
"alert": {
|
||||
"deleteWorkout": "Usuń trening",
|
||||
"deleteTemplate": "Usuń szablon",
|
||||
"areYouSure": "Na pewno?",
|
||||
"deleteConfirm": "Usunąć \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"noCompletedYet": "Brak ukończonych treningów",
|
||||
"lap": "Długość {{number}}",
|
||||
"statistics": "Statystyki",
|
||||
"thisWeek": "Ten tydzień",
|
||||
"allTime": "Cały okres",
|
||||
"personalBests": "Rekordy osobiste",
|
||||
"noDataYet": "Brak danych",
|
||||
"swolf": "SWOLF: {{value}}"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"workouts": "Treningi",
|
||||
"history": "Historia",
|
||||
"settings": "Ustawienia",
|
||||
"newWorkout": "Nowy trening",
|
||||
"buildWorkout": "Stwórz trening",
|
||||
"startSwim": "Rozpocznij pływanie",
|
||||
"readyToSwim": "Gotowy do pływania",
|
||||
"swimComplete": "Pływanie zakończone",
|
||||
"session": "Sesja",
|
||||
"sessionDetail": "Szczegóły sesji"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"settings": "Ustawienia",
|
||||
"defaultPoolLength": "Domyślna długość basenu",
|
||||
"language": "Język",
|
||||
"systemLanguage": "Język systemowy",
|
||||
"version": "SwimBuddy v1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workoutName": "Nazwa treningu",
|
||||
"poolLength": "Długość basenu",
|
||||
"stroke": "Styl",
|
||||
"distanceM": "Dystans (m)",
|
||||
"reps": "Powtórzenia",
|
||||
"restS": "Odpoczynek (s)",
|
||||
"addSet": "+ Dodaj serię",
|
||||
"remove": "Usuń",
|
||||
"setNumber": "Seria {{number}}",
|
||||
"startSwim": "Rozpocznij pływanie",
|
||||
"newWorkout": "+ Nowy trening",
|
||||
"noWorkoutsYet": "Brak treningów",
|
||||
"tapNewWorkout": "Stuknij \"Nowy trening\" aby rozpocząć",
|
||||
"morningSwim": "Poranne pływanie",
|
||||
"placeholder": {
|
||||
"workoutName": "Poranne pływanie"
|
||||
},
|
||||
"validation": {
|
||||
"error": "Błąd",
|
||||
"failedToSave": "Nie udało się zapisać treningu: {{error}}"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"workoutSentToWatch": "Treino enviado para a Watch!",
|
||||
"openWatchApp": "Abra a aplicação SwimBuddy na sua Apple Watch para iniciar o registo.",
|
||||
"failedToSend": "Falha no envio",
|
||||
"couldNotSend": "Não foi possível enviar o treino para a Watch. Iniciando sem Watch.",
|
||||
"startWithoutWatch": "Iniciar sem Watch",
|
||||
"sendToWatchAndStart": "Enviar para a Watch e iniciar",
|
||||
"syncToWatch": "O treino será sincronizado com a sua Apple Watch",
|
||||
"trackedOnDevice": "O treino será registado neste dispositivo",
|
||||
"rest": "Descanso",
|
||||
"betweenSets": "{{duration}}s de descanso entre séries",
|
||||
"swimComplete": "Natação concluída!",
|
||||
"greatJob": "Bom trabalho! Esteve na água hoje!",
|
||||
"loadingSummary": "A carregar resumo...",
|
||||
"watchStatus": {
|
||||
"ready": "Apple Watch pronta",
|
||||
"pairedNotInRange": "Watch associada, mas fora de alcance",
|
||||
"pairedNotInstalled": "Watch associada, aplicação não instalada",
|
||||
"noWatch": "Nenhuma Watch associada"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"done": "Concluído",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"start": "Iniciar",
|
||||
"back": "Voltar",
|
||||
"loading": "A carregar...",
|
||||
"error": "Erro",
|
||||
"save": "Guardar",
|
||||
"saving": "A guardar...",
|
||||
"yes": "Sim",
|
||||
"no": "Não",
|
||||
"confirm": "Confirmar",
|
||||
"ok": "OK",
|
||||
"distance": "Distância",
|
||||
"duration": "Duração",
|
||||
"workouts": "Treinos",
|
||||
"sets": "Séries",
|
||||
"laps": "Comprimentos",
|
||||
"avgSwolf": "SWOLF médio",
|
||||
"pool": "Piscina de {{length}}m",
|
||||
"untitledWorkout": "Treino sem nome",
|
||||
"swimSession": "Sessão de natação",
|
||||
"workout": "Treino",
|
||||
"kcal": "{{count}} kcal",
|
||||
"meter": "{{count}}m",
|
||||
"kilometer": "{{count}}km",
|
||||
"pace": "{{pace}}/100m",
|
||||
"noPace": "--:--",
|
||||
"stroke": {
|
||||
"freestyle": "Estilo livre",
|
||||
"backstroke": "Costas",
|
||||
"breaststroke": "Peito",
|
||||
"butterfly": "Borboleta",
|
||||
"mixed": "Misto"
|
||||
},
|
||||
"alert": {
|
||||
"deleteWorkout": "Eliminar treino",
|
||||
"deleteTemplate": "Eliminar modelo",
|
||||
"areYouSure": "Tem certeza?",
|
||||
"deleteConfirm": "Eliminar \"{{name}}\"?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"noCompletedYet": "Ainda não há treinos concluídos",
|
||||
"lap": "Comprimento {{number}}",
|
||||
"statistics": "Estatísticas",
|
||||
"thisWeek": "Esta semana",
|
||||
"allTime": "Todo o tempo",
|
||||
"personalBests": "Recordes pessoais",
|
||||
"noDataYet": "Ainda sem dados",
|
||||
"swolf": "SWOLF: {{value}}"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"workouts": "Treinos",
|
||||
"history": "Histórico",
|
||||
"settings": "Definições",
|
||||
"newWorkout": "Novo treino",
|
||||
"buildWorkout": "Criar treino",
|
||||
"startSwim": "Iniciar natação",
|
||||
"readyToSwim": "Pronto para nadar",
|
||||
"swimComplete": "Natação concluída",
|
||||
"session": "Sessão",
|
||||
"sessionDetail": "Detalhes da sessão"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"settings": "Definições",
|
||||
"defaultPoolLength": "Comprimento de piscina predefinido",
|
||||
"language": "Idioma",
|
||||
"systemLanguage": "Idioma do sistema",
|
||||
"version": "SwimBuddy v1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"workoutName": "Nome do treino",
|
||||
"poolLength": "Comprimento da piscina",
|
||||
"stroke": "Estilo",
|
||||
"distanceM": "Distância (m)",
|
||||
"reps": "Repetições",
|
||||
"restS": "Descanso (s)",
|
||||
"addSet": "+ Adicionar série",
|
||||
"remove": "Remover",
|
||||
"setNumber": "Série {{number}}",
|
||||
"startSwim": "Iniciar natação",
|
||||
"newWorkout": "+ Novo treino",
|
||||
"noWorkoutsYet": "Ainda não há treinos",
|
||||
"tapNewWorkout": "Toque em \"Novo treino\" para começar",
|
||||
"morningSwim": "Natação matinal",
|
||||
"placeholder": {
|
||||
"workoutName": "Natação matinal"
|
||||
},
|
||||
"validation": {
|
||||
"error": "Erro",
|
||||
"failedToSave": "Falha ao guardar o treino: {{error}}"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import { HistoryStackParamList } from "./types";
|
||||
import { HistoryScreen } from "../features/history/screens/HistoryScreen";
|
||||
@@ -6,6 +7,8 @@ import { SessionDetailScreen } from "../features/history/screens/SessionDetailSc
|
||||
const Stack = createNativeStackNavigator<HistoryStackParamList>();
|
||||
|
||||
export function HistoryNavigator() {
|
||||
const { t } = useTranslation("navigation");
|
||||
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
@@ -17,12 +20,12 @@ export function HistoryNavigator() {
|
||||
<Stack.Screen
|
||||
name="HistoryList"
|
||||
component={HistoryScreen}
|
||||
options={{ title: "History" }}
|
||||
options={{ title: t("history") }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SessionDetail"
|
||||
component={SessionDetailScreen}
|
||||
options={{ title: "Session Detail" }}
|
||||
options={{ title: t("sessionDetail") }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { MainTabParamList } from "./types";
|
||||
@@ -8,6 +9,8 @@ import { SettingsScreen } from "../features/settings/screens/SettingsScreen";
|
||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||
|
||||
export function MainTabNavigator() {
|
||||
const { t } = useTranslation("navigation");
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
@@ -23,6 +26,7 @@ export function MainTabNavigator() {
|
||||
component={WorkoutNavigator}
|
||||
options={{
|
||||
headerShown: false,
|
||||
tabBarLabel: t("workouts"),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="water" size={size} color={color} />
|
||||
),
|
||||
@@ -33,6 +37,7 @@ export function MainTabNavigator() {
|
||||
component={HistoryNavigator}
|
||||
options={{
|
||||
headerShown: false,
|
||||
tabBarLabel: t("history"),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="time-outline" size={size} color={color} />
|
||||
),
|
||||
@@ -42,6 +47,7 @@ export function MainTabNavigator() {
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{
|
||||
tabBarLabel: t("settings"),
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="settings-outline" size={size} color={color} />
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import { MainTabNavigator } from "./MainTabNavigator";
|
||||
import { RootStackParamList } from "./types";
|
||||
@@ -9,6 +10,8 @@ import { SessionDetailScreen } from "../features/history/screens/SessionDetailSc
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
export function RootNavigator() {
|
||||
const { t } = useTranslation("navigation");
|
||||
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
@@ -25,22 +28,22 @@ export function RootNavigator() {
|
||||
<Stack.Screen
|
||||
name="WorkoutBuilder"
|
||||
component={BuilderScreen}
|
||||
options={{ title: "New Workout" }}
|
||||
options={{ title: t("newWorkout") }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PreSwim"
|
||||
component={PreSwimScreen}
|
||||
options={{ title: "Ready to Swim" }}
|
||||
options={{ title: t("readyToSwim") }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PostSwim"
|
||||
component={PostSwimScreen}
|
||||
options={{ title: "Swim Complete", headerLeft: () => null }}
|
||||
options={{ title: t("swimComplete"), headerLeft: () => null }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SessionDetail"
|
||||
component={SessionDetailScreen}
|
||||
options={{ title: "Session" }}
|
||||
options={{ title: t("session") }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createNativeStackNavigator } from "@react-navigation/native-stack";
|
||||
import { WorkoutStackParamList } from "./types";
|
||||
import { WorkoutListScreen } from "../features/workout-builder/screens/WorkoutListScreen";
|
||||
@@ -7,6 +8,8 @@ import { PreSwimScreen } from "../features/active-workout/screens/PreSwimScreen"
|
||||
const Stack = createNativeStackNavigator<WorkoutStackParamList>();
|
||||
|
||||
export function WorkoutNavigator() {
|
||||
const { t } = useTranslation("navigation");
|
||||
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
@@ -18,17 +21,17 @@ export function WorkoutNavigator() {
|
||||
<Stack.Screen
|
||||
name="WorkoutList"
|
||||
component={WorkoutListScreen}
|
||||
options={{ title: "Workouts" }}
|
||||
options={{ title: t("workouts") }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Builder"
|
||||
component={BuilderScreen}
|
||||
options={{ title: "Build Workout" }}
|
||||
options={{ title: t("buildWorkout") }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PreSwim"
|
||||
component={PreSwimScreen}
|
||||
options={{ title: "Start Swim" }}
|
||||
options={{ title: t("startSwim") }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
import { createMMKV } from "react-native-mmkv";
|
||||
import i18n, {
|
||||
type LanguageCode,
|
||||
type StoredLanguage,
|
||||
getStoredLanguage,
|
||||
setStoredLanguage,
|
||||
resolveLanguage,
|
||||
} from "../i18n";
|
||||
|
||||
const storage = createMMKV();
|
||||
|
||||
type SettingsContextType = {
|
||||
poolLength: number;
|
||||
setPoolLength: (length: number) => void;
|
||||
language: StoredLanguage;
|
||||
setLanguage: (code: StoredLanguage) => void;
|
||||
};
|
||||
|
||||
const SettingsContext = createContext<SettingsContextType>({
|
||||
poolLength: 25,
|
||||
setPoolLength: () => {},
|
||||
language: "system",
|
||||
setLanguage: () => {},
|
||||
});
|
||||
|
||||
export function useSettings() {
|
||||
@@ -23,13 +34,23 @@ export function SettingsProvider({ children }: { children: ReactNode }) {
|
||||
return stored ?? 25;
|
||||
});
|
||||
|
||||
const [language, setLanguageState] = useState<StoredLanguage>(() => {
|
||||
return getStoredLanguage() ?? "system";
|
||||
});
|
||||
|
||||
const setPoolLength = (length: number) => {
|
||||
setPoolLengthState(length);
|
||||
storage.set("poolLength", length);
|
||||
};
|
||||
|
||||
const setLanguage = (code: StoredLanguage) => {
|
||||
setLanguageState(code);
|
||||
setStoredLanguage(code);
|
||||
i18n.changeLanguage(resolveLanguage(code));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ poolLength, setPoolLength }}>
|
||||
<SettingsContext.Provider value={{ poolLength, setPoolLength, language, setLanguage }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ struct WorkoutListView: View {
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(workout.name ?? "Workout")
|
||||
Text(workout.name ?? String(localized: "Workout"))
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
Text("\(workout.sets.reduce(0) { $0 + $1.distancePerRep * $1.repCount })m · \(workout.sets.count) sets")
|
||||
@@ -73,7 +73,7 @@ struct WorkoutListView: View {
|
||||
ForEach(workoutManager.completedWorkouts.prefix(3)) { workout in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(workout.name ?? "Workout")
|
||||
Text(workout.name ?? String(localized: "Workout"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.primary)
|
||||
Text(workout.completedAt ?? "")
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"No workouts yet": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "No workouts yet" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Nog geen workouts" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Noch keine Workouts" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Aucun entraînement pour le moment" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Nessun allenamento ancora" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Aún no hay entrenamientos" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Brak treningów" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Ainda não há treinos" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Ingen træninger endnu" } }
|
||||
}
|
||||
},
|
||||
"Create a workout on your iPhone to get started": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Create a workout on your iPhone to get started" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Maak een workout op je iPhone om te beginnen" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Erstelle ein Workout auf deinem iPhone um zu beginnen" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Créez un entraînement sur votre iPhone pour commencer" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Crea un allenamento sul tuo iPhone per iniziare" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Crea un entrenamiento en tu iPhone para empezar" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Stwórz trening na swoim iPhone, aby rozpocząć" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Crie um treino no seu iPhone para começar" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Opret en træning på din iPhone for at komme i gang" } }
|
||||
}
|
||||
},
|
||||
"Saved Workouts": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Saved Workouts" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Opgeslagen workouts" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Gespeicherte Workouts" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Entraînements enregistrés" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Allenamenti salvati" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Entrenamientos guardados" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Zapisane treningi" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Treinos guardados" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Gemte træninger" } }
|
||||
}
|
||||
},
|
||||
"Workout": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Workout" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Workout" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Workout" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Entraînement" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Allenamento" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Entrenamiento" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Trening" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Treino" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Træning" } }
|
||||
}
|
||||
},
|
||||
"Recent": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Recent" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Recent" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Zuletzt" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Récent" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Recenti" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Reciente" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Ostatnie" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Recente" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Seneste" } }
|
||||
}
|
||||
},
|
||||
"Sets": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Sets" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Sets" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Sets" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Séries" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Serie" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Series" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Serie" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Séries" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Sæt" } }
|
||||
}
|
||||
},
|
||||
"Start": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Start" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Start" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Starten" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Commencer" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Inizia" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Iniciar" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Rozpocznij" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Iniciar" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Start" } }
|
||||
}
|
||||
},
|
||||
"Back": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Back" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Terug" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Zurück" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Retour" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Indietro" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Volver" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Wstecz" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Voltar" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Tilbage" } }
|
||||
}
|
||||
},
|
||||
"Complete Lap": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Complete Lap" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Baan voltooien" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Bahn abschließen" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Terminer la longueur" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Completa lunghezza" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Completar largo" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Ukończ długość" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Completar comprimento" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Fuldfør bane" } }
|
||||
}
|
||||
},
|
||||
"End": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "End" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Einde" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Beenden" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Terminer" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Fine" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Finalizar" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Koniec" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Fim" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Slut" } }
|
||||
}
|
||||
},
|
||||
"Rest": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Rest" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Rust" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Pause" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Repos" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Recupero" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Descanso" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Odpoczynek" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Descanso" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Hvile" } }
|
||||
}
|
||||
},
|
||||
"Lap detected!": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "Lap detected!" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "Baan gedetecteerd!" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "Bahn erkannt!" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Longueur détectée !" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Lunghezza rilevata!" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "¡Largo detectado!" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Wykryto długość!" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Comprimento detectado!" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "Bane registreret!" } }
|
||||
}
|
||||
},
|
||||
"m pool": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "%@m pool" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "%@m bad" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "%@m Becken" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "Bassin de %@m" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "Piscina di %@m" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "Piscina de %@m" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "Basen %@m" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "Piscina de %@m" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "%@m pool" } }
|
||||
}
|
||||
},
|
||||
"m total": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "%@m total" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "%@m totaal" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "%@m gesamt" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "%@m au total" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "%@m totale" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "%@m total" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "%@m łącznie" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "%@m total" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "%@m i alt" } }
|
||||
}
|
||||
},
|
||||
"kcal": {
|
||||
"extractionState": "manual",
|
||||
"localizations": {
|
||||
"en": { "stringUnit": { "state": "translated", "value": "%@kcal" } },
|
||||
"nl": { "stringUnit": { "state": "translated", "value": "%@kcal" } },
|
||||
"de": { "stringUnit": { "state": "translated", "value": "%@kcal" } },
|
||||
"fr": { "stringUnit": { "state": "translated", "value": "%@kcal" } },
|
||||
"it": { "stringUnit": { "state": "translated", "value": "%@kcal" } },
|
||||
"es": { "stringUnit": { "state": "translated", "value": "%@kcal" } },
|
||||
"pl": { "stringUnit": { "state": "translated", "value": "%@kcal" } },
|
||||
"pt": { "stringUnit": { "state": "translated", "value": "%@kcal" } },
|
||||
"dk": { "stringUnit": { "state": "translated", "value": "%@kcal" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ struct SwimView: View {
|
||||
workoutManager.currentSetIndex < workout.sets.count {
|
||||
let currentSet = workout.sets[workoutManager.currentSetIndex]
|
||||
|
||||
// Set/Rep info
|
||||
HStack {
|
||||
Text("Set \(workoutManager.currentSetIndex + 1)/\(workout.sets.count)")
|
||||
.font(.caption2)
|
||||
@@ -36,18 +35,15 @@ struct SwimView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// Stroke name
|
||||
Text(currentSet.stroke)
|
||||
.font(.headline)
|
||||
.foregroundColor(.cyan)
|
||||
|
||||
// Current set distance
|
||||
Text("\(Int(workoutManager.currentSetDistanceSwum))/\(Int(workoutManager.currentSetTotalDistance))m")
|
||||
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.cyan)
|
||||
}
|
||||
|
||||
// Total distance
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "figure.pool.swim")
|
||||
.font(.caption)
|
||||
@@ -57,11 +53,9 @@ struct SwimView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// Elapsed time
|
||||
Text(formatTime(workoutManager.elapsedSeconds))
|
||||
.font(.system(.title3, design: .monospaced))
|
||||
|
||||
// Calories + Heart rate
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "flame.fill")
|
||||
@@ -82,7 +76,6 @@ struct SwimView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Lap detection indicator
|
||||
if workoutManager.lapDetected {
|
||||
Text("Lap detected!")
|
||||
.font(.caption)
|
||||
@@ -138,7 +131,6 @@ struct RestView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// Show stats during rest too
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "figure.pool.swim")
|
||||
|
||||
@@ -8,7 +8,7 @@ struct WorkoutDetailView: View {
|
||||
if let workout = workoutManager.selectedWorkout {
|
||||
Section {
|
||||
VStack(spacing: 8) {
|
||||
Text(workout.name ?? "Workout")
|
||||
Text(workout.name ?? String(localized: "Workout"))
|
||||
.font(.headline)
|
||||
|
||||
Text("\(Int(workout.poolLength))m pool")
|
||||
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"@constants/*": ["./src/constants/*"],
|
||||
"@db/*": ["./src/db/*"],
|
||||
"@navigation/*": ["./src/navigation/*"],
|
||||
"@types/*": ["./src/types/*"]
|
||||
"@types/*": ["./src/types/*"],
|
||||
"@i18n/*": ["./src/i18n/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", "nativewind-env.d.ts"]
|
||||
|
||||
Reference in New Issue
Block a user