feat(i18n): add internationalization with i18next and expo-localization
This commit is contained in:
@@ -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