From 9946e7885834f70455f4f0b5ffc4d45a3a74b22b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB?= Date: Sat, 8 Aug 2026 10:09:30 +0200 Subject: [PATCH] feat(i18n): add internationalization with i18next and expo-localization --- App.tsx | 8 + app.json | 5 +- package-lock.json | 86 +++++++ package.json | 3 + src/components/SwipeableRow.tsx | 7 +- .../active-workout/screens/PostSwimScreen.tsx | 19 +- .../active-workout/screens/PreSwimScreen.tsx | 41 ++-- .../history/screens/HistoryScreen.tsx | 17 +- .../history/screens/SessionDetailScreen.tsx | 27 +- src/features/history/screens/StatsScreen.tsx | 31 +-- .../settings/components/LanguagePicker.tsx | 48 ++++ .../settings/screens/SettingsScreen.tsx | 11 +- .../templates/screens/TemplatesScreen.tsx | 14 +- .../hooks/useWorkoutBuilder.ts | 3 +- .../workout-builder/screens/BuilderScreen.tsx | 39 +-- .../screens/WorkoutListScreen.tsx | 21 +- src/i18n/de/active-workout.json | 21 ++ src/i18n/de/common.json | 43 ++++ src/i18n/de/history.json | 10 + src/i18n/de/navigation.json | 12 + src/i18n/de/settings.json | 7 + src/i18n/de/workout.json | 23 ++ src/i18n/dk/active-workout.json | 21 ++ src/i18n/dk/common.json | 43 ++++ src/i18n/dk/history.json | 10 + src/i18n/dk/navigation.json | 12 + src/i18n/dk/settings.json | 7 + src/i18n/dk/workout.json | 23 ++ src/i18n/en/active-workout.json | 21 ++ src/i18n/en/common.json | 43 ++++ src/i18n/en/history.json | 10 + src/i18n/en/navigation.json | 12 + src/i18n/en/settings.json | 7 + src/i18n/en/workout.json | 23 ++ src/i18n/es/active-workout.json | 21 ++ src/i18n/es/common.json | 43 ++++ src/i18n/es/history.json | 10 + src/i18n/es/navigation.json | 12 + src/i18n/es/settings.json | 7 + src/i18n/es/workout.json | 23 ++ src/i18n/fr/active-workout.json | 21 ++ src/i18n/fr/common.json | 43 ++++ src/i18n/fr/history.json | 10 + src/i18n/fr/navigation.json | 12 + src/i18n/fr/settings.json | 7 + src/i18n/fr/workout.json | 23 ++ src/i18n/index.ts | 231 ++++++++++++++++++ src/i18n/it/active-workout.json | 21 ++ src/i18n/it/common.json | 43 ++++ src/i18n/it/history.json | 10 + src/i18n/it/navigation.json | 12 + src/i18n/it/settings.json | 7 + src/i18n/it/workout.json | 23 ++ src/i18n/nl/active-workout.json | 21 ++ src/i18n/nl/common.json | 43 ++++ src/i18n/nl/history.json | 10 + src/i18n/nl/navigation.json | 12 + src/i18n/nl/settings.json | 7 + src/i18n/nl/workout.json | 23 ++ src/i18n/pl/active-workout.json | 21 ++ src/i18n/pl/common.json | 43 ++++ src/i18n/pl/history.json | 10 + src/i18n/pl/navigation.json | 12 + src/i18n/pl/settings.json | 7 + src/i18n/pl/workout.json | 23 ++ src/i18n/pt/active-workout.json | 21 ++ src/i18n/pt/common.json | 43 ++++ src/i18n/pt/history.json | 10 + src/i18n/pt/navigation.json | 12 + src/i18n/pt/settings.json | 7 + src/i18n/pt/workout.json | 23 ++ src/navigation/HistoryNavigator.tsx | 7 +- src/navigation/MainTabNavigator.tsx | 6 + src/navigation/RootNavigator.tsx | 11 +- src/navigation/WorkoutNavigator.tsx | 9 +- src/providers/WatchProvider.tsx | 25 +- targets/watch/ContentView.swift | 4 +- targets/watch/Localizable.xcstrings | 215 ++++++++++++++++ targets/watch/Views/ActiveWorkoutView.swift | 8 - targets/watch/Views/WorkoutDetailView.swift | 2 +- tsconfig.json | 3 +- 81 files changed, 1820 insertions(+), 125 deletions(-) create mode 100644 src/features/settings/components/LanguagePicker.tsx create mode 100644 src/i18n/de/active-workout.json create mode 100644 src/i18n/de/common.json create mode 100644 src/i18n/de/history.json create mode 100644 src/i18n/de/navigation.json create mode 100644 src/i18n/de/settings.json create mode 100644 src/i18n/de/workout.json create mode 100644 src/i18n/dk/active-workout.json create mode 100644 src/i18n/dk/common.json create mode 100644 src/i18n/dk/history.json create mode 100644 src/i18n/dk/navigation.json create mode 100644 src/i18n/dk/settings.json create mode 100644 src/i18n/dk/workout.json create mode 100644 src/i18n/en/active-workout.json create mode 100644 src/i18n/en/common.json create mode 100644 src/i18n/en/history.json create mode 100644 src/i18n/en/navigation.json create mode 100644 src/i18n/en/settings.json create mode 100644 src/i18n/en/workout.json create mode 100644 src/i18n/es/active-workout.json create mode 100644 src/i18n/es/common.json create mode 100644 src/i18n/es/history.json create mode 100644 src/i18n/es/navigation.json create mode 100644 src/i18n/es/settings.json create mode 100644 src/i18n/es/workout.json create mode 100644 src/i18n/fr/active-workout.json create mode 100644 src/i18n/fr/common.json create mode 100644 src/i18n/fr/history.json create mode 100644 src/i18n/fr/navigation.json create mode 100644 src/i18n/fr/settings.json create mode 100644 src/i18n/fr/workout.json create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/it/active-workout.json create mode 100644 src/i18n/it/common.json create mode 100644 src/i18n/it/history.json create mode 100644 src/i18n/it/navigation.json create mode 100644 src/i18n/it/settings.json create mode 100644 src/i18n/it/workout.json create mode 100644 src/i18n/nl/active-workout.json create mode 100644 src/i18n/nl/common.json create mode 100644 src/i18n/nl/history.json create mode 100644 src/i18n/nl/navigation.json create mode 100644 src/i18n/nl/settings.json create mode 100644 src/i18n/nl/workout.json create mode 100644 src/i18n/pl/active-workout.json create mode 100644 src/i18n/pl/common.json create mode 100644 src/i18n/pl/history.json create mode 100644 src/i18n/pl/navigation.json create mode 100644 src/i18n/pl/settings.json create mode 100644 src/i18n/pl/workout.json create mode 100644 src/i18n/pt/active-workout.json create mode 100644 src/i18n/pt/common.json create mode 100644 src/i18n/pt/history.json create mode 100644 src/i18n/pt/navigation.json create mode 100644 src/i18n/pt/settings.json create mode 100644 src/i18n/pt/workout.json create mode 100644 targets/watch/Localizable.xcstrings diff --git a/App.tsx b/App.tsx index 5b467ed..f993db9 100644 --- a/App.tsx +++ b/App.tsx @@ -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 ( + diff --git a/app.json b/app.json index db49dc0..b56d6f9 100644 --- a/app.json +++ b/app.json @@ -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": { diff --git a/package-lock.json b/package-lock.json index 705e570..af3cfbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6fd979c..fadf8b8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index 051207c..1c8bee9 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -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) { - Delete + {deleteLabel} >(); const route = useRoute>(); const [summary, setSummary] = useState<{ @@ -46,38 +49,38 @@ export function PostSwimScreen() { 🏊 - Swim Complete! - Great job getting in the water + {t("swimComplete")} + {t("greatJob")} {summary ? ( - Distance + {tc("distance")} {formatDistance(summary.distance)} - Duration + {tc("duration")} {formatTime(summary.duration)} - Laps + {tc("laps")} {summary.laps} {summary.avgSwolf && ( - Avg SWOLF + {tc("avgSwolf")} {summary.avgSwolf} )} ) : ( - Loading summary... + {t("loadingSummary")} )} @@ -85,7 +88,7 @@ export function PostSwimScreen() { onPress={() => navigation.popToTop()} className="bg-slate-900 rounded-2xl py-4 items-center" > - Done + {tc("done")} ); diff --git a/src/features/active-workout/screens/PreSwimScreen.tsx b/src/features/active-workout/screens/PreSwimScreen.tsx index bd4cac6..883ccc1 100644 --- a/src/features/active-workout/screens/PreSwimScreen.tsx +++ b/src/features/active-workout/screens/PreSwimScreen.tsx @@ -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>(); const route = useRoute>(); const [workout, setWorkout] = useState(null); @@ -72,16 +75,16 @@ export function PreSwimScreen() { ⌚ - Workout sent to Watch! + {t("workoutSentToWatch")} - Open the SwimBuddy app on your Apple Watch to start tracking. + {t("openWatchApp")} navigation.popToTop()} className="bg-slate-900 rounded-2xl py-4 items-center w-full" > - Done + {tc("done")} ); @@ -92,16 +95,16 @@ export function PreSwimScreen() { ⚠️ - Failed to send + {t("failedToSend")} - Could not send workout to Watch. Starting without Watch instead. + {t("couldNotSend")} navigation.navigate("PostSwim", { workoutId: route.params.workoutId })} className="bg-sky-500 rounded-2xl py-4 items-center w-full" > - Start Without Watch + {t("startWithoutWatch")} ); @@ -111,16 +114,16 @@ export function PreSwimScreen() { - {workout.name || "Workout"} + {workout.name || tc("workout")} - {formatDistance(workout.totalDistance ?? 0)} · {workout.poolLength}m pool + {formatDistance(workout.totalDistance ?? 0)} · {tc("pool", { length: workout.poolLength })} - Sets + {tc("sets")} {workout.sets.map((set, i) => ( - {set.stroke} + {tc(`stroke.${set.stroke.toLowerCase()}`)} {set.repCount}×{set.distancePerRep}m @@ -143,9 +146,9 @@ export function PreSwimScreen() { - Rest + {t("rest")} - {workout.sets[0]?.restDuration ?? 0}s between sets + {t("betweenSets", { duration: workout.sets[0]?.restDuration ?? 0 })} @@ -158,12 +161,12 @@ export function PreSwimScreen() { /> {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")} @@ -178,15 +181,15 @@ export function PreSwimScreen() { ) : ( - {isReady ? "Send to Watch & Start" : "Start Without Watch"} + {isReady ? t("sendToWatchAndStart") : t("startWithoutWatch")} )} {isReady - ? "Workout will be synced to your Apple Watch" - : "Workout will be tracked on this device"} + ? t("syncToWatch") + : t("trackedOnDevice")} ); diff --git a/src/features/history/screens/HistoryScreen.tsx b/src/features/history/screens/HistoryScreen.tsx index 3ee029c..95b6c29 100644 --- a/src/features/history/screens/HistoryScreen.tsx +++ b/src/features/history/screens/HistoryScreen.tsx @@ -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>(); const [workouts, setWorkouts] = useState([]); 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={ - No completed workouts yet + {t("noCompletedYet")} } renderItem={({ item: [date, rows] }) => ( @@ -83,7 +86,7 @@ export function HistoryScreen() { {date} {rows.map((w) => ( - onDelete(w.id)}> + onDelete(w.id)} deleteLabel={tc("delete")}> navigation.navigate("SessionDetail", { sessionId: w.id })} className="p-4" @@ -91,12 +94,12 @@ export function HistoryScreen() { - {w.name || "Swim Session"} + {w.name || tc("swimSession")} {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) })}` : ""} diff --git a/src/features/history/screens/SessionDetailScreen.tsx b/src/features/history/screens/SessionDetailScreen.tsx index a66e44b..0d3068d 100644 --- a/src/features/history/screens/SessionDetailScreen.tsx +++ b/src/features/history/screens/SessionDetailScreen.tsx @@ -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(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 ( - Loading... + {tc("loading")} ); } @@ -81,7 +84,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail" - {workout.name || "Swim Session"} + {workout.name || tc("swimSession")} {new Date(workout.startedAt).toLocaleDateString()} @@ -100,13 +103,13 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail" {formatDistance(workout.totalDistance ?? 0)} - Distance + {tc("distance")} {formatTime(workout.totalDuration ?? 0)} - Duration + {tc("duration")} {workout.calories && ( @@ -120,13 +123,13 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail" {avgSwolf && ( - Avg SWOLF + {tc("avgSwolf")} {avgSwolf} )} - Sets + {tc("sets")} {workout.sets.map((set, i) => ( - {set.stroke} + {tc(`stroke.${set.stroke.toLowerCase()}`)} {set.repCount}×{set.distancePerRep}m @@ -149,7 +152,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail" {workout.laps.length > 0 && ( <> - Laps + {tc("laps")} {workout.laps.map((lap, i) => ( - Lap {i + 1} · {lap.distance}m + {t("lap", { number: i + 1 })} · {lap.distance}m {formatTime(lap.duration)} diff --git a/src/features/history/screens/StatsScreen.tsx b/src/features/history/screens/StatsScreen.tsx index 545457c..4b49fb1 100644 --- a/src/features/history/screens/StatsScreen.tsx +++ b/src/features/history/screens/StatsScreen.tsx @@ -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(null); const [total, setTotal] = useState(null); const [bests, setBests] = useState([]); @@ -45,79 +48,79 @@ export function StatsScreen() { return ( - Statistics + {t("statistics")} - This Week + {t("thisWeek")} {weekly ? ( {weekly.count} - Workouts + {tc("workouts")} {formatDistance(weekly.totalDistance)} - Distance + {tc("distance")} {formatTime(weekly.totalDuration)} - Duration + {tc("duration")} ) : ( - No data yet + {t("noDataYet")} )} - All Time + {t("allTime")} {total ? ( {total.totalWorkouts} - Workouts + {tc("workouts")} {formatDistance(total.totalDistance)} - Distance + {tc("distance")} {formatTime(total.totalDuration)} - Duration + {tc("duration")} ) : ( - No data yet + {t("noDataYet")} )} {bests.length > 0 && ( <> - Personal Bests + {t("personalBests")} {bests.map((best) => ( - {best.distance}m + {tc("meter", { count: best.distance })} {formatTime(best.bestTime)} {best.bestSwolf && ( - SWOLF: {best.bestSwolf} + {t("swolf", { value: best.bestSwolf })} )} diff --git a/src/features/settings/components/LanguagePicker.tsx b/src/features/settings/components/LanguagePicker.tsx new file mode 100644 index 0000000..780c379 --- /dev/null +++ b/src/features/settings/components/LanguagePicker.tsx @@ -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 ( + + {t("language")} + + setLanguage("system")} + className={`px-3 py-2 rounded-xl ${ + language === "system" ? "bg-sky-500" : "bg-slate-100" + }`} + > + + {t("systemLanguage")} + + + {LANGUAGES.map((lang) => ( + setLanguage(lang.code)} + className={`px-3 py-2 rounded-xl ${ + language === lang.code ? "bg-sky-500" : "bg-slate-100" + }`} + > + + {lang.label} + + + ))} + + + ); +} diff --git a/src/features/settings/screens/SettingsScreen.tsx b/src/features/settings/screens/SettingsScreen.tsx index 249ff93..7d4f46c 100644 --- a/src/features/settings/screens/SettingsScreen.tsx +++ b/src/features/settings/screens/SettingsScreen.tsx @@ -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 ( - Settings + {t("settings")} + + - Default Pool Length + {t("defaultPoolLength")} {[25, 50].map((len) => ( - SwimBuddy v1.0.0 + {t("version")} ); diff --git a/src/features/templates/screens/TemplatesScreen.tsx b/src/features/templates/screens/TemplatesScreen.tsx index a8ebdf9..966ba15 100644 --- a/src/features/templates/screens/TemplatesScreen.tsx +++ b/src/features/templates/screens/TemplatesScreen.tsx @@ -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>(); 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={ - No templates yet + {t("alert.deleteTemplate")} - Save a workout as a template to reuse it + {t("noTemplatesYet")} } @@ -38,7 +40,7 @@ export function TemplatesScreen() { {item.name} - {item.poolLength}m pool + {t("pool", { length: item.poolLength })} diff --git a/src/features/workout-builder/hooks/useWorkoutBuilder.ts b/src/features/workout-builder/hooks/useWorkoutBuilder.ts index 0427ac4..961acfd 100644 --- a/src/features/workout-builder/hooks/useWorkoutBuilder.ts +++ b/src/features/workout-builder/hooks/useWorkoutBuilder.ts @@ -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 { diff --git a/src/features/workout-builder/screens/BuilderScreen.tsx b/src/features/workout-builder/screens/BuilderScreen.tsx index d07b7e4..0aaf74f 100644 --- a/src/features/workout-builder/screens/BuilderScreen.tsx +++ b/src/features/workout-builder/screens/BuilderScreen.tsx @@ -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>(); 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 ( - Build Workout + {t("buildWorkout")} - {totalDistance}m total · {poolLength}m pool + {totalDistance}m total · {tc("pool", { length: poolLength })} - Workout Name + {t("workoutName")} - Pool Length + {t("poolLength")} {[25, 50].map((length) => ( ( - Set {index + 1} + {t("setNumber", { number: index + 1 })} {sets.length > 1 && ( removeSet(index)}> - Remove + {t("remove")} )} - Stroke + {t("stroke")} {STROKES.map((stroke) => ( @@ -105,7 +108,7 @@ export function BuilderScreen() { set.stroke === stroke ? "text-white" : "text-slate-600" }`} > - {stroke} + {tc(`stroke.${stroke.toLowerCase()}`)} ))} @@ -113,29 +116,29 @@ export function BuilderScreen() { - Distance (m) + {t("distanceM")} 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" /> - Reps + {t("reps")} 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" /> - Rest (s) + {t("restS")} 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" /> @@ -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" > - + Add Set + {t("addSet")} - {isSaving ? "Saving..." : "Start Swim"} + {isSaving ? tc("saving") : t("startSwim")} diff --git a/src/features/workout-builder/screens/WorkoutListScreen.tsx b/src/features/workout-builder/screens/WorkoutListScreen.tsx index b786874..8f572d5 100644 --- a/src/features/workout-builder/screens/WorkoutListScreen.tsx +++ b/src/features/workout-builder/screens/WorkoutListScreen.tsx @@ -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>(); const [workouts, setWorkouts] = useState([]); 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" > - + New Workout + {t("newWorkout")} @@ -73,12 +76,12 @@ export function WorkoutListScreen() { onRefresh={onRefresh} ListEmptyComponent={ - No workouts yet - Tap "New Workout" to get started + {t("noWorkoutsYet")} + {t("tapNewWorkout")} } renderItem={({ item }) => ( - onDelete(item.id)}> + onDelete(item.id)} deleteLabel={tc("delete")}> navigation.navigate("PreSwim", { workoutId: item.id })} className="p-4" @@ -86,7 +89,7 @@ export function WorkoutListScreen() { - {item.name || "Untitled Workout"} + {item.name || tc("untitledWorkout")} {formatDistance(item.totalDistance ?? 0)} @@ -98,7 +101,7 @@ export function WorkoutListScreen() { {item.finishedAt && ( - Done + {tc("done")} )} diff --git a/src/i18n/de/active-workout.json b/src/i18n/de/active-workout.json new file mode 100644 index 0000000..dd123dd --- /dev/null +++ b/src/i18n/de/active-workout.json @@ -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" + } +} diff --git a/src/i18n/de/common.json b/src/i18n/de/common.json new file mode 100644 index 0000000..9f677e4 --- /dev/null +++ b/src/i18n/de/common.json @@ -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?" + } +} diff --git a/src/i18n/de/history.json b/src/i18n/de/history.json new file mode 100644 index 0000000..b5cbbe3 --- /dev/null +++ b/src/i18n/de/history.json @@ -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}}" +} diff --git a/src/i18n/de/navigation.json b/src/i18n/de/navigation.json new file mode 100644 index 0000000..d80d411 --- /dev/null +++ b/src/i18n/de/navigation.json @@ -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" +} diff --git a/src/i18n/de/settings.json b/src/i18n/de/settings.json new file mode 100644 index 0000000..9c17b1a --- /dev/null +++ b/src/i18n/de/settings.json @@ -0,0 +1,7 @@ +{ + "settings": "Einstellungen", + "defaultPoolLength": "Standard-Beckenlänge", + "language": "Sprache", + "systemLanguage": "Systemsprache", + "version": "SwimBuddy v1.0.0" +} diff --git a/src/i18n/de/workout.json b/src/i18n/de/workout.json new file mode 100644 index 0000000..b8dcf8d --- /dev/null +++ b/src/i18n/de/workout.json @@ -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}}" + } +} diff --git a/src/i18n/dk/active-workout.json b/src/i18n/dk/active-workout.json new file mode 100644 index 0000000..c91647d --- /dev/null +++ b/src/i18n/dk/active-workout.json @@ -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" + } +} diff --git a/src/i18n/dk/common.json b/src/i18n/dk/common.json new file mode 100644 index 0000000..275fd7b --- /dev/null +++ b/src/i18n/dk/common.json @@ -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}}\"?" + } +} diff --git a/src/i18n/dk/history.json b/src/i18n/dk/history.json new file mode 100644 index 0000000..f50bda6 --- /dev/null +++ b/src/i18n/dk/history.json @@ -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}}" +} diff --git a/src/i18n/dk/navigation.json b/src/i18n/dk/navigation.json new file mode 100644 index 0000000..a6389ef --- /dev/null +++ b/src/i18n/dk/navigation.json @@ -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" +} diff --git a/src/i18n/dk/settings.json b/src/i18n/dk/settings.json new file mode 100644 index 0000000..850b903 --- /dev/null +++ b/src/i18n/dk/settings.json @@ -0,0 +1,7 @@ +{ + "settings": "Indstillinger", + "defaultPoolLength": "Standard poolængde", + "language": "Sprog", + "systemLanguage": "Systemssprog", + "version": "SwimBuddy v1.0.0" +} diff --git a/src/i18n/dk/workout.json b/src/i18n/dk/workout.json new file mode 100644 index 0000000..e550df3 --- /dev/null +++ b/src/i18n/dk/workout.json @@ -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}}" + } +} diff --git a/src/i18n/en/active-workout.json b/src/i18n/en/active-workout.json new file mode 100644 index 0000000..9484442 --- /dev/null +++ b/src/i18n/en/active-workout.json @@ -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" + } +} diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json new file mode 100644 index 0000000..e60fc5b --- /dev/null +++ b/src/i18n/en/common.json @@ -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}}\"?" + } +} diff --git a/src/i18n/en/history.json b/src/i18n/en/history.json new file mode 100644 index 0000000..e038115 --- /dev/null +++ b/src/i18n/en/history.json @@ -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}}" +} diff --git a/src/i18n/en/navigation.json b/src/i18n/en/navigation.json new file mode 100644 index 0000000..c1103c8 --- /dev/null +++ b/src/i18n/en/navigation.json @@ -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" +} diff --git a/src/i18n/en/settings.json b/src/i18n/en/settings.json new file mode 100644 index 0000000..dd0b369 --- /dev/null +++ b/src/i18n/en/settings.json @@ -0,0 +1,7 @@ +{ + "settings": "Settings", + "defaultPoolLength": "Default Pool Length", + "language": "Language", + "systemLanguage": "System Language", + "version": "SwimBuddy v1.0.0" +} diff --git a/src/i18n/en/workout.json b/src/i18n/en/workout.json new file mode 100644 index 0000000..3fbb61a --- /dev/null +++ b/src/i18n/en/workout.json @@ -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}}" + } +} diff --git a/src/i18n/es/active-workout.json b/src/i18n/es/active-workout.json new file mode 100644 index 0000000..241a253 --- /dev/null +++ b/src/i18n/es/active-workout.json @@ -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" + } +} diff --git a/src/i18n/es/common.json b/src/i18n/es/common.json new file mode 100644 index 0000000..c851122 --- /dev/null +++ b/src/i18n/es/common.json @@ -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}}\"?" + } +} diff --git a/src/i18n/es/history.json b/src/i18n/es/history.json new file mode 100644 index 0000000..dfce856 --- /dev/null +++ b/src/i18n/es/history.json @@ -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}}" +} diff --git a/src/i18n/es/navigation.json b/src/i18n/es/navigation.json new file mode 100644 index 0000000..9f04ed2 --- /dev/null +++ b/src/i18n/es/navigation.json @@ -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" +} diff --git a/src/i18n/es/settings.json b/src/i18n/es/settings.json new file mode 100644 index 0000000..8c17602 --- /dev/null +++ b/src/i18n/es/settings.json @@ -0,0 +1,7 @@ +{ + "settings": "Ajustes", + "defaultPoolLength": "Longitud de piscina predeterminada", + "language": "Idioma", + "systemLanguage": "Idioma del sistema", + "version": "SwimBuddy v1.0.0" +} diff --git a/src/i18n/es/workout.json b/src/i18n/es/workout.json new file mode 100644 index 0000000..9e666d3 --- /dev/null +++ b/src/i18n/es/workout.json @@ -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}}" + } +} diff --git a/src/i18n/fr/active-workout.json b/src/i18n/fr/active-workout.json new file mode 100644 index 0000000..32e511b --- /dev/null +++ b/src/i18n/fr/active-workout.json @@ -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" + } +} diff --git a/src/i18n/fr/common.json b/src/i18n/fr/common.json new file mode 100644 index 0000000..95a45af --- /dev/null +++ b/src/i18n/fr/common.json @@ -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}}\" ?" + } +} diff --git a/src/i18n/fr/history.json b/src/i18n/fr/history.json new file mode 100644 index 0000000..7c50691 --- /dev/null +++ b/src/i18n/fr/history.json @@ -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}}" +} diff --git a/src/i18n/fr/navigation.json b/src/i18n/fr/navigation.json new file mode 100644 index 0000000..ec5c1b6 --- /dev/null +++ b/src/i18n/fr/navigation.json @@ -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" +} diff --git a/src/i18n/fr/settings.json b/src/i18n/fr/settings.json new file mode 100644 index 0000000..f7218d1 --- /dev/null +++ b/src/i18n/fr/settings.json @@ -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" +} diff --git a/src/i18n/fr/workout.json b/src/i18n/fr/workout.json new file mode 100644 index 0000000..dc71ecd --- /dev/null +++ b/src/i18n/fr/workout.json @@ -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}}" + } +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..1f44213 --- /dev/null +++ b/src/i18n/index.ts @@ -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; diff --git a/src/i18n/it/active-workout.json b/src/i18n/it/active-workout.json new file mode 100644 index 0000000..e987703 --- /dev/null +++ b/src/i18n/it/active-workout.json @@ -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" + } +} diff --git a/src/i18n/it/common.json b/src/i18n/it/common.json new file mode 100644 index 0000000..acd14b4 --- /dev/null +++ b/src/i18n/it/common.json @@ -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}}\"?" + } +} diff --git a/src/i18n/it/history.json b/src/i18n/it/history.json new file mode 100644 index 0000000..6015e5c --- /dev/null +++ b/src/i18n/it/history.json @@ -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}}" +} diff --git a/src/i18n/it/navigation.json b/src/i18n/it/navigation.json new file mode 100644 index 0000000..1168a7d --- /dev/null +++ b/src/i18n/it/navigation.json @@ -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" +} diff --git a/src/i18n/it/settings.json b/src/i18n/it/settings.json new file mode 100644 index 0000000..7d9cd4b --- /dev/null +++ b/src/i18n/it/settings.json @@ -0,0 +1,7 @@ +{ + "settings": "Impostazioni", + "defaultPoolLength": "Lunghezza piscina predefinita", + "language": "Lingua", + "systemLanguage": "Lingua di sistema", + "version": "SwimBuddy v1.0.0" +} diff --git a/src/i18n/it/workout.json b/src/i18n/it/workout.json new file mode 100644 index 0000000..c141a07 --- /dev/null +++ b/src/i18n/it/workout.json @@ -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}}" + } +} diff --git a/src/i18n/nl/active-workout.json b/src/i18n/nl/active-workout.json new file mode 100644 index 0000000..ab6b1ad --- /dev/null +++ b/src/i18n/nl/active-workout.json @@ -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" + } +} diff --git a/src/i18n/nl/common.json b/src/i18n/nl/common.json new file mode 100644 index 0000000..1616dd7 --- /dev/null +++ b/src/i18n/nl/common.json @@ -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?" + } +} diff --git a/src/i18n/nl/history.json b/src/i18n/nl/history.json new file mode 100644 index 0000000..5b693a7 --- /dev/null +++ b/src/i18n/nl/history.json @@ -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}}" +} diff --git a/src/i18n/nl/navigation.json b/src/i18n/nl/navigation.json new file mode 100644 index 0000000..c5e4167 --- /dev/null +++ b/src/i18n/nl/navigation.json @@ -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" +} diff --git a/src/i18n/nl/settings.json b/src/i18n/nl/settings.json new file mode 100644 index 0000000..559286b --- /dev/null +++ b/src/i18n/nl/settings.json @@ -0,0 +1,7 @@ +{ + "settings": "Instellingen", + "defaultPoolLength": "Standaard badlengte", + "language": "Taal", + "systemLanguage": "Systeemtaal", + "version": "SwimBuddy v1.0.0" +} diff --git a/src/i18n/nl/workout.json b/src/i18n/nl/workout.json new file mode 100644 index 0000000..428cd21 --- /dev/null +++ b/src/i18n/nl/workout.json @@ -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}}" + } +} diff --git a/src/i18n/pl/active-workout.json b/src/i18n/pl/active-workout.json new file mode 100644 index 0000000..b5cdd55 --- /dev/null +++ b/src/i18n/pl/active-workout.json @@ -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" + } +} diff --git a/src/i18n/pl/common.json b/src/i18n/pl/common.json new file mode 100644 index 0000000..01a9cfb --- /dev/null +++ b/src/i18n/pl/common.json @@ -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}}\"?" + } +} diff --git a/src/i18n/pl/history.json b/src/i18n/pl/history.json new file mode 100644 index 0000000..a89a871 --- /dev/null +++ b/src/i18n/pl/history.json @@ -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}}" +} diff --git a/src/i18n/pl/navigation.json b/src/i18n/pl/navigation.json new file mode 100644 index 0000000..64b4b4d --- /dev/null +++ b/src/i18n/pl/navigation.json @@ -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" +} diff --git a/src/i18n/pl/settings.json b/src/i18n/pl/settings.json new file mode 100644 index 0000000..379466e --- /dev/null +++ b/src/i18n/pl/settings.json @@ -0,0 +1,7 @@ +{ + "settings": "Ustawienia", + "defaultPoolLength": "Domyślna długość basenu", + "language": "Język", + "systemLanguage": "Język systemowy", + "version": "SwimBuddy v1.0.0" +} diff --git a/src/i18n/pl/workout.json b/src/i18n/pl/workout.json new file mode 100644 index 0000000..e951435 --- /dev/null +++ b/src/i18n/pl/workout.json @@ -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}}" + } +} diff --git a/src/i18n/pt/active-workout.json b/src/i18n/pt/active-workout.json new file mode 100644 index 0000000..3971039 --- /dev/null +++ b/src/i18n/pt/active-workout.json @@ -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" + } +} diff --git a/src/i18n/pt/common.json b/src/i18n/pt/common.json new file mode 100644 index 0000000..8ca15ff --- /dev/null +++ b/src/i18n/pt/common.json @@ -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}}\"?" + } +} diff --git a/src/i18n/pt/history.json b/src/i18n/pt/history.json new file mode 100644 index 0000000..fbe2eea --- /dev/null +++ b/src/i18n/pt/history.json @@ -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}}" +} diff --git a/src/i18n/pt/navigation.json b/src/i18n/pt/navigation.json new file mode 100644 index 0000000..c8afac4 --- /dev/null +++ b/src/i18n/pt/navigation.json @@ -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" +} diff --git a/src/i18n/pt/settings.json b/src/i18n/pt/settings.json new file mode 100644 index 0000000..0412a86 --- /dev/null +++ b/src/i18n/pt/settings.json @@ -0,0 +1,7 @@ +{ + "settings": "Definições", + "defaultPoolLength": "Comprimento de piscina predefinido", + "language": "Idioma", + "systemLanguage": "Idioma do sistema", + "version": "SwimBuddy v1.0.0" +} diff --git a/src/i18n/pt/workout.json b/src/i18n/pt/workout.json new file mode 100644 index 0000000..d1b559b --- /dev/null +++ b/src/i18n/pt/workout.json @@ -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}}" + } +} diff --git a/src/navigation/HistoryNavigator.tsx b/src/navigation/HistoryNavigator.tsx index ead6ed5..9f7d651 100644 --- a/src/navigation/HistoryNavigator.tsx +++ b/src/navigation/HistoryNavigator.tsx @@ -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(); export function HistoryNavigator() { + const { t } = useTranslation("navigation"); + return ( ); diff --git a/src/navigation/MainTabNavigator.tsx b/src/navigation/MainTabNavigator.tsx index ac55b38..a53982b 100644 --- a/src/navigation/MainTabNavigator.tsx +++ b/src/navigation/MainTabNavigator.tsx @@ -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(); export function MainTabNavigator() { + const { t } = useTranslation("navigation"); + return ( ( ), @@ -33,6 +37,7 @@ export function MainTabNavigator() { component={HistoryNavigator} options={{ headerShown: false, + tabBarLabel: t("history"), tabBarIcon: ({ color, size }) => ( ), @@ -42,6 +47,7 @@ export function MainTabNavigator() { name="Settings" component={SettingsScreen} options={{ + tabBarLabel: t("settings"), tabBarIcon: ({ color, size }) => ( ), diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx index 9cf8890..b958878 100644 --- a/src/navigation/RootNavigator.tsx +++ b/src/navigation/RootNavigator.tsx @@ -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(); export function RootNavigator() { + const { t } = useTranslation("navigation"); + return ( null }} + options={{ title: t("swimComplete"), headerLeft: () => null }} /> ); diff --git a/src/navigation/WorkoutNavigator.tsx b/src/navigation/WorkoutNavigator.tsx index 56aa1e2..3195bb3 100644 --- a/src/navigation/WorkoutNavigator.tsx +++ b/src/navigation/WorkoutNavigator.tsx @@ -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(); export function WorkoutNavigator() { + const { t } = useTranslation("navigation"); + return ( ); diff --git a/src/providers/WatchProvider.tsx b/src/providers/WatchProvider.tsx index 1db420b..c601968 100644 --- a/src/providers/WatchProvider.tsx +++ b/src/providers/WatchProvider.tsx @@ -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({ 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(() => { + 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 ( - + {children} ); diff --git a/targets/watch/ContentView.swift b/targets/watch/ContentView.swift index 48243c0..00ea613 100644 --- a/targets/watch/ContentView.swift +++ b/targets/watch/ContentView.swift @@ -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 ?? "") diff --git a/targets/watch/Localizable.xcstrings b/targets/watch/Localizable.xcstrings new file mode 100644 index 0000000..7368bfc --- /dev/null +++ b/targets/watch/Localizable.xcstrings @@ -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" } } + } + } + } +} diff --git a/targets/watch/Views/ActiveWorkoutView.swift b/targets/watch/Views/ActiveWorkoutView.swift index 60e3b68..c4a38e9 100644 --- a/targets/watch/Views/ActiveWorkoutView.swift +++ b/targets/watch/Views/ActiveWorkoutView.swift @@ -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") diff --git a/targets/watch/Views/WorkoutDetailView.swift b/targets/watch/Views/WorkoutDetailView.swift index d76d5dd..4bb7158 100644 --- a/targets/watch/Views/WorkoutDetailView.swift +++ b/targets/watch/Views/WorkoutDetailView.swift @@ -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") diff --git a/tsconfig.json b/tsconfig.json index a521dc9..f599124 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"]