From 462f295caa71bbb94f781c3960a40082c7872006 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 6 Sep 2026 17:31:41 +0200 Subject: [PATCH] feat(mobile): add German/English i18n across the whole app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - i18next + react-i18next + expo-localization: device-locale detection, an MMKV-persisted DE/EN/Automatisch override (mirrors web's cookie-only locale, no backend field needed), and a language switcher on the Pets tab next to the theme toggle - every screen and shared component converted from hardcoded German strings to translation keys; de.json/en.json kept in exact key-parity (218 keys each, verified programmatically) - backend: src/trpc/init.ts now accepts an x-locale header as a fallback locale source (cookie still wins, web unaffected) since mobile has no cookie jar to set NEXT_LOCALE in; mobile's tRPC client sends the current language on every request - fixes a latent bug: species/breed pickers were hardcoded to locale "de" while every other pet query resolved names via ctx.locale's default (English, since mobile never sent a locale cookie) — both now use the same current-language value - expo-localization is a new native module (config plugin) and needs a native rebuild to actually link; deviceLocale() falls back to English instead of crashing when it isn't linked yet, so the app stays usable before that rebuild happens Needs a NAS deploy (src/trpc/init.ts is shared backend code) before the x-locale fallback and the species/breed locale fix take effect for mobile. --- mobile/README.md | 72 ++++- mobile/app.json | 3 +- mobile/app/(app)/(tabs)/_layout.tsx | 10 +- mobile/app/(app)/(tabs)/explore.tsx | 22 +- mobile/app/(app)/(tabs)/index.tsx | 4 +- mobile/app/(app)/(tabs)/notifications.tsx | 37 +-- mobile/app/(app)/(tabs)/pets.tsx | 85 +++++- mobile/app/(app)/_layout.tsx | 28 +- .../app/(app)/messages/[conversationId].tsx | 8 +- mobile/app/(app)/messages/index.tsx | 4 +- mobile/app/(app)/milestone-new.tsx | 18 +- mobile/app/(app)/pets/[petId]/edit.tsx | 22 +- mobile/app/(app)/pets/[petId]/followers.tsx | 4 +- mobile/app/(app)/pets/[petId]/following.tsx | 4 +- mobile/app/(app)/pets/[petId]/index.tsx | 20 +- mobile/app/(app)/pets/[petId]/milestones.tsx | 11 +- mobile/app/(app)/post-new.tsx | 18 +- mobile/app/(app)/posts/[postId]/comments.tsx | 10 +- mobile/app/(app)/search.tsx | 24 +- mobile/app/(app)/story-new.tsx | 16 +- mobile/app/(auth)/sign-in.tsx | 24 +- mobile/app/(auth)/sign-up.tsx | 24 +- mobile/app/onboarding/_layout.tsx | 4 +- mobile/package-lock.json | 86 ++++++ mobile/package.json | 3 + mobile/src/api/trpc.ts | 10 +- mobile/src/components/FollowButton.tsx | 4 +- mobile/src/components/OptionPicker.tsx | 4 +- mobile/src/components/RepostCard.tsx | 4 +- mobile/src/components/StoryTray.tsx | 4 +- mobile/src/components/VideoPlayer.tsx | 6 +- .../components/health/HealthConsentGate.tsx | 45 ++- .../src/components/health/VaccineSection.tsx | 24 +- .../src/components/health/VetVisitSection.tsx | 18 +- .../components/health/WeightLogSection.tsx | 18 +- .../onboarding/ArachnophobiaOptIn.tsx | 15 +- .../src/components/onboarding/AvatarStep.tsx | 20 +- .../onboarding/OnboardingGuidelines.tsx | 12 +- .../components/onboarding/PetCreateForm.tsx | 40 +-- .../components/onboarding/UploadConsent.tsx | 16 +- mobile/src/i18n/index.ts | 69 +++++ mobile/src/i18n/locales/de.json | 266 ++++++++++++++++++ mobile/src/i18n/locales/en.json | 266 ++++++++++++++++++ mobile/src/lib/format.ts | 19 +- mobile/src/lib/milestone-meta.ts | 37 +-- src/trpc/init.ts | 6 +- 46 files changed, 1179 insertions(+), 285 deletions(-) create mode 100644 mobile/src/i18n/index.ts create mode 100644 mobile/src/i18n/locales/de.json create mode 100644 mobile/src/i18n/locales/en.json diff --git a/mobile/README.md b/mobile/README.md index a7f7019..bbc2fba 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -18,8 +18,9 @@ stories (tray/creation/viewer), video playback, search, health tracking (consent gate + weight/vet/vaccine logs), milestones, and message sending/receiving are done and verified end-to-end against the live backend. Push notifications are fully working — see "Push notifications" below. -Sign-up, first-pet onboarding, and a light/dark/auto theme are also done — -see their own sections below. Android is the priority platform; iOS +Sign-up, first-pet onboarding, a light/dark/auto theme, and German/English +i18n are also done — see their own sections below. Android is the priority +platform; iOS builds need a Mac + Apple Developer account and come later (same codebase, no separate source tree — see "Android-first, then iOS" below). @@ -199,8 +200,10 @@ mobile/ lib/format.ts # pet display name + relative-time formatting lib/sponsor.ts # active-sponsor check lib/upload.ts # direct PUT of a picked image to a presigned Supabase URL - lib/milestone-meta.ts # MilestoneType → German label/icon map + lib/milestone-meta.ts # MilestoneType → i18n key/icon map, see "Internationalization" theme/tokens.ts # design tokens (light `colors` + dark `darkColors`), see "Dark mode" + i18n/index.ts # i18next setup, device-locale detection, language preference, see "Internationalization" + i18n/locales/{en,de}.json # translation catalogs (kept in exact key-parity, see "Internationalization") components/ # Button, TextField, Card, PostCard, PawButton, PetAvatar, # PetIdentityBadges, FollowButton, StoryTray, PawRing, VideoPlayer, # OptionPicker (species/breed dropdown, shared with Explore + onboarding) @@ -242,14 +245,13 @@ screen is focused since messages.ts has no realtime mechanism) are done and verified end-to-end against the live backend on a real Android emulator. Still missing, roughly in the order they'd unblock the most: -- No i18n — the whole app is hardcoded German text, matching the sign-in screen - Web's Explore "Trending" tab (algorithmic hot-posts grid + trending hashtags, `explore.getTrending`) — mobile's Explore only has the species/breed-filtered pet grid (`explore.listPets`), not this second tab. Onboarding (`/onboarding/pet` — guidelines → upload consent → -Arachnophobie-Helfer opt-in → pet form → avatar), sign-up, and dark mode -are now built — see their own sections below. +Arachnophobie-Helfer opt-in → pet form → avatar), sign-up, dark mode, and +i18n are now built — see their own sections below. ### Onboarding and pet management @@ -326,6 +328,64 @@ pet profile/edit/followers/following. They'll render correctly in light mode regardless of the device/account theme setting until someone applies the same `makeStyles` conversion to them. +### Internationalization (i18n) + +German and English, matching web's supported locale set exactly +(`src/i18n/request.ts`'s `LOCALES`) — mobile intentionally didn't scope +this to the wider EN/ES/IT/FR list mentioned in older planning notes, since +web itself only ships EN/DE today. `i18next` + `react-i18next` (not +`next-intl`, which is Next.js-specific) with one flat "translation" +namespace per locale (`src/i18n/locales/{en,de}.json`), addressed by +dotted keys (`t("petForm.createButton")`) — mirrors next-intl's own +per-namespace-object shape closely enough that terminology was copied +verbatim from `../messages/{en,de}.json` wherever the same concept already +existed there (e.g. `MilestoneTypes`, `PetForm`). + +- **Preference resolution** (`src/i18n/index.ts`) mirrors web's own + cookie-only approach: `getLanguagePreference()`/`setLanguagePreference()` + persist a `"de" | "en" | null` override to MMKV (`null` = follow device + language, via `expo-localization`'s `getLocales()`) — there's no backend + field for it, same as web's locale living only in the `NEXT_LOCALE` + cookie, never on the `Owner` row. The switcher lives right below the + Darstellung (theme) section on the Pets tab, same three-button pattern + (Deutsch/English/Automatisch), and calls `queryClient.invalidateQueries()` + on change so already-cached species/breed names and dates refetch under + the new language instead of staying stale. +- **`react-i18next`'s `useTranslation()` needs no theme-style `makeStyles` + workaround** — unlike colors, text updates on every render automatically + once `i18n.changeLanguage()` fires, so switching language re-labels the + entire app instantly with no per-screen conversion debt (contrast with + "Dark mode" above, which very much has that debt). +- **Backend locale resolution for mobile** (`src/trpc/init.ts`, root + `CLAUDE.md`'s `createTRPCContext`): procedures that resolve + DB-stored translatable text (Species/Breed names, via `ctx.locale`) read + a `NEXT_LOCALE` cookie that a React Native `fetch`/`httpBatchLink` + request can't set. Mobile's tRPC client (`src/api/trpc.ts`) sends an + `x-locale: ` header instead; the backend checks the + cookie first (web unaffected), then this header (mobile-only fallback). + `pets.listSpecies`/`listBreeds` take `locale` as an explicit input rather + than reading `ctx.locale` — those call sites (`explore.tsx`, + `PetCreateForm.tsx`) pass `i18n.language` directly instead of a + hardcoded `"de"`, which was the actual state before this session (a + latent bug: species/breed *pickers* were German-only while the *rest* of + a pet's data was already resolving to English via `ctx.locale`'s + default, since mobile requests never carried a cookie at all). +- **Needs a NAS deploy**, unlike most `mobile/`-only work — the + `x-locale` header fallback is a shared backend file + (`src/trpc/init.ts`), and mobile's live default `EXPO_PUBLIC_API_URL` + points at the deployed site, not local dev (see + [[project_mobile_deployment_target]]). +- **`expo-localization` requires a native rebuild** (`expo prebuild` / + `expo run:android`) to actually link — it was added as a new native + module via config plugin, the same category of change as the FCM + credentials step in "Push notifications" below. Until that rebuild + happens on a given device, `Localization.getLocales()` throws `Cannot + find native module 'ExpoLocalization'`; `deviceLocale()` in + `src/i18n/index.ts` catches that and falls back to `defaultLocale` + ("en") rather than crashing the app at module-load time — "Automatisch" + silently behaves like "English" on an un-rebuilt dev client instead of + actually reading the device's system language. + ### Push notifications `usePushNotifications` (in `app/_layout.tsx`) requests notification diff --git a/mobile/app.json b/mobile/app.json index 9adbcf0..7671f2b 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -50,7 +50,8 @@ "icon": "./assets/android-icon-monochrome.png", "color": "#f97316" } - ] + ], + "expo-localization" ], "extra": { "router": {}, diff --git a/mobile/app/(app)/(tabs)/_layout.tsx b/mobile/app/(app)/(tabs)/_layout.tsx index 75b99b7..1ece212 100644 --- a/mobile/app/(app)/(tabs)/_layout.tsx +++ b/mobile/app/(app)/(tabs)/_layout.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { View, Text, Pressable, StyleSheet } from "react-native"; import { Tabs, useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { Home, Compass, Bell, Heart, MessageCircle, PawPrint, Plus, Search } from "lucide-react-native"; import { useTRPC } from "../../../src/api/trpc"; @@ -18,6 +19,7 @@ import { fontFamily, type ColorTokens } from "../../../src/theme/tokens"; * for actions that need one. */ export default function TabsLayout() { + const { t } = useTranslation(); const { colors } = useTheme(); const styles = useMemo(() => makeStyles(colors), [colors]); @@ -44,14 +46,14 @@ export default function TabsLayout() { ), - tabBarLabel: "Feed", + tabBarLabel: t("nav.feedTab"), tabBarIcon: ({ color, size }) => , }} /> ( @@ -63,12 +65,12 @@ export default function TabsLayout() { /> ( diff --git a/mobile/app/(app)/(tabs)/explore.tsx b/mobile/app/(app)/(tabs)/explore.tsx index f32bf21..e5c3ec4 100644 --- a/mobile/app/(app)/(tabs)/explore.tsx +++ b/mobile/app/(app)/(tabs)/explore.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { View, Text, FlatList, Pressable, ScrollView, ActivityIndicator, StyleSheet } from "react-native"; import { useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { useTRPC } from "../../../src/api/trpc"; import { useTheme } from "../../../src/context/ThemeContext"; @@ -19,6 +20,7 @@ import type { ExplorePet } from "../../../src/api/types"; * yet. */ export default function ExploreScreen() { + const { t, i18n } = useTranslation(); const trpc = useTRPC(); const { colors } = useTheme(); const styles = useMemo(() => makeStyles(colors), [colors]); @@ -27,7 +29,7 @@ export default function ExploreScreen() { const [breedId, setBreedId] = useState(null); const [breedName, setBreedName] = useState(null); - const { data: speciesList = [] } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: "de" })); + const { data: speciesList = [] } = useQuery(trpc.pets.listSpecies.queryOptions({ locale: i18n.language })); const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, error } = useInfiniteQuery( trpc.explore.listPets.infiniteQueryOptions( @@ -73,7 +75,7 @@ export default function ExploreScreen() { ListHeaderComponent={ - selectSpecies(null)} /> + selectSpecies(null)} /> {speciesList.map((s) => ( selectSpecies(s)} /> ))} @@ -99,7 +101,11 @@ export default function ExploreScreen() { !isLoading ? ( - {breedName ? `Noch keine ${breedName}.` : speciesName ? `Noch keine ${speciesName}.` : "Keine Tiere gefunden."} + {breedName + ? t("explore.noPetsForSpecies", { name: breedName }) + : speciesName + ? t("explore.noPetsForSpecies", { name: speciesName }) + : t("explore.noPetsAtAll")} ) : null @@ -127,22 +133,23 @@ function BreedFilter({ selectedName: string | null; onSelect: (b: PickerOption | null) => void; }) { + const { t, i18n } = useTranslation(); const trpc = useTRPC(); const { colors } = useTheme(); const styles = useMemo(() => makeStyles(colors), [colors]); const [open, setOpen] = useState(false); - const { data: breeds = [], isLoading } = useQuery(trpc.pets.listBreeds.queryOptions({ speciesId, locale: "de" })); + const { data: breeds = [], isLoading } = useQuery(trpc.pets.listBreeds.queryOptions({ speciesId, locale: i18n.language })); if (!isLoading && breeds.length === 0) return null; - const allOption: PickerOption = { id: "__all__", name: "Alle Rassen" }; + const allOption: PickerOption = { id: "__all__", name: t("explore.allBreeds") }; return ( setOpen((v) => !v)} selectedName={selectedName} - placeholder="Alle Rassen" + placeholder={t("explore.allBreeds")} isLoading={isLoading} options={[allOption, ...breeds]} onSelect={(o) => { @@ -155,6 +162,7 @@ function BreedFilter({ } function ExplorePetCard({ pet }: { pet: ExplorePet }) { + const { t } = useTranslation(); const router = useRouter(); const { colors } = useTheme(); const styles = useMemo(() => makeStyles(colors), [colors]); @@ -165,7 +173,7 @@ function ExplorePetCard({ pet }: { pet: ExplorePet }) { {formatPetName(pet)} - {pet._count.followers} Follower + {t("explore.followerCount", { count: pet._count.followers })} ); diff --git a/mobile/app/(app)/(tabs)/index.tsx b/mobile/app/(app)/(tabs)/index.tsx index 33dff4a..8e17006 100644 --- a/mobile/app/(app)/(tabs)/index.tsx +++ b/mobile/app/(app)/(tabs)/index.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { View, Text, FlatList, ActivityIndicator, RefreshControl, StyleSheet } from "react-native"; +import { useTranslation } from "react-i18next"; import { useInfiniteQuery } from "@tanstack/react-query"; import { useTRPC } from "../../../src/api/trpc"; import { useActivePet } from "../../../src/context/ActivePetContext"; @@ -15,6 +16,7 @@ import type { FeedPost } from "../../../src/api/types"; * "chrono") toggle yet. */ export default function FeedScreen() { + const { t } = useTranslation(); const trpc = useTRPC(); const { activePetId, isLoading: isActivePetLoading } = useActivePet(); const { colors, typography } = useTheme(); @@ -67,7 +69,7 @@ export default function FeedScreen() { ListFooterComponent={isFetchingNextPage ? : null} ListEmptyComponent={ - Noch keine Beiträge im Feed. + {t("feed.empty")} } /> diff --git a/mobile/app/(app)/(tabs)/notifications.tsx b/mobile/app/(app)/(tabs)/notifications.tsx index 066abf1..74ddefc 100644 --- a/mobile/app/(app)/(tabs)/notifications.tsx +++ b/mobile/app/(app)/(tabs)/notifications.tsx @@ -1,6 +1,8 @@ import { useCallback, useMemo } from "react"; import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native"; import { useFocusEffect } from "expo-router"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "../../../src/api/trpc"; import { useActivePet } from "../../../src/context/ActivePetContext"; @@ -10,31 +12,34 @@ import { formatPetName, formatRelativeTime } from "../../../src/lib/format"; import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens"; import type { NotificationItem } from "../../../src/api/types"; -const ACTOR_MESSAGES: Partial string>> = { - FOLLOW: (actor) => `${actor} folgt dir jetzt.`, - REACTION: (actor) => `${actor} hat deinen Beitrag mit einer Pfote versehen.`, - COMMENT: (actor) => `${actor} hat deinen Beitrag kommentiert.`, - MESSAGE: (actor) => `${actor} hat dir eine Nachricht geschickt.`, - MENTION: (actor) => `${actor} hat dich erwähnt.`, - PAW_BACK: (actor) => `${actor} hat zurückgepfotet.`, +const ACTOR_MESSAGE_KEYS: Partial> = { + FOLLOW: "notifications.follow", + REACTION: "notifications.reaction", + COMMENT: "notifications.comment", + MESSAGE: "notifications.message", + MENTION: "notifications.mention", + PAW_BACK: "notifications.pawBack", }; // BIRTHDAY/ADOPTION_DAY are self-referential ("your pet's day today") — the // backend never sets actorPetId for them (see src/app/api/cron/anniversaries -// on web), so there's no actor to name. Wording ported from messages/de.json. -const SELF_MESSAGES: Partial> = { - BIRTHDAY: "🎂 Alles Gute zum Geburtstag!", - ADOPTION_DAY: "🏠 Heute ist der Adoptionstag!", +// on web), so there's no actor to name. +const SELF_MESSAGE_KEYS: Partial> = { + BIRTHDAY: "notifications.birthday", + ADOPTION_DAY: "notifications.adoptionDay", }; -function getNotificationText(item: NotificationItem): string { - if (SELF_MESSAGES[item.type]) return SELF_MESSAGES[item.type]!; - if (item.actorPet) return ACTOR_MESSAGES[item.type]?.(formatPetName(item.actorPet)) ?? item.type; +function getNotificationText(item: NotificationItem, t: TFunction): string { + const selfKey = SELF_MESSAGE_KEYS[item.type]; + if (selfKey) return t(selfKey); + const actorKey = ACTOR_MESSAGE_KEYS[item.type]; + if (actorKey && item.actorPet) return t(actorKey, { actor: formatPetName(item.actorPet) }); return item.type; } /** Port of web's notifications list — mark-read on tap, no deep-linking to the target post/comment yet. */ export default function NotificationsScreen() { + const { t } = useTranslation(); const trpc = useTRPC(); const queryClient = useQueryClient(); const { activePetId } = useActivePet(); @@ -116,14 +121,14 @@ export default function NotificationsScreen() { )} - {getNotificationText(item)} + {getNotificationText(item, t)} {formatRelativeTime(item.createdAt)} )} ListEmptyComponent={ - Keine Benachrichtigungen. + {t("notifications.empty")} } /> diff --git a/mobile/app/(app)/(tabs)/pets.tsx b/mobile/app/(app)/(tabs)/pets.tsx index 395b2c0..2d89164 100644 --- a/mobile/app/(app)/(tabs)/pets.tsx +++ b/mobile/app/(app)/(tabs)/pets.tsx @@ -1,9 +1,12 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native"; import { useRouter } from "expo-router"; -import { Check, Sun, Moon, SunMoon } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { Check, Sun, Moon, SunMoon, Languages } from "lucide-react-native"; import { useActivePet } from "../../../src/context/ActivePetContext"; import { useTheme } from "../../../src/context/ThemeContext"; +import { getLanguagePreference, setLanguagePreference, type Locale } from "../../../src/i18n"; import { PetAvatar } from "../../../src/components/PetAvatar"; import { PetIdentityBadges } from "../../../src/components/PetIdentityBadges"; import { Card } from "../../../src/components/Card"; @@ -23,6 +26,7 @@ import type { Pet } from "../../../src/api/types"; * on web (re-toggleable on /pets, not just at onboarding). */ export default function PetsScreen() { + const { t } = useTranslation(); const router = useRouter(); const { pets, activePetId, setActivePetId, isLoading } = useActivePet(); const { colors } = useTheme(); @@ -52,10 +56,15 @@ export default function PetsScreen() { )} ListEmptyComponent={ - Noch keine Tiere angelegt. + {t("pets.empty")} } - ListFooterComponent={} + ListFooterComponent={ + <> + + + + } /> ); } @@ -103,21 +112,22 @@ function PetRow({ ); } -const APPEARANCE_OPTIONS = [ - { value: "LIGHT" as const, label: "Hell", icon: Sun }, - { value: "DARK" as const, label: "Dunkel", icon: Moon }, - { value: null, label: "Automatisch", icon: SunMoon }, -]; - function AppearanceSection() { + const { t } = useTranslation(); const { colors, preference, setPreference } = useTheme(); const styles = useMemo(() => makeStyles(colors), [colors]); + const appearanceOptions = [ + { value: "LIGHT" as const, label: t("pets.appearanceLight"), icon: Sun }, + { value: "DARK" as const, label: t("pets.appearanceDark"), icon: Moon }, + { value: null, label: t("pets.appearanceAuto"), icon: SunMoon }, + ]; + return ( - Darstellung + {t("pets.appearanceTitle")} - {APPEARANCE_OPTIONS.map((option) => { + {appearanceOptions.map((option) => { const active = preference === option.value; const Icon = option.icon; return ( @@ -138,6 +148,57 @@ function AppearanceSection() { ); } +/** + * Local-only preference (mirrors web: locale lives only in the NEXT_LOCALE + * cookie, never on the Owner row — see src/i18n/index.ts), so this needs no + * mutation, just MMKV + i18next's changeLanguage(). Full cache invalidation + * on change is deliberate: pet/post data cached before the switch (species + * and breed names in particular) was fetched under the old x-locale header + * and won't relabel itself otherwise. + */ +function LanguageSection() { + const { t } = useTranslation(); + const { colors } = useTheme(); + const styles = useMemo(() => makeStyles(colors), [colors]); + const queryClient = useQueryClient(); + const [preference, setPreferenceState] = useState(() => getLanguagePreference()); + + const languageOptions = [ + { value: "de" as const, label: t("pets.languageGerman") }, + { value: "en" as const, label: t("pets.languageEnglish") }, + { value: null, label: t("pets.languageAuto") }, + ]; + + const handleSelect = (value: Locale | null) => { + setPreferenceState(value); + setLanguagePreference(value); + queryClient.invalidateQueries(); + }; + + return ( + + {t("pets.languageTitle")} + + {languageOptions.map((option) => { + const active = preference === option.value; + return ( + handleSelect(option.value)} + > + + + {option.label} + + + ); + })} + + + ); +} + function makeStyles(colors: ColorTokens) { const typography: Typography = getTypography(colors); return StyleSheet.create({ diff --git a/mobile/app/(app)/_layout.tsx b/mobile/app/(app)/_layout.tsx index 4875daf..0581c76 100644 --- a/mobile/app/(app)/_layout.tsx +++ b/mobile/app/(app)/_layout.tsx @@ -1,6 +1,7 @@ import { Pressable } from "react-native"; import { Stack, useRouter } from "expo-router"; import { X } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; import { useTheme } from "../../src/context/ThemeContext"; import { fontFamily } from "../../src/theme/tokens"; @@ -15,6 +16,7 @@ function CloseModalButton() { } export default function AppLayout() { + const { t } = useTranslation(); const { colors } = useTheme(); // Plain prop objects, not StyleSheet.create — safe to recompute on every // render, unlike the module-level StyleSheet.create calls most other @@ -29,21 +31,21 @@ export default function AppLayout() { return ( - - - - - - - - - - + + + + + + + + + + , }} @@ -52,7 +54,7 @@ export default function AppLayout() { name="story-new" options={{ ...headerOptions, - title: "Neue Story", + title: t("screens.newStory"), presentation: "modal", headerLeft: () => , }} @@ -61,7 +63,7 @@ export default function AppLayout() { name="milestone-new" options={{ ...headerOptions, - title: "Neuer Meilenstein", + title: t("screens.newMilestone"), presentation: "modal", headerLeft: () => , }} diff --git a/mobile/app/(app)/messages/[conversationId].tsx b/mobile/app/(app)/messages/[conversationId].tsx index eeb30c5..6eb6fec 100644 --- a/mobile/app/(app)/messages/[conversationId].tsx +++ b/mobile/app/(app)/messages/[conversationId].tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { View, Text, FlatList, KeyboardAvoidingView, Platform, StyleSheet } from "react-native"; import { useLocalSearchParams } from "expo-router"; +import { useTranslation } from "react-i18next"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "../../../src/api/trpc"; import { useActivePet } from "../../../src/context/ActivePetContext"; @@ -18,6 +19,7 @@ const POLL_INTERVAL_MS = 4000; * this screen is focused rather than a subscription. */ export default function ConversationScreen() { + const { t } = useTranslation(); const { conversationId } = useLocalSearchParams<{ conversationId: string }>(); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -73,7 +75,7 @@ export default function ConversationScreen() { renderItem={({ item }) => } ListEmptyComponent={ - Noch keine Nachrichten. Schreib die erste! + {t("messages.empty")} } /> @@ -81,14 +83,14 @@ export default function ConversationScreen() {