Merge branch 'feature/settings-header-icon' into main
Closes Gitea #36. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: true,
|
||||
headerRight: () => <MessagesHeaderButton />,
|
||||
headerRight: () => (
|
||||
<View style={styles.headerButtons}>
|
||||
<SettingsHeaderButton />
|
||||
<MessagesHeaderButton />
|
||||
</View>
|
||||
),
|
||||
headerStyle: { backgroundColor: colors.pureWhite },
|
||||
headerTitleStyle: { fontFamily: fontFamily.bold, color: colors.ink },
|
||||
tabBarActiveTintColor: colors.marigoldOrange,
|
||||
@@ -43,6 +56,7 @@ export default function TabsLayout() {
|
||||
headerRight: () => (
|
||||
<View style={styles.headerButtons}>
|
||||
<NewPostHeaderButton />
|
||||
<SettingsHeaderButton />
|
||||
<MessagesHeaderButton />
|
||||
</View>
|
||||
),
|
||||
@@ -57,6 +71,7 @@ export default function TabsLayout() {
|
||||
headerRight: () => (
|
||||
<View style={styles.headerButtons}>
|
||||
<SearchHeaderButton />
|
||||
<SettingsHeaderButton />
|
||||
<MessagesHeaderButton />
|
||||
</View>
|
||||
),
|
||||
@@ -74,6 +89,7 @@ export default function TabsLayout() {
|
||||
headerRight: () => (
|
||||
<View style={styles.headerButtons}>
|
||||
<AddPetHeaderButton />
|
||||
<SettingsHeaderButton />
|
||||
<MessagesHeaderButton />
|
||||
</View>
|
||||
),
|
||||
@@ -100,7 +116,7 @@ function NewPostHeaderButton() {
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Pressable onPress={() => router.push("/post-new")} hitSlop={12}>
|
||||
<Pressable onPress={() => router.push("/post-new")} hitSlop={8}>
|
||||
<Plus color={colors.ink} size={22} />
|
||||
</Pressable>
|
||||
);
|
||||
@@ -110,7 +126,7 @@ function AddPetHeaderButton() {
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Pressable onPress={() => router.push("/onboarding/pet")} hitSlop={12}>
|
||||
<Pressable onPress={() => router.push("/onboarding/pet")} hitSlop={8}>
|
||||
<Plus color={colors.ink} size={22} />
|
||||
</Pressable>
|
||||
);
|
||||
@@ -120,18 +136,28 @@ function SearchHeaderButton() {
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Pressable onPress={() => router.push("/search")} hitSlop={12}>
|
||||
<Pressable onPress={() => router.push("/search")} hitSlop={8}>
|
||||
<Search color={colors.ink} size={22} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsHeaderButton() {
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
return (
|
||||
<Pressable onPress={() => router.push("/settings")} hitSlop={8}>
|
||||
<Settings color={colors.ink} size={22} />
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function MessagesHeaderButton() {
|
||||
const router = useRouter();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
return (
|
||||
<Pressable onPress={() => router.push("/messages")} hitSlop={12} style={styles.headerButton}>
|
||||
<Pressable onPress={() => router.push("/messages")} hitSlop={8} style={styles.headerButton}>
|
||||
<MessageCircle color={colors.ink} size={22} />
|
||||
</Pressable>
|
||||
);
|
||||
@@ -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 },
|
||||
|
||||
@@ -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() {
|
||||
<Text style={getTypography(colors).body}>{t("pets.empty")}</Text>
|
||||
</View>
|
||||
}
|
||||
ListFooterComponent={
|
||||
<>
|
||||
<AppearanceSection />
|
||||
<LanguageSection />
|
||||
<PetSuggestionsSection />
|
||||
<LegalSection />
|
||||
<FooterErrorBoundary>
|
||||
<LogoutSection />
|
||||
</FooterErrorBoundary>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<View style={styles.appearanceSection}>
|
||||
<Text style={styles.appearanceTitle}>{t("pets.appearanceTitle")}</Text>
|
||||
<View style={styles.appearanceRow}>
|
||||
{appearanceOptions.map((option) => {
|
||||
const active = preference === option.value;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.label}
|
||||
style={[styles.appearanceOption, active && styles.appearanceOptionActive]}
|
||||
onPress={() => setPreference(option.value)}
|
||||
>
|
||||
<Icon color={active ? colors.pureWhite : colors.ink} size={18} />
|
||||
<Text style={[styles.appearanceOptionText, active && styles.appearanceOptionTextActive]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<View style={styles.appearanceSection}>
|
||||
<Text style={styles.appearanceTitle}>{t("pets.languageTitle")}</Text>
|
||||
<LanguageSwitcher />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<View style={styles.appearanceSection}>
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleText}>
|
||||
<Text style={styles.appearanceTitle}>{t("pets.petSuggestionsTitle")}</Text>
|
||||
<Text style={styles.toggleLabel}>{t("pets.petSuggestionsLabel")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={petSuggestionsEnabled}
|
||||
onValueChange={(value) => setPreferenceMutation.mutate({ petSuggestionsEnabled: value })}
|
||||
disabled={setPreferenceMutation.isPending}
|
||||
trackColor={{ true: colors.marigoldOrange }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<View style={styles.legalSection}>
|
||||
{links.map((link) => (
|
||||
<Pressable key={link.path} onPress={() => Linking.openURL(`${env.apiUrl}/${link.path}`)}>
|
||||
<Text style={styles.legalLink}>{link.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Text style={styles.versionText}>{t("pets.versionLabel", { version, ota })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <Text style={{ color: "#dc2626", padding: 16 }}>Footer-Fehler: {this.state.error.message}</Text>;
|
||||
}
|
||||
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 <Button title={t("pets.logoutButton")} variant="outline" onPress={confirmLogout} style={styles.logoutButton} />;
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
@@ -354,30 +126,5 @@ function makeStyles(colors: ColorTokens) {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
},
|
||||
appearanceSection: { marginTop: 28 },
|
||||
appearanceTitle: { ...typography.label, textTransform: "none", letterSpacing: 0, marginBottom: 10 },
|
||||
toggleRow: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||
toggleText: { flex: 1 },
|
||||
toggleLabel: { ...typography.body, fontSize: 12, color: colors.quietGray, marginTop: 2 },
|
||||
appearanceRow: { flexDirection: "row", gap: 8 },
|
||||
appearanceOption: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
},
|
||||
appearanceOptionActive: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
appearanceOptionText: { ...typography.body, fontSize: 13, color: colors.ink },
|
||||
appearanceOptionTextActive: { color: colors.pureWhite },
|
||||
legalSection: { marginTop: 28, gap: 12, alignItems: "center" },
|
||||
legalLink: { ...typography.body, fontSize: 13, color: colors.marigoldDeep },
|
||||
versionText: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
logoutButton: { marginTop: 28 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export default function AppLayout() {
|
||||
<Stack.Screen name="posts/[postId]/comments" options={{ ...headerOptions, title: t("screens.comments") }} />
|
||||
<Stack.Screen name="messages/[conversationId]" options={{ ...headerOptions, title: t("screens.conversation") }} />
|
||||
<Stack.Screen name="search" options={{ ...headerOptions, title: t("screens.search") }} />
|
||||
<Stack.Screen name="settings" options={{ ...headerOptions, title: t("screens.settings") }} />
|
||||
<Stack.Screen
|
||||
name="post-new"
|
||||
options={{
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from "react";
|
||||
import { ScrollView, StyleSheet } from "react-native";
|
||||
import { useTheme } from "../../src/context/ThemeContext";
|
||||
import { AppearanceSection } from "../../src/components/settings/AppearanceSection";
|
||||
import { LanguageSection } from "../../src/components/settings/LanguageSection";
|
||||
import { PetSuggestionsSection } from "../../src/components/settings/PetSuggestionsSection";
|
||||
import { LegalSection } from "../../src/components/settings/LegalSection";
|
||||
import { LogoutSection } from "../../src/components/settings/LogoutSection";
|
||||
import { FooterErrorBoundary } from "../../src/components/settings/FooterErrorBoundary";
|
||||
import type { ColorTokens } from "../../src/theme/tokens";
|
||||
|
||||
/**
|
||||
* Dedicated Settings screen, reached via the gear icon in every tab's header
|
||||
* (see (tabs)/_layout.tsx's SettingsHeaderButton) rather than a bottom tab.
|
||||
* found.md: a tester suggested pulling these out of "Meine Tiere" into their
|
||||
* own place; a header icon was chosen over a 5th tab because Settings is a
|
||||
* low-frequency destination (realistically a couple of times a year for a
|
||||
* typical user) — not worth permanent bottom-nav real estate.
|
||||
*
|
||||
* Lives outside (tabs)/ (like search.tsx), registered as a plain Stack.Screen
|
||||
* in (app)/_layout.tsx — pushes with a normal back arrow, no bottom tab bar
|
||||
* showing while it's open, no `presentation: "modal"` (this isn't a
|
||||
* create-something flow like post-new/story-new).
|
||||
*
|
||||
* The extracted sections still read from the `pets.*` i18n namespace rather
|
||||
* than a new `settings.*` one — deliberate, not an oversight (flagged in
|
||||
* code review): `pets.language*` is also used by LanguageSwitcher on the
|
||||
* pre-login sign-in/sign-up screens, so renaming it would misdescribe those
|
||||
* call sites too, and renaming only some of the moved keys would leave the
|
||||
* namespace inconsistent either way. Revisit only as part of a deliberate
|
||||
* i18n-namespace pass across the whole app, not as a side effect of this
|
||||
* screen move.
|
||||
*/
|
||||
export default function SettingsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
return (
|
||||
<FooterErrorBoundary>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<AppearanceSection />
|
||||
<LanguageSection />
|
||||
<PetSuggestionsSection />
|
||||
<LegalSection />
|
||||
<LogoutSection />
|
||||
</ScrollView>
|
||||
</FooterErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
content: { padding: 16, gap: 28, flexGrow: 1, backgroundColor: colors.porchWhite },
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -115,8 +115,8 @@ Neu 7:
|
||||
→ Analyse erledigt, betrifft 4 Dateien (edit.tsx Geburtstag/Adoptionsdatum + 3 Health-Sektionen). Wheel-Picker braucht ein natives Modul (`@react-native-community/datetimepicker`) → kein einzelner Build nur dafür, stattdessen auf `PENDING-NATIVE-BUILD.md` verschoben und wird mit dem nächsten ohnehin anstehenden nativen Build mitgenommen. Der reine Trennzeichen-Fix ("-" → ".") ist davon unabhängig und OTA-fähig, sobald gewünscht.
|
||||
- [x] Ein User bemängelt, das die Regisrierung gut ist, aber beim eingeben des Passworts diese hinter der "Bildschrimtastatur" verschwindet, ausserdem gibt es keine Möglichkeit für den User die korrekte eingabe nochmal zu prüfen.
|
||||
→ Beides im Code bestätigt (`app/(auth)/sign-up.tsx`, gleiches Muster auch in `sign-in.tsx` gefunden und mitbehoben): (1) Der Screen nutzte nur ein einfaches `View` als äußeren Container, kein `ScrollView`/`KeyboardAvoidingView` — auf kleinen Screens schob sich die Tastatur einfach über die unteren Felder. Fix: äußerer Container jetzt `KeyboardAvoidingView` (iOS: `padding`) + `ScrollView` (`keyboardShouldPersistTaps="handled"`, `contentContainerStyle` mit `flexGrow` statt `flex`, damit weiterhin zentriert bleibt aber scrollbar wird). (2) Neue geteilte `PasswordField`-Komponente (`src/components/PasswordField.tsx`) mit Auge/Auge-durchgestrichen-Toggle (lucide `Eye`/`EyeOff`) ersetzt das nackte `secureTextEntry`-`TextField` in beiden Screens. Kein natives Modul, OTA-fähig. Typecheck sauber (Mobile).
|
||||
- [ ] Ein anderer User, meinte wäre es nicht schöner und aufgeräumter wenn die App Settings nicht unter MyPets stehen würden sondern einen eigenen Tab erhalten.
|
||||
→ Nachvollziehbarer Punkt (Darstellung/Sprache/Tier-Vorschläge/Rechtliches/Logout stecken aktuell alle im Footer von "Meine Tiere", `app/(app)/(tabs)/pets.tsx`) — reine IA-Entscheidung, kein Bug. Würde einen neuen Tab in `(tabs)/_layout.tsx` plus Verschieben der bestehenden Footer-Sections in einen neuen `settings.tsx`-Screen bedeuten. Noch nicht umgesetzt, auf Rückmeldung ob gewünscht.
|
||||
- [x] Ein anderer User, meinte wäre es nicht schöner und aufgeräumter wenn die App Settings nicht unter MyPets stehen würden sondern einen eigenen Tab erhalten.
|
||||
→ Umgesetzt (Plan: `~/.claude/plans/mobile-settings-tab-extraction.md`, Gitea-Issue #36, Branch `feature/settings-header-icon`). Statt eines 5. Bottom-Tabs (ursprünglich vorgeschlagene Variante, siehe Mockup `mobile-settings-tab-mockup.png` im Plan-Ordner) wurde ein **Zahnrad-Icon im Header** gewählt, neben der Mailbox — auf Wunsch des Users, da Settings realistisch nur 1–2× im Halbjahr geöffnet wird und ein permanenter 5. Tab dafür unnötig Platz kostet (Mockup: `mobile-settings-header-icon-mockup.png`). Neuer Screen `app/(app)/settings.tsx` (Stack-Push wie "Suche", kein Modal, keine Tab-Bar sichtbar währenddessen), erreichbar über `SettingsHeaderButton` in `(tabs)/_layout.tsx` — erscheint auf allen 4 Tabs neben `MessagesHeaderButton`. Die 5 Footer-Sektionen aus `pets.tsx` (Darstellung/Sprache/Tier-Vorschläge/Rechtliches/Logout) wurden nach `src/components/settings/*.tsx` extrahiert (DRY, je eigene Datei). `pets.tsx` ist jetzt reine Tierliste. Nebenbei behoben: `FooterErrorBoundary` schützt jetzt den **gesamten** Settings-Screen statt nur den Logout-Button (Lücke aus der Code-Review beim OTA-Indikator-Feature). Typecheck + Lint sauber, i18n-Parität geprüft.
|
||||
- [x] Ein user hat die meldung erhalten, "you do not have premission to access this pet" - kannst du mir erklären woher das kommen könnte ?
|
||||
→ Ursache im Code gefunden (Web-seitig, nicht Mobile): Der Fehlertext kommt exakt aus `assertPetOwnership` (`src/lib/assert-pet-ownership.ts:19`), dem gemeinsamen Ownership-Check, den praktisch jede pet-bezogene Mutation zuerst durchläuft. Web speicherte die "aktive Tier"-ID in `localStorage["active_pet_id"]` (`src/context/ActivePetContext.tsx`) — anders als Mobile (siehe TODO Neu6: `LogoutSection` ruft dort bewusst `clearActivePetId()` auf) wurde dieser Wert auf Web **nirgends geleert**, auch nicht beim Sign-out über Clerks `<UserButton />`. `ActivePetInitializer` setzt nur dann einen Wert, wenn `activePetId` leer ist — er prüfte nie, ob die gespeicherte ID überhaupt noch zum aktuell eingeloggten Owner gehört. Konkretes Szenario: Nutzer A meldet sich ab, Nutzer B meldet sich im selben Browser an (gemeinsamer Rechner, oder A erstellt ein zweites Konto) — B's Session lud weiterhin A's alte `active_pet_id`, und jede Aktion schickte diese fremde Pet-ID ans Backend, das sie zu Recht mit genau dieser Fehlermeldung ablehnte. Gleiche Bug-Klasse wie der bereits auf Mobile behobene Logout-Fund, hier aber nie für Web nachgezogen.
|
||||
Fix: `clearActivePetId()` in `src/context/ActivePetContext.tsx` ergänzt (Pendant zu Mobile). Da `ActivePetProvider` im Root-Layout **außerhalb** von `ClerkThemeProvider` sitzt (Clerk-Hooks dort nicht verfügbar — bewusste Architektur laut `(app)/layout.tsx`-Kommentar, um den Clerk-Bundle aus öffentlichen Routen rauszuhalten), kein direkter `useAuth()`-Zugriff im Context selbst möglich. Stattdessen neue `SignOutCleanup`-Komponente (`src/components/layout/SignOutCleanup.tsx`), mounted in `(app)/layout.tsx` neben `ActivePetInitializer` — beobachtet `useAuth().isSignedIn` und ruft `clearActivePetId()` beim Wechsel von `true` auf `false`, statt einen Klick-Handler an Clerks `<UserButton/>` zu hängen (die bietet dafür keinen sauberen Hook-Punkt). Deckt damit jeden Sign-out ab, unabhängig vom Auslöser. Typecheck + voller Testlauf (209/210, 1 skip wie zuvor) sauber, keine neuen Lint-Fehler.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, Pressable, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Sun, Moon, SunMoon } from "lucide-react-native";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
|
||||
/**
|
||||
* Darstellung (light/dark/auto) toggle. Extracted from pets.tsx's former
|
||||
* Settings footer into the dedicated Settings screen (app/(app)/settings.tsx)
|
||||
* — see that screen's doc comment for why Settings moved to a header icon
|
||||
* instead of staying bundled with "Meine Tiere".
|
||||
*/
|
||||
export 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 (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>{t("pets.appearanceTitle")}</Text>
|
||||
<View style={styles.row}>
|
||||
{appearanceOptions.map((option) => {
|
||||
const active = preference === option.value;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<Pressable
|
||||
key={option.label}
|
||||
style={[styles.option, active && styles.optionActive]}
|
||||
onPress={() => setPreference(option.value)}
|
||||
>
|
||||
<Icon color={active ? colors.pureWhite : colors.ink} size={18} />
|
||||
<Text style={[styles.optionText, active && styles.optionTextActive]}>{option.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
row: { flexDirection: "row", gap: 8 },
|
||||
option: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.hairlineGray,
|
||||
backgroundColor: colors.pureWhite,
|
||||
},
|
||||
optionActive: { backgroundColor: colors.marigoldOrange, borderColor: colors.marigoldOrange },
|
||||
optionText: { ...typography.body, fontSize: 13, color: colors.ink },
|
||||
optionTextActive: { color: colors.pureWhite },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { Text } from "react-native";
|
||||
import * as Sentry from "@sentry/react-native";
|
||||
|
||||
/**
|
||||
* Safety net for the Settings screen's content. `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 whole screen with
|
||||
* no visible detail. Cheap enough to leave in permanently.
|
||||
*
|
||||
* Wraps the *entire* Settings screen now, not just the logout button like it
|
||||
* did in pets.tsx before this screen existed — a code-review pass during the
|
||||
* OTA-indicator feature (see mobile/found.md) found that the narrower
|
||||
* wrapping left AppearanceSection/LegalSection/etc. unprotected, since they
|
||||
* also touch native-module-backed state (theme, expo-updates).
|
||||
*/
|
||||
export 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 (
|
||||
<Text style={{ color: "#dc2626", padding: 16 }}>Fehler beim Laden der Einstellungen: {this.state.error.message}</Text>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { LanguageSwitcher } from "../LanguageSwitcher";
|
||||
import type { ColorTokens } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function LanguageSection() {
|
||||
const { t } = useTranslation();
|
||||
const { colors } = useTheme();
|
||||
const styles = useMemo(() => makeStyles(colors), [colors]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Text style={styles.sectionTitle}>{t("pets.languageTitle")}</Text>
|
||||
<LanguageSwitcher />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, Pressable, Linking, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Constants from "expo-constants";
|
||||
import * as Updates from "expo-updates";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { env } from "../../lib/env";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
|
||||
/**
|
||||
* 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. The whole Settings screen is
|
||||
* wrapped in FooterErrorBoundary (see app/(app)/settings.tsx), so a throw here would degrade to that
|
||||
* fallback rather than crash the app — this try/catch is a second, cheaper line of defense that avoids
|
||||
* losing the rest of the screen over just this one line.
|
||||
* 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.
|
||||
*/
|
||||
export 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 (
|
||||
<View style={styles.section}>
|
||||
{links.map((link) => (
|
||||
<Pressable key={link.path} onPress={() => Linking.openURL(`${env.apiUrl}/${link.path}`)}>
|
||||
<Text style={styles.legalLink}>{link.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Text style={styles.versionText}>{t("pets.versionLabel", { version, ota })}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
section: { gap: 12, alignItems: "center" },
|
||||
legalLink: { ...typography.body, fontSize: 13, color: colors.marigoldDeep },
|
||||
versionText: { ...typography.label, textTransform: "none", letterSpacing: 0, marginTop: 4 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Alert } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@clerk/expo";
|
||||
import { useActivePet } from "../../context/ActivePetContext";
|
||||
import { Button } from "../Button";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function LogoutSection() {
|
||||
const { t } = useTranslation();
|
||||
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 <Button title={t("pets.logoutButton")} variant="outline" onPress={confirmLogout} />;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from "react";
|
||||
import { View, Text, Switch, StyleSheet } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "../../api/trpc";
|
||||
import { useTheme } from "../../context/ThemeContext";
|
||||
import { getTypography, type ColorTokens, type Typography } from "../../theme/tokens";
|
||||
import { sectionTitleStyle } from "./sectionTitleStyle";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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 (
|
||||
<View>
|
||||
<View style={styles.toggleRow}>
|
||||
<View style={styles.toggleText}>
|
||||
<Text style={styles.sectionTitle}>{t("pets.petSuggestionsTitle")}</Text>
|
||||
<Text style={styles.toggleLabel}>{t("pets.petSuggestionsLabel")}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={petSuggestionsEnabled}
|
||||
onValueChange={(value) => setPreferenceMutation.mutate({ petSuggestionsEnabled: value })}
|
||||
disabled={setPreferenceMutation.isPending}
|
||||
trackColor={{ true: colors.marigoldOrange }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function makeStyles(colors: ColorTokens) {
|
||||
const typography: Typography = getTypography(colors);
|
||||
return StyleSheet.create({
|
||||
sectionTitle: sectionTitleStyle(colors),
|
||||
toggleRow: { flexDirection: "row", alignItems: "center", gap: 12 },
|
||||
toggleText: { flex: 1 },
|
||||
toggleLabel: { ...typography.body, fontSize: 12, color: colors.quietGray, marginTop: 2 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getTypography, type ColorTokens } from "../../theme/tokens";
|
||||
|
||||
/**
|
||||
* Shared "section label" chrome (the small uppercase-ish title above a
|
||||
* Settings-screen section, e.g. "DARSTELLUNG"/"SPRACHE"). Extracted so
|
||||
* AppearanceSection/LanguageSection/PetSuggestionsSection can't drift out of
|
||||
* sync on this — they all had the exact same style object copy-pasted
|
||||
* before this file existed (found during code review of the Settings-screen
|
||||
* extraction, see mobile/found.md).
|
||||
*/
|
||||
export function sectionTitleStyle(colors: ColorTokens) {
|
||||
const typography = getTypography(colors);
|
||||
return { ...typography.label, textTransform: "none" as const, letterSpacing: 0, marginBottom: 10 };
|
||||
}
|
||||
@@ -304,6 +304,7 @@
|
||||
"comments": "Kommentare",
|
||||
"conversation": "Nachricht",
|
||||
"search": "Suche",
|
||||
"settings": "Einstellungen",
|
||||
"newPost": "Neuer Beitrag",
|
||||
"newStory": "Neue Story",
|
||||
"newMilestone": "Neuer Meilenstein"
|
||||
|
||||
@@ -304,6 +304,7 @@
|
||||
"comments": "Comments",
|
||||
"conversation": "Message",
|
||||
"search": "Search",
|
||||
"settings": "Settings",
|
||||
"newPost": "New post",
|
||||
"newStory": "New story",
|
||||
"newMilestone": "New milestone"
|
||||
|
||||
Reference in New Issue
Block a user