diff --git a/mobile/app/(app)/(tabs)/_layout.tsx b/mobile/app/(app)/(tabs)/_layout.tsx index fa7b8a8..8e70fe5 100644 --- a/mobile/app/(app)/(tabs)/_layout.tsx +++ b/mobile/app/(app)/(tabs)/_layout.tsx @@ -3,7 +3,7 @@ import { View, Text, Pressable, StyleSheet, type ColorValue } 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 { Home, Compass, Bell, Heart, MessageCircle, PawPrint, Plus, Search, Settings } from "lucide-react-native"; import { useTRPC } from "../../../src/api/trpc"; import { useActivePet } from "../../../src/context/ActivePetContext"; import { useTheme } from "../../../src/context/ThemeContext"; @@ -17,6 +17,14 @@ import { fontFamily, type ColorTokens } from "../../../src/theme/tokens"; * (Explore tab) have no web mobile-nav equivalent at all — web only exposes * search via the desktop Sidebar — so these are this app's own entry points * for actions that need one. + * + * Settings is also a header button (gear, next to Messages), not a 5th tab — + * found.md: a tester suggested pulling app settings out of "Meine Tiere" + * into their own place; a header icon was chosen over another tab because + * Settings is a low-frequency destination (a couple of times a year for a + * typical user), not worth permanent bottom-nav real estate. It appears on + * every tab (via the `screenOptions.headerRight` default plus each tab's own + * override), unlike the other action buttons above which are per-tab. */ export default function TabsLayout() { const { t } = useTranslation(); @@ -27,7 +35,12 @@ export default function TabsLayout() { , + headerRight: () => ( + + + + + ), headerStyle: { backgroundColor: colors.pureWhite }, headerTitleStyle: { fontFamily: fontFamily.bold, color: colors.ink }, tabBarActiveTintColor: colors.marigoldOrange, @@ -43,6 +56,7 @@ export default function TabsLayout() { headerRight: () => ( + ), @@ -57,6 +71,7 @@ export default function TabsLayout() { headerRight: () => ( + ), @@ -74,6 +89,7 @@ export default function TabsLayout() { headerRight: () => ( + ), @@ -100,7 +116,7 @@ function NewPostHeaderButton() { const router = useRouter(); const { colors } = useTheme(); return ( - router.push("/post-new")} hitSlop={12}> + router.push("/post-new")} hitSlop={8}> ); @@ -110,7 +126,7 @@ function AddPetHeaderButton() { const router = useRouter(); const { colors } = useTheme(); return ( - router.push("/onboarding/pet")} hitSlop={12}> + router.push("/onboarding/pet")} hitSlop={8}> ); @@ -120,18 +136,28 @@ function SearchHeaderButton() { const router = useRouter(); const { colors } = useTheme(); return ( - router.push("/search")} hitSlop={12}> + router.push("/search")} hitSlop={8}> ); } +function SettingsHeaderButton() { + const router = useRouter(); + const { colors } = useTheme(); + return ( + router.push("/settings")} hitSlop={8}> + + + ); +} + function MessagesHeaderButton() { const router = useRouter(); const { colors } = useTheme(); const styles = useMemo(() => makeStyles(colors), [colors]); return ( - router.push("/messages")} hitSlop={12} style={styles.headerButton}> + router.push("/messages")} hitSlop={8} style={styles.headerButton}> ); @@ -162,6 +188,12 @@ function NotificationsTabIcon({ color, size }: { color: ColorValue; size: number function makeStyles(colors: ColorTokens) { return StyleSheet.create({ + // `gap: 16` between icons here — every header icon Pressable below uses + // `hitSlop={8}` (not more) specifically so two adjacent icons' extended + // touch areas meet at this gap without overlapping (8 + 8 = 16). Rows + // can have up to 3 icons now that SettingsHeaderButton sits between an + // action button and MessagesHeaderButton — raise this gap before raising + // any hitSlop, or adjacent buttons start stealing each other's taps. headerButtons: { flexDirection: "row", alignItems: "center", gap: 16 }, headerButton: { marginRight: 16 }, wordmark: { flexDirection: "row", alignItems: "center", gap: 8 }, diff --git a/mobile/app/(app)/(tabs)/pets.tsx b/mobile/app/(app)/(tabs)/pets.tsx index 7b8e36c..2fe3d90 100644 --- a/mobile/app/(app)/(tabs)/pets.tsx +++ b/mobile/app/(app)/(tabs)/pets.tsx @@ -1,22 +1,13 @@ -import { Component, useMemo, type ReactNode } from "react"; -import { View, Text, FlatList, Pressable, ActivityIndicator, Linking, Alert, StyleSheet, Switch } from "react-native"; +import { useMemo } from "react"; +import { View, Text, FlatList, Pressable, ActivityIndicator, StyleSheet } from "react-native"; import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import Constants from "expo-constants"; -import * as Updates from "expo-updates"; -import * as Sentry from "@sentry/react-native"; -import { useAuth } from "@clerk/expo"; -import { Check, Sun, Moon, SunMoon } from "lucide-react-native"; -import { useTRPC } from "../../../src/api/trpc"; +import { Check } from "lucide-react-native"; import { useActivePet } from "../../../src/context/ActivePetContext"; import { useTheme } from "../../../src/context/ThemeContext"; -import { env } from "../../../src/lib/env"; import { PetAvatar } from "../../../src/components/PetAvatar"; import { PetIdentityBadges } from "../../../src/components/PetIdentityBadges"; import { Card } from "../../../src/components/Card"; -import { LanguageSwitcher } from "../../../src/components/LanguageSwitcher"; -import { Button } from "../../../src/components/Button"; import { formatPetName } from "../../../src/lib/format"; import { getTypography, type ColorTokens, type Typography } from "../../../src/theme/tokens"; import type { Pet } from "../../../src/api/types"; @@ -27,10 +18,13 @@ import type { Pet } from "../../../src/api/types"; * separate tap target for setting the "active pet" used by every * pet-scoped action elsewhere in the app — these are two different actions * and shouldn't share one tap target (a pet you're not actively posting as - * should still be viewable/editable). Also hosts the Darstellung - * (light/dark/auto) toggle at the bottom — there's no dedicated settings - * screen yet, and this mirrors where the Arachnophobie-Helfer toggle lives - * on web (re-toggleable on /pets, not just at onboarding). + * should still be viewable/editable). + * + * Used to also host Darstellung/Sprache/Tier-Vorschläge/Rechtliches/Logout + * as a footer below the list (no dedicated settings screen existed yet) — + * those moved to their own screen (app/(app)/settings.tsx, reached via the + * gear icon in the header) once this screen got crowded enough that a + * tester flagged it in found.md. This screen is pet-list-only again now. */ export default function PetsScreen() { const { t } = useTranslation(); @@ -66,17 +60,6 @@ export default function PetsScreen() { {t("pets.empty")} } - ListFooterComponent={ - <> - - - - - - - - - } /> ); } @@ -124,217 +107,6 @@ function PetRow({ ); } -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 ( - - {t("pets.appearanceTitle")} - - {appearanceOptions.map((option) => { - const active = preference === option.value; - const Icon = option.icon; - return ( - setPreference(option.value)} - > - - - {option.label} - - - ); - })} - - - ); -} - -/** Section chrome (title + spacing) around the shared LanguageSwitcher — see that component for the actual preference logic (found.md: extracted once the sign-in/sign-up screens needed the same switcher). */ -function LanguageSection() { - const { t } = useTranslation(); - const { colors } = useTheme(); - const styles = useMemo(() => makeStyles(colors), [colors]); - - return ( - - {t("pets.languageTitle")} - - - ); -} - -/** - * Opt-out for the PET_SUGGESTION cron notification - * (src/app/api/cron/pet-suggestions on the backend) — on by default, always - * re-toggleable here. No dedicated Switch component exists in this app yet - * (web uses shadcn's Switch), so this uses React Native's built-in one. - */ -function PetSuggestionsSection() { - const { t } = useTranslation(); - const { colors } = useTheme(); - const styles = useMemo(() => makeStyles(colors), [colors]); - const trpc = useTRPC(); - const qc = useQueryClient(); - - const { data } = useQuery(trpc.owner.getPetSuggestionsPreference.queryOptions()); - - const setPreferenceMutation = useMutation( - trpc.owner.setPetSuggestionsPreference.mutationOptions({ - onSuccess: () => { - void qc.invalidateQueries({ queryKey: trpc.owner.getPetSuggestionsPreference.queryKey() }); - }, - }) - ); - - const petSuggestionsEnabled = data?.petSuggestionsEnabled ?? true; - - return ( - - - - {t("pets.petSuggestionsTitle")} - {t("pets.petSuggestionsLabel")} - - setPreferenceMutation.mutate({ petSuggestionsEnabled: value })} - disabled={setPreferenceMutation.isPending} - trackColor={{ true: colors.marigoldOrange }} - /> - - - ); -} - -/** - * Reads expo-updates' launch info defensively — these are plain native-module-backed exports, not - * function calls, but a stripped/misconfigured build (e.g. a dev client without the module properly - * linked) throwing here would otherwise crash LegalSection's render, and LegalSection sits outside - * FooterErrorBoundary (that only wraps LogoutSection below it), so there's no fallback UI to catch it. - * Not meaningful on the web target (the web shim always reports isEmbeddedLaunch=false/updateId=null) — - * this app has no shipped web build (`eas update` only ever runs `--platform android`, see README), so - * that's a dead branch today, not a live bug. - */ -function readOtaUpdateId(): { isEmbeddedLaunch: boolean; updateId: string | null } { - try { - return { isEmbeddedLaunch: Updates.isEmbeddedLaunch, updateId: Updates.updateId }; - } catch { - return { isEmbeddedLaunch: false, updateId: null }; - } -} - -/** - * Links to the fixed-language legal pages (see root CLAUDE.md: not localized, always German) plus - * the installed app version and current OTA update state, for anyone who needs to reach them without - * going through the web app. The OTA suffix exists so a support conversation ("did the update actually - * arrive?") can be answered by reading this screen instead of guessing — compare the short id shown - * here against the "Android update ID" an `eas update` publish prints. - */ -function LegalSection() { - const { t } = useTranslation(); - const { colors } = useTheme(); - const styles = useMemo(() => makeStyles(colors), [colors]); - const version = Constants.expoConfig?.version ?? "?"; - const { isEmbeddedLaunch, updateId } = readOtaUpdateId(); - const ota = isEmbeddedLaunch - ? t("pets.otaEmbedded") - : updateId - ? t("pets.otaId", { id: updateId.slice(0, 8) }) - : t("pets.otaUnknown"); - - const links = [ - { label: t("pets.legalImpressum"), path: "impressum" }, - { label: t("pets.legalPrivacy"), path: "datenschutz" }, - { label: t("pets.legalTerms"), path: "nutzungsbedingungen" }, - { label: t("pets.legalChildSafety"), path: "kinderschutz" }, - ]; - - return ( - - {links.map((link) => ( - Linking.openURL(`${env.apiUrl}/${link.path}`)}> - {link.label} - - ))} - {t("pets.versionLabel", { version, ota })} - - ); -} - -/** - * Local safety net for LogoutSection. Sentry.wrap(RootLayout) in - * app/_layout.tsx is the only other error boundary in the app, at the very - * root — a render crash here would otherwise take down the entire screen - * with no visible detail. Cheap enough to leave in permanently. - */ -class FooterErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { - state: { error: Error | null } = { error: null }; - - static getDerivedStateFromError(error: Error) { - return { error }; - } - - componentDidCatch(error: Error) { - Sentry.captureException(error); - } - - render() { - if (this.state.error) { - return Footer-Fehler: {this.state.error.message}; - } - return this.props.children; - } -} - -/** - * Sign-out was previously only reachable by uninstalling the app or clearing - * app data — no way to switch accounts or log back in as someone else - * (found.md). `useProtectedRoute()` in app/_layout.tsx already redirects to - * sign-in as soon as `isSignedIn` flips false after signOut() resolves, so - * no manual navigation is needed here. - * - * Uses the shared `Button` component (outline variant) instead of a - * hand-rolled Pressable+icon — a first pass copied edit.tsx's bespoke - * red delete-pet row, which read as unpolished ("dev button": thin - * border, no fill, off typography) once actually seen on a device. - * Sign-out is also reversible and non-destructive, unlike deleting a - * pet, so the neutral outline style fits better than alarm red anyway. - */ -function LogoutSection() { - const { t } = useTranslation(); - const { colors } = useTheme(); - const styles = useMemo(() => makeStyles(colors), [colors]); - const { signOut } = useAuth(); - const { clearActivePetId } = useActivePet(); - - const confirmLogout = () => { - Alert.alert(t("pets.logoutConfirmTitle"), t("pets.logoutConfirmMessage"), [ - { text: t("common.cancel"), style: "cancel" }, - { - text: t("pets.logoutButton"), - style: "destructive", - onPress: () => { - clearActivePetId(); - void signOut(); - }, - }, - ]); - }; - - return