feat(i18n): add internationalization with i18next and expo-localization
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "@navigation/types";
|
||||
@@ -9,6 +10,8 @@ import { formatTime } from "@utils/formatTime";
|
||||
import { formatDistance } from "@utils/formatDistance";
|
||||
|
||||
export function PostSwimScreen() {
|
||||
const { t } = useTranslation("active-workout");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const route = useRoute<RouteProp<RootStackParamList, "PostSwim">>();
|
||||
const [summary, setSummary] = useState<{
|
||||
@@ -46,38 +49,38 @@ export function PostSwimScreen() {
|
||||
<View className="flex-1 bg-white p-4">
|
||||
<View className="items-center mb-8">
|
||||
<Text className="text-3xl mb-2">🏊</Text>
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-1">Swim Complete!</Text>
|
||||
<Text className="text-slate-500">Great job getting in the water</Text>
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-1">{t("swimComplete")}</Text>
|
||||
<Text className="text-slate-500">{t("greatJob")}</Text>
|
||||
</View>
|
||||
|
||||
{summary ? (
|
||||
<View className="gap-3 mb-8">
|
||||
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
|
||||
<Text className="text-slate-600">Distance</Text>
|
||||
<Text className="text-slate-600">{tc("distance")}</Text>
|
||||
<Text className="text-xl font-bold text-sky-600">
|
||||
{formatDistance(summary.distance)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
|
||||
<Text className="text-slate-600">Duration</Text>
|
||||
<Text className="text-slate-600">{tc("duration")}</Text>
|
||||
<Text className="text-xl font-bold text-sky-600">
|
||||
{formatTime(summary.duration)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
|
||||
<Text className="text-slate-600">Laps</Text>
|
||||
<Text className="text-slate-600">{tc("laps")}</Text>
|
||||
<Text className="text-xl font-bold text-sky-600">{summary.laps}</Text>
|
||||
</View>
|
||||
{summary.avgSwolf && (
|
||||
<View className="bg-sky-50 rounded-2xl p-5 flex-row justify-between items-center">
|
||||
<Text className="text-slate-600">Avg SWOLF</Text>
|
||||
<Text className="text-slate-600">{tc("avgSwolf")}</Text>
|
||||
<Text className="text-xl font-bold text-sky-600">{summary.avgSwolf}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<View className="py-8 items-center">
|
||||
<Text className="text-slate-400">Loading summary...</Text>
|
||||
<Text className="text-slate-400">{t("loadingSummary")}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -85,7 +88,7 @@ export function PostSwimScreen() {
|
||||
onPress={() => navigation.popToTop()}
|
||||
className="bg-slate-900 rounded-2xl py-4 items-center"
|
||||
>
|
||||
<Text className="text-white font-bold text-lg">Done</Text>
|
||||
<Text className="text-white font-bold text-lg">{tc("done")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, Pressable, ActivityIndicator } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigation, useRoute } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "@navigation/types";
|
||||
@@ -20,6 +21,8 @@ type WorkoutWithSets = {
|
||||
};
|
||||
|
||||
export function PreSwimScreen() {
|
||||
const { t } = useTranslation("active-workout");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const route = useRoute<RouteProp<RootStackParamList, "PreSwim">>();
|
||||
const [workout, setWorkout] = useState<WorkoutWithSets | null>(null);
|
||||
@@ -72,16 +75,16 @@ export function PreSwimScreen() {
|
||||
<View className="flex-1 bg-white p-4 items-center justify-center">
|
||||
<View className="bg-green-50 rounded-2xl p-6 items-center mb-6 w-full">
|
||||
<Text className="text-4xl mb-3">⌚</Text>
|
||||
<Text className="text-xl font-bold text-green-800 mb-2">Workout sent to Watch!</Text>
|
||||
<Text className="text-xl font-bold text-green-800 mb-2">{t("workoutSentToWatch")}</Text>
|
||||
<Text className="text-green-600 text-center">
|
||||
Open the SwimBuddy app on your Apple Watch to start tracking.
|
||||
{t("openWatchApp")}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => navigation.popToTop()}
|
||||
className="bg-slate-900 rounded-2xl py-4 items-center w-full"
|
||||
>
|
||||
<Text className="text-white font-bold text-lg">Done</Text>
|
||||
<Text className="text-white font-bold text-lg">{tc("done")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -92,16 +95,16 @@ export function PreSwimScreen() {
|
||||
<View className="flex-1 bg-white p-4 items-center justify-center">
|
||||
<View className="bg-red-50 rounded-2xl p-6 items-center mb-6 w-full">
|
||||
<Text className="text-4xl mb-3">⚠️</Text>
|
||||
<Text className="text-xl font-bold text-red-800 mb-2">Failed to send</Text>
|
||||
<Text className="text-xl font-bold text-red-800 mb-2">{t("failedToSend")}</Text>
|
||||
<Text className="text-red-600 text-center">
|
||||
Could not send workout to Watch. Starting without Watch instead.
|
||||
{t("couldNotSend")}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate("PostSwim", { workoutId: route.params.workoutId })}
|
||||
className="bg-sky-500 rounded-2xl py-4 items-center w-full"
|
||||
>
|
||||
<Text className="text-white font-bold text-lg">Start Without Watch</Text>
|
||||
<Text className="text-white font-bold text-lg">{t("startWithoutWatch")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
@@ -111,16 +114,16 @@ export function PreSwimScreen() {
|
||||
<View className="flex-1 bg-white p-4">
|
||||
<View className="items-center mb-6">
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-1">
|
||||
{workout.name || "Workout"}
|
||||
{workout.name || tc("workout")}
|
||||
</Text>
|
||||
<Text className="text-slate-500">
|
||||
{formatDistance(workout.totalDistance ?? 0)} · {workout.poolLength}m pool
|
||||
{formatDistance(workout.totalDistance ?? 0)} · {tc("pool", { length: workout.poolLength })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="bg-sky-50 rounded-2xl p-4 mb-4 border border-sky-100">
|
||||
<Text className="text-xs font-semibold text-sky-700 mb-3 uppercase tracking-wide">
|
||||
Sets
|
||||
{tc("sets")}
|
||||
</Text>
|
||||
{workout.sets.map((set, i) => (
|
||||
<View
|
||||
@@ -132,7 +135,7 @@ export function PreSwimScreen() {
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: STROKE_COLORS[set.stroke] ?? "#94a3b8" }}
|
||||
/>
|
||||
<Text className="text-slate-700 font-medium">{set.stroke}</Text>
|
||||
<Text className="text-slate-700 font-medium">{tc(`stroke.${set.stroke.toLowerCase()}`)}</Text>
|
||||
</View>
|
||||
<Text className="text-slate-600">
|
||||
{set.repCount}×{set.distancePerRep}m
|
||||
@@ -143,9 +146,9 @@ export function PreSwimScreen() {
|
||||
|
||||
<View className="bg-slate-50 rounded-2xl p-4 mb-6">
|
||||
<View className="flex-row justify-between mb-2">
|
||||
<Text className="text-slate-500">Rest</Text>
|
||||
<Text className="text-slate-500">{t("rest")}</Text>
|
||||
<Text className="text-slate-900 font-medium">
|
||||
{workout.sets[0]?.restDuration ?? 0}s between sets
|
||||
{t("betweenSets", { duration: workout.sets[0]?.restDuration ?? 0 })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -158,12 +161,12 @@ export function PreSwimScreen() {
|
||||
/>
|
||||
<Text className="text-sm text-slate-600">
|
||||
{isReady
|
||||
? "Apple Watch ready"
|
||||
? t("watchStatus.ready")
|
||||
: isPaired
|
||||
? isInstalled
|
||||
? "Watch paired but not in range"
|
||||
: "Watch paired, app not installed"
|
||||
: "No Watch paired"}
|
||||
? t("watchStatus.pairedNotInRange")
|
||||
: t("watchStatus.pairedNotInstalled")
|
||||
: t("watchStatus.noWatch")}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -178,15 +181,15 @@ export function PreSwimScreen() {
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text className="text-white font-bold text-lg">
|
||||
{isReady ? "Send to Watch & Start" : "Start Without Watch"}
|
||||
{isReady ? t("sendToWatchAndStart") : t("startWithoutWatch")}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<Text className="text-slate-400 text-xs text-center mt-3">
|
||||
{isReady
|
||||
? "Workout will be synced to your Apple Watch"
|
||||
: "Workout will be tracked on this device"}
|
||||
? t("syncToWatch")
|
||||
: t("trackedOnDevice")}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { View, Text, Pressable, FlatList, Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
@@ -20,6 +21,8 @@ type WorkoutRow = {
|
||||
};
|
||||
|
||||
export function HistoryScreen() {
|
||||
const { t } = useTranslation("history");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<HistoryStackParamList>>();
|
||||
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -42,10 +45,10 @@ export function HistoryScreen() {
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
Alert.alert("Delete Workout", "Are you sure?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
Alert.alert(tc("alert.deleteWorkout"), tc("alert.areYouSure"), [
|
||||
{ text: tc("cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
text: tc("delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteWorkout(id);
|
||||
@@ -74,7 +77,7 @@ export function HistoryScreen() {
|
||||
onRefresh={onRefresh}
|
||||
ListEmptyComponent={
|
||||
<View className="py-12 items-center">
|
||||
<Text className="text-slate-400 text-lg">No completed workouts yet</Text>
|
||||
<Text className="text-slate-400 text-lg">{t("noCompletedYet")}</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item: [date, rows] }) => (
|
||||
@@ -83,7 +86,7 @@ export function HistoryScreen() {
|
||||
{date}
|
||||
</Text>
|
||||
{rows.map((w) => (
|
||||
<SwipeableRow key={w.id} onDelete={() => onDelete(w.id)}>
|
||||
<SwipeableRow key={w.id} onDelete={() => onDelete(w.id)} deleteLabel={tc("delete")}>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate("SessionDetail", { sessionId: w.id })}
|
||||
className="p-4"
|
||||
@@ -91,12 +94,12 @@ export function HistoryScreen() {
|
||||
<View className="flex-row justify-between items-center">
|
||||
<View>
|
||||
<Text className="font-semibold text-slate-900">
|
||||
{w.name || "Swim Session"}
|
||||
{w.name || tc("swimSession")}
|
||||
</Text>
|
||||
<Text className="text-slate-500 text-sm">
|
||||
{formatDistance(w.totalDistance ?? 0)}
|
||||
{w.totalDuration ? ` · ${formatTime(w.totalDuration)}` : ""}
|
||||
{w.calories ? ` · ${Math.round(w.calories)} kcal` : ""}
|
||||
{w.calories ? ` · ${tc("kcal", { count: Math.round(w.calories) })}` : ""}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="text-slate-400 text-xs">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView, Pressable, Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import type { HistoryStackProps } from "@navigation/types";
|
||||
import { getWorkoutWithSets, deleteWorkout } from "@db/operations";
|
||||
@@ -35,6 +36,8 @@ type WorkoutDetail = {
|
||||
};
|
||||
|
||||
export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail">) {
|
||||
const { t } = useTranslation("history");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const navigation = useNavigation();
|
||||
const [workout, setWorkout] = useState<WorkoutDetail | null>(null);
|
||||
|
||||
@@ -48,10 +51,10 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert("Delete Workout", "Are you sure?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
Alert.alert(tc("alert.deleteWorkout"), tc("alert.areYouSure"), [
|
||||
{ text: tc("cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
text: tc("delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteWorkout(route.params.sessionId);
|
||||
@@ -64,7 +67,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
if (!workout) {
|
||||
return (
|
||||
<View className="flex-1 bg-white items-center justify-center">
|
||||
<Text className="text-slate-400">Loading...</Text>
|
||||
<Text className="text-slate-400">{tc("loading")}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +84,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
<View className="flex-row justify-between items-start mb-6">
|
||||
<View className="flex-1">
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-1">
|
||||
{workout.name || "Swim Session"}
|
||||
{workout.name || tc("swimSession")}
|
||||
</Text>
|
||||
<Text className="text-slate-500">
|
||||
{new Date(workout.startedAt).toLocaleDateString()}
|
||||
@@ -100,13 +103,13 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
<Text className="text-2xl font-bold text-sky-600">
|
||||
{formatDistance(workout.totalDistance ?? 0)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500 mt-1">Distance</Text>
|
||||
<Text className="text-xs text-slate-500 mt-1">{tc("distance")}</Text>
|
||||
</View>
|
||||
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
|
||||
<Text className="text-2xl font-bold text-sky-600">
|
||||
{formatTime(workout.totalDuration ?? 0)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500 mt-1">Duration</Text>
|
||||
<Text className="text-xs text-slate-500 mt-1">{tc("duration")}</Text>
|
||||
</View>
|
||||
{workout.calories && (
|
||||
<View className="flex-1 bg-sky-50 rounded-2xl p-4 items-center">
|
||||
@@ -120,13 +123,13 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
|
||||
{avgSwolf && (
|
||||
<View className="bg-slate-50 rounded-2xl p-4 mb-4">
|
||||
<Text className="text-sm font-semibold text-slate-700">Avg SWOLF</Text>
|
||||
<Text className="text-sm font-semibold text-slate-700">{tc("avgSwolf")}</Text>
|
||||
<Text className="text-xl font-bold text-slate-900">{avgSwolf}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Sets
|
||||
{tc("sets")}
|
||||
</Text>
|
||||
{workout.sets.map((set, i) => (
|
||||
<View
|
||||
@@ -138,7 +141,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: STROKE_COLORS[set.stroke] ?? "#94a3b8" }}
|
||||
/>
|
||||
<Text className="text-slate-700 font-medium">{set.stroke}</Text>
|
||||
<Text className="text-slate-700 font-medium">{tc(`stroke.${set.stroke.toLowerCase()}`)}</Text>
|
||||
</View>
|
||||
<Text className="text-slate-600">
|
||||
{set.repCount}×{set.distancePerRep}m
|
||||
@@ -149,7 +152,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
{workout.laps.length > 0 && (
|
||||
<>
|
||||
<Text className="text-sm font-semibold text-slate-500 uppercase tracking-wide mt-6 mb-2">
|
||||
Laps
|
||||
{tc("laps")}
|
||||
</Text>
|
||||
{workout.laps.map((lap, i) => (
|
||||
<View
|
||||
@@ -157,7 +160,7 @@ export function SessionDetailScreen({ route }: HistoryStackProps<"SessionDetail"
|
||||
className="flex-row justify-between py-2 border-b border-slate-100"
|
||||
>
|
||||
<Text className="text-slate-600">
|
||||
Lap {i + 1} · {lap.distance}m
|
||||
{t("lap", { number: i + 1 })} · {lap.distance}m
|
||||
</Text>
|
||||
<Text className="text-slate-600 font-medium">
|
||||
{formatTime(lap.duration)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { View, Text, ScrollView } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getWeeklyStats, getTotalStats, getPersonalBests } from "@db/operations";
|
||||
import { formatDistance } from "@utils/formatDistance";
|
||||
import { formatTime } from "@utils/formatTime";
|
||||
@@ -24,6 +25,8 @@ type PersonalBest = {
|
||||
};
|
||||
|
||||
export function StatsScreen() {
|
||||
const { t } = useTranslation("history");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const [weekly, setWeekly] = useState<WeeklyStats | null>(null);
|
||||
const [total, setTotal] = useState<TotalStats | null>(null);
|
||||
const [bests, setBests] = useState<PersonalBest[]>([]);
|
||||
@@ -45,79 +48,79 @@ export function StatsScreen() {
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white p-4">
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-6">Statistics</Text>
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-6">{t("statistics")}</Text>
|
||||
|
||||
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
This Week
|
||||
{t("thisWeek")}
|
||||
</Text>
|
||||
<View className="bg-sky-50 rounded-2xl p-4 mb-6">
|
||||
{weekly ? (
|
||||
<View className="flex-row justify-between">
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-sky-600">{weekly.count}</Text>
|
||||
<Text className="text-xs text-slate-500">Workouts</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("workouts")}</Text>
|
||||
</View>
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-sky-600">
|
||||
{formatDistance(weekly.totalDistance)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500">Distance</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("distance")}</Text>
|
||||
</View>
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-sky-600">
|
||||
{formatTime(weekly.totalDuration)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500">Duration</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("duration")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-slate-400 text-center py-4">No data yet</Text>
|
||||
<Text className="text-slate-400 text-center py-4">{t("noDataYet")}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
All Time
|
||||
{t("allTime")}
|
||||
</Text>
|
||||
<View className="bg-slate-50 rounded-2xl p-4 mb-6">
|
||||
{total ? (
|
||||
<View className="flex-row justify-between">
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-slate-700">{total.totalWorkouts}</Text>
|
||||
<Text className="text-xs text-slate-500">Workouts</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("workouts")}</Text>
|
||||
</View>
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-slate-700">
|
||||
{formatDistance(total.totalDistance)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500">Distance</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("distance")}</Text>
|
||||
</View>
|
||||
<View className="items-center flex-1">
|
||||
<Text className="text-2xl font-bold text-slate-700">
|
||||
{formatTime(total.totalDuration)}
|
||||
</Text>
|
||||
<Text className="text-xs text-slate-500">Duration</Text>
|
||||
<Text className="text-xs text-slate-500">{tc("duration")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-slate-400 text-center py-4">No data yet</Text>
|
||||
<Text className="text-slate-400 text-center py-4">{t("noDataYet")}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{bests.length > 0 && (
|
||||
<>
|
||||
<Text className="text-xs font-semibold text-slate-500 uppercase tracking-wide mb-2">
|
||||
Personal Bests
|
||||
{t("personalBests")}
|
||||
</Text>
|
||||
{bests.map((best) => (
|
||||
<View
|
||||
key={best.distance}
|
||||
className="bg-green-50 rounded-2xl p-4 mb-2 flex-row justify-between items-center"
|
||||
>
|
||||
<Text className="text-green-700 font-medium">{best.distance}m</Text>
|
||||
<Text className="text-green-700 font-medium">{tc("meter", { count: best.distance })}</Text>
|
||||
<View className="items-end">
|
||||
<Text className="text-green-800 font-bold">{formatTime(best.bestTime)}</Text>
|
||||
{best.bestSwolf && (
|
||||
<Text className="text-green-600 text-xs">SWOLF: {best.bestSwolf}</Text>
|
||||
<Text className="text-green-600 text-xs">{t("swolf", { value: best.bestSwolf })}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSettings } from "../../../providers/WatchProvider";
|
||||
import { LANGUAGES } from "@i18n/index";
|
||||
|
||||
export function LanguagePicker() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { language, setLanguage } = useSettings();
|
||||
|
||||
return (
|
||||
<View className="mb-6">
|
||||
<Text className="text-sm font-medium text-slate-500 mb-2">{t("language")}</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<Pressable
|
||||
onPress={() => setLanguage("system")}
|
||||
className={`px-3 py-2 rounded-xl ${
|
||||
language === "system" ? "bg-sky-500" : "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm font-medium ${
|
||||
language === "system" ? "text-white" : "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{t("systemLanguage")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<Pressable
|
||||
key={lang.code}
|
||||
onPress={() => setLanguage(lang.code)}
|
||||
className={`px-3 py-2 rounded-xl ${
|
||||
language === lang.code ? "bg-sky-500" : "bg-slate-100"
|
||||
}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-sm font-medium ${
|
||||
language === lang.code ? "text-white" : "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{lang.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { View, Text, Pressable } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSettings } from "../../../providers/WatchProvider";
|
||||
import { LanguagePicker } from "../components/LanguagePicker";
|
||||
|
||||
export function SettingsScreen() {
|
||||
const { t } = useTranslation("settings");
|
||||
const { poolLength, setPoolLength } = useSettings();
|
||||
|
||||
return (
|
||||
<View className="flex-1 bg-white p-4">
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-6">Settings</Text>
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-6">{t("settings")}</Text>
|
||||
|
||||
<LanguagePicker />
|
||||
|
||||
<View className="mb-6">
|
||||
<Text className="text-sm font-medium text-slate-500 mb-2">Default Pool Length</Text>
|
||||
<Text className="text-sm font-medium text-slate-500 mb-2">{t("defaultPoolLength")}</Text>
|
||||
<View className="flex-row gap-3">
|
||||
{[25, 50].map((len) => (
|
||||
<Pressable
|
||||
@@ -32,7 +37,7 @@ export function SettingsScreen() {
|
||||
</View>
|
||||
|
||||
<View className="border-t border-slate-200 pt-4 mt-2">
|
||||
<Text className="text-sm text-slate-400">SwimBuddy v1.0.0</Text>
|
||||
<Text className="text-sm text-slate-400">{t("version")}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text, Pressable, FlatList, Alert } from "react-native";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
@@ -5,13 +6,14 @@ import type { WorkoutStackParamList } from "@navigation/types";
|
||||
import { useTemplates } from "../hooks/useTemplates";
|
||||
|
||||
export function TemplatesScreen() {
|
||||
const { t } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
|
||||
const { templates, remove, toggleFavorite } = useTemplates();
|
||||
|
||||
const handleDelete = (id: string, name: string) => {
|
||||
Alert.alert("Delete Template", `Delete "${name}"?`, [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{ text: "Delete", style: "destructive", onPress: () => remove(id) },
|
||||
Alert.alert(t("alert.deleteTemplate"), t("alert.deleteConfirm", { name }), [
|
||||
{ text: t("cancel"), style: "cancel" },
|
||||
{ text: t("delete"), style: "destructive", onPress: () => remove(id) },
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -23,9 +25,9 @@ export function TemplatesScreen() {
|
||||
contentContainerClassName="p-4"
|
||||
ListEmptyComponent={
|
||||
<View className="py-12 items-center">
|
||||
<Text className="text-slate-400 text-lg">No templates yet</Text>
|
||||
<Text className="text-slate-400 text-lg">{t("alert.deleteTemplate")}</Text>
|
||||
<Text className="text-slate-400 text-sm mt-1">
|
||||
Save a workout as a template to reuse it
|
||||
{t("noTemplatesYet")}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
@@ -38,7 +40,7 @@ export function TemplatesScreen() {
|
||||
<View className="flex-1">
|
||||
<Text className="font-semibold text-slate-900 text-base">{item.name}</Text>
|
||||
<Text className="text-slate-500 text-sm mt-1">
|
||||
{item.poolLength}m pool
|
||||
{t("pool", { length: item.poolLength })}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex-row gap-2">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import type { WorkoutSet, StrokeType, RestType } from "@types/workout";
|
||||
import { saveWorkout, saveTemplate } from "@db/operations";
|
||||
import i18n from "@i18n/index";
|
||||
|
||||
const DEFAULT_SET: WorkoutSet = {
|
||||
stroke: "Freestyle",
|
||||
@@ -33,7 +34,7 @@ export function useWorkoutBuilder(initialSets?: WorkoutSet[]) {
|
||||
async (poolLength: number, templateId?: string) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const name = workoutName.trim() || `Workout ${new Date().toLocaleDateString()}`;
|
||||
const name = workoutName.trim() || i18n.t("workout:untitledWorkout");
|
||||
const id = await saveWorkout(name, poolLength, sets, templateId);
|
||||
return id;
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, TextInput, Pressable, ScrollView, Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
import type { RootStackParamList } from "@navigation/types";
|
||||
@@ -10,6 +11,8 @@ import type { StrokeType } from "@types/workout";
|
||||
const STROKES: StrokeType[] = ["Freestyle", "Backstroke", "Breaststroke", "Butterfly", "Mixed"];
|
||||
|
||||
export function BuilderScreen() {
|
||||
const { t } = useTranslation("workout");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const { poolLength: defaultPoolLength } = useSettings();
|
||||
const [poolLength, setPoolLength] = useState(defaultPoolLength);
|
||||
@@ -31,31 +34,31 @@ export function BuilderScreen() {
|
||||
navigation.navigate("PreSwim", { workoutId });
|
||||
} catch (e) {
|
||||
console.error("Failed to save workout:", e);
|
||||
Alert.alert("Error", `Failed to save workout: ${e}`);
|
||||
Alert.alert(tc("validation.error"), tc("validation.failedToSave", { error: String(e) }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView className="flex-1 bg-white">
|
||||
<View className="p-4">
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-1">Build Workout</Text>
|
||||
<Text className="text-2xl font-bold text-slate-900 mb-1">{t("buildWorkout")}</Text>
|
||||
<Text className="text-slate-500 mb-4">
|
||||
{totalDistance}m total · {poolLength}m pool
|
||||
{totalDistance}m total · {tc("pool", { length: poolLength })}
|
||||
</Text>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm font-medium text-slate-500 mb-1">Workout Name</Text>
|
||||
<Text className="text-sm font-medium text-slate-500 mb-1">{t("workoutName")}</Text>
|
||||
<TextInput
|
||||
value={workoutName}
|
||||
onChangeText={setWorkoutName}
|
||||
placeholder="Morning Swim"
|
||||
placeholder={t("placeholder.workoutName")}
|
||||
placeholderTextColor="#94a3b8"
|
||||
className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-slate-900"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Text className="text-sm font-medium text-slate-500 mb-1">Pool Length</Text>
|
||||
<Text className="text-sm font-medium text-slate-500 mb-1">{t("poolLength")}</Text>
|
||||
<View className="flex-row gap-2">
|
||||
{[25, 50].map((length) => (
|
||||
<Pressable
|
||||
@@ -80,16 +83,16 @@ export function BuilderScreen() {
|
||||
{sets.map((set, index) => (
|
||||
<View key={index} className="bg-slate-50 rounded-2xl p-4 mb-3 border border-slate-200">
|
||||
<View className="flex-row justify-between items-center mb-3">
|
||||
<Text className="font-semibold text-slate-700">Set {index + 1}</Text>
|
||||
<Text className="font-semibold text-slate-700">{t("setNumber", { number: index + 1 })}</Text>
|
||||
{sets.length > 1 && (
|
||||
<Pressable onPress={() => removeSet(index)}>
|
||||
<Text className="text-red-500 text-sm font-medium">Remove</Text>
|
||||
<Text className="text-red-500 text-sm font-medium">{t("remove")}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1.5 uppercase tracking-wide">
|
||||
Stroke
|
||||
{t("stroke")}
|
||||
</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} className="mb-3">
|
||||
{STROKES.map((stroke) => (
|
||||
@@ -105,7 +108,7 @@ export function BuilderScreen() {
|
||||
set.stroke === stroke ? "text-white" : "text-slate-600"
|
||||
}`}
|
||||
>
|
||||
{stroke}
|
||||
{tc(`stroke.${stroke.toLowerCase()}`)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
@@ -113,29 +116,29 @@ export function BuilderScreen() {
|
||||
|
||||
<View className="flex-row gap-2">
|
||||
<View className="flex-1">
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">Distance (m)</Text>
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">{t("distanceM")}</Text>
|
||||
<TextInput
|
||||
keyboardType="numeric"
|
||||
value={String(set.distancePerRep)}
|
||||
onChangeText={(t) => updateSet(index, { distancePerRep: Number(t) || 0 })}
|
||||
onChangeText={(val) => updateSet(index, { distancePerRep: Number(val) || 0 })}
|
||||
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">Reps</Text>
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">{t("reps")}</Text>
|
||||
<TextInput
|
||||
keyboardType="numeric"
|
||||
value={String(set.repCount)}
|
||||
onChangeText={(t) => updateSet(index, { repCount: Number(t) || 1 })}
|
||||
onChangeText={(val) => updateSet(index, { repCount: Number(val) || 1 })}
|
||||
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1">
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">Rest (s)</Text>
|
||||
<Text className="text-xs font-medium text-slate-500 mb-1">{t("restS")}</Text>
|
||||
<TextInput
|
||||
keyboardType="numeric"
|
||||
value={String(set.restDuration)}
|
||||
onChangeText={(t) => updateSet(index, { restDuration: Number(t) || 0 })}
|
||||
onChangeText={(val) => updateSet(index, { restDuration: Number(val) || 0 })}
|
||||
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-slate-900"
|
||||
/>
|
||||
</View>
|
||||
@@ -147,7 +150,7 @@ export function BuilderScreen() {
|
||||
onPress={addSet}
|
||||
className="bg-slate-100 rounded-2xl py-3 items-center mb-6 border border-slate-200"
|
||||
>
|
||||
<Text className="text-sky-600 font-semibold">+ Add Set</Text>
|
||||
<Text className="text-sky-600 font-semibold">{t("addSet")}</Text>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
@@ -156,7 +159,7 @@ export function BuilderScreen() {
|
||||
className={`rounded-2xl py-4 items-center mb-8 ${isSaving ? "bg-sky-300" : "bg-sky-500"}`}
|
||||
>
|
||||
<Text className="text-white font-bold text-lg">
|
||||
{isSaving ? "Saving..." : "Start Swim"}
|
||||
{isSaving ? tc("saving") : t("startSwim")}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { View, Text, Pressable, FlatList, Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigation } from "@react-navigation/native";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
|
||||
@@ -19,6 +20,8 @@ type WorkoutRow = {
|
||||
};
|
||||
|
||||
export function WorkoutListScreen() {
|
||||
const { t } = useTranslation("workout");
|
||||
const { t: tc } = useTranslation("common");
|
||||
const navigation = useNavigation<NativeStackNavigationProp<WorkoutStackParamList>>();
|
||||
const [workouts, setWorkouts] = useState<WorkoutRow[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -41,10 +44,10 @@ export function WorkoutListScreen() {
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
Alert.alert("Delete Workout", "Are you sure?", [
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
Alert.alert(tc("alert.deleteWorkout"), tc("alert.areYouSure"), [
|
||||
{ text: tc("cancel"), style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
text: tc("delete"),
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteWorkout(id);
|
||||
@@ -61,7 +64,7 @@ export function WorkoutListScreen() {
|
||||
onPress={() => navigation.navigate("Builder")}
|
||||
className="bg-sky-500 rounded-2xl p-4 items-center mb-4"
|
||||
>
|
||||
<Text className="text-white font-bold text-lg">+ New Workout</Text>
|
||||
<Text className="text-white font-bold text-lg">{t("newWorkout")}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -73,12 +76,12 @@ export function WorkoutListScreen() {
|
||||
onRefresh={onRefresh}
|
||||
ListEmptyComponent={
|
||||
<View className="py-12 items-center">
|
||||
<Text className="text-slate-400 text-lg">No workouts yet</Text>
|
||||
<Text className="text-slate-400 text-sm mt-1">Tap "New Workout" to get started</Text>
|
||||
<Text className="text-slate-400 text-lg">{t("noWorkoutsYet")}</Text>
|
||||
<Text className="text-slate-400 text-sm mt-1">{t("tapNewWorkout")}</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<SwipeableRow onDelete={() => onDelete(item.id)}>
|
||||
<SwipeableRow onDelete={() => onDelete(item.id)} deleteLabel={tc("delete")}>
|
||||
<Pressable
|
||||
onPress={() => navigation.navigate("PreSwim", { workoutId: item.id })}
|
||||
className="p-4"
|
||||
@@ -86,7 +89,7 @@ export function WorkoutListScreen() {
|
||||
<View className="flex-row justify-between items-start">
|
||||
<View className="flex-1">
|
||||
<Text className="font-semibold text-slate-900 text-base">
|
||||
{item.name || "Untitled Workout"}
|
||||
{item.name || tc("untitledWorkout")}
|
||||
</Text>
|
||||
<Text className="text-slate-500 text-sm mt-1">
|
||||
{formatDistance(item.totalDistance ?? 0)}
|
||||
@@ -98,7 +101,7 @@ export function WorkoutListScreen() {
|
||||
</View>
|
||||
{item.finishedAt && (
|
||||
<View className="bg-green-100 rounded-full px-2 py-0.5">
|
||||
<Text className="text-green-700 text-xs font-medium">Done</Text>
|
||||
<Text className="text-green-700 text-xs font-medium">{tc("done")}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user