feat(native): internationalize tab bar labels and improve recently-viewed row

- Wrap tab bar trigger labels and stack screen titles with `useLingui()` / `t` so they translate correctly across all 6 supported locales
- Swap `IconDeviceTv` → `IconDeviceTvOld` in the recently-viewed badge to match the icon used elsewhere in the app
- Show the type icon inside the pill badge and display a department label (Actor, Director, Writer, etc.) for person results instead of the generic "Person" / "TV" label
- Suppress the subtitle row for person results (department is shown in the badge instead)
- Merge the separate iOS/Android `headerTitleStyle` variables in `TabStack` into a single shared style
- Mark `packages/i18n/src/po/*` as readonly/excluded in VS Code settings alongside `routeTree.gen.ts`
- Extract and compile updated PO/TS catalogs for all locales (de, en, es, fr, it, pt)
This commit is contained in:
2026-03-19 15:06:02 -04:00
parent fb4b521450
commit 78266cf202
23 changed files with 942 additions and 607 deletions
@@ -1,5 +1,8 @@
import { useLingui } from "@lingui/react/macro";
import { TabStack } from "@/components/navigation/tab-stack";
export default function ExploreLayout() {
return <TabStack title="Explore" />;
const { t } = useLingui();
return <TabStack title={t`Explore`} />;
}
@@ -1,5 +1,8 @@
import { useLingui } from "@lingui/react/macro";
import { TabStack } from "@/components/navigation/tab-stack";
export default function HomeLayout() {
return <TabStack title="Home" />;
const { t } = useLingui();
return <TabStack title={t`Home`} />;
}
@@ -1,5 +1,8 @@
import { useLingui } from "@lingui/react/macro";
import { TabStack } from "@/components/navigation/tab-stack";
export default function SearchLayout() {
return <TabStack />;
const { t } = useLingui();
return <TabStack title={t`Search`} />;
}
@@ -68,13 +68,7 @@ export default function SearchScreen() {
const keyExtractor = useCallback((item: SearchResultItem) => `${item.type}-${item.id}`, []);
return (
<View className="bg-background flex-1">
<Stack.Header
transparent={false}
style={{ backgroundColor: "#000" }}
largeStyle={{ backgroundColor: "#000" }}
/>
<Stack.Screen.Title>Search</Stack.Screen.Title>
<View collapsable={false} className="bg-background flex-1">
<Stack.SearchBar
placeholder={t`Search movies, shows, people...`}
onChangeText={(e) => setQuery(e.nativeEvent.text)}
@@ -1,5 +1,8 @@
import { useLingui } from "@lingui/react/macro";
import { TabStack } from "@/components/navigation/tab-stack";
export default function SettingsLayout() {
return <TabStack title="Settings" />;
const { t } = useLingui();
return <TabStack title={t`Settings`} />;
}
@@ -58,8 +58,8 @@ import { activateLocale, type SupportedLocale } from "@sofa/i18n";
import { LOCALE_INFO } from "@sofa/i18n/locales";
const settingsContentContainerStyle = {
paddingTop: 8,
paddingBottom: 32,
paddingTop: 12,
paddingBottom: 24,
paddingHorizontal: 16,
};
@@ -535,17 +535,17 @@ export default function SettingsScreen() {
<Animated.View entering={FadeInDown.duration(300).delay(450)} className="mt-3 items-center">
<Pressable
onPress={() => Linking.openURL("https://github.com/jakejarvis/sofa")}
className="flex-row items-center gap-1.5 active:opacity-70"
className="flex-row items-center gap-1 active:opacity-70"
>
<ScaledIcon icon={IconBrandGithub} size={14} color={mutedFgColor} />
<Text className="text-muted-foreground text-xs">GitHub</Text>
<Text className="text-muted-foreground text-xs">jakejarvis/sofa</Text>
</Pressable>
</Animated.View>
{/* TMDB Attribution */}
<Animated.View
entering={FadeInDown.duration(300).delay(500)}
className="mt-4 items-center gap-2"
className="mt-5 items-center gap-2"
>
<Pressable
onPress={() => Linking.openURL("https://www.themoviedb.org/")}
@@ -553,8 +553,8 @@ export default function SettingsScreen() {
>
<TmdbLogo height={12} />
<Text
maxFontSizeMultiplier={1.0}
className="text-muted-foreground text-center text-xs leading-relaxed"
maxFontSizeMultiplier={1.2}
className="text-muted-foreground text-center text-[10px] leading-relaxed"
>
<Trans>This product uses the TMDB API but is not endorsed or certified by TMDB.</Trans>
</Text>
+3 -10
View File
@@ -38,14 +38,9 @@ enableFreeze(true);
initialize();
const localeReady = initLocale();
export const unstable_settings = {
initialRouteName: "(tabs)",
};
const changePasswordOptions =
process.env.EXPO_OS === "ios"
? {
headerShown: true,
presentation: "formSheet" as const,
sheetAllowedDetents: "fitToContents" as const,
sheetGrabberVisible: true,
@@ -54,7 +49,6 @@ const changePasswordOptions =
headerBlurEffect: "none" as const,
}
: {
headerShown: true,
presentation: "modal" as const,
headerLargeTitle: false,
headerTransparent: false,
@@ -89,7 +83,7 @@ function AppContent() {
// distinct ID, but only when the resolved state is actually enabled
// (respects both ATT result and the user's settings override).
if (enabled && posthog) {
const advertisingId = await getAdvertisingId();
const advertisingId = getAdvertisingId();
if (advertisingId) {
posthog.identify(advertisingId);
}
@@ -132,13 +126,12 @@ function AppContent() {
<ServerUnreachableBanner />
<Stack
screenOptions={{
headerShown: false,
contentStyle,
animation: "slide_from_right",
headerShown: false,
}}
>
<Stack.Protected guard={!session}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(auth)" options={{ navigationBarHidden: true, animation: "fade" }} />
</Stack.Protected>
<Stack.Protected guard={!!session}>
@@ -1,8 +1,10 @@
import { useLingui } from "@lingui/react/macro";
import * as Haptics from "expo-haptics";
import { NativeTabs } from "expo-router/unstable-native-tabs";
import { useCSSVariable } from "uniwind";
export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean }) {
const { t } = useLingui();
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
@@ -23,20 +25,20 @@ export function NativeTabBar({ showSettingsBadge }: { showSettingsBadge: boolean
})}
>
<NativeTabs.Trigger name="(home)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Label>{t`Home`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="house.fill" md="home" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(explore)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Label>{t`Explore`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="safari" md="explore" />
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(settings)" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Label>{t`Settings`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="gear" md="settings" />
{showSettingsBadge ? <NativeTabs.Trigger.Badge>!</NativeTabs.Trigger.Badge> : null}
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(search)" role="search" disableTransparentOnScrollEdge>
<NativeTabs.Trigger.Label>Search</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Label>{t`Search`}</NativeTabs.Trigger.Label>
<NativeTabs.Trigger.Icon sf="magnifyingglass" md="search" />
</NativeTabs.Trigger>
</NativeTabs>
@@ -8,11 +8,8 @@ export function TabStack({ title, children }: { title?: string; children?: React
const contentStyle = useResolveClassNames("bg-background");
const tintColor = useCSSVariable("--color-primary") as string;
const backgroundColor = useCSSVariable("--color-background") as string;
const iosHeaderLargeTitleStyle = useResolveClassNames("font-display text-foreground");
const iosHeaderTitleStyle = useResolveClassNames("font-display text-foreground text-lg");
const androidHeaderTitleStyle = useResolveClassNames(
"text-foreground font-sans text-lg font-semibold",
);
const headerTitleStyle = useResolveClassNames("font-display text-foreground text-xl");
const headerLargeTitleStyle = useResolveClassNames("font-display text-foreground");
if (process.env.EXPO_OS === "ios") {
return (
@@ -41,8 +38,8 @@ export function TabStack({ title, children }: { title?: string; children?: React
/>
<Stack.Screen.Title
large
style={iosHeaderTitleStyle as Record<string, unknown>}
largeStyle={iosHeaderLargeTitleStyle as Record<string, unknown>}
style={headerTitleStyle as Record<string, unknown>}
largeStyle={headerLargeTitleStyle as Record<string, unknown>}
>
{title}
</Stack.Screen.Title>
@@ -71,7 +68,7 @@ export function TabStack({ title, children }: { title?: string; children?: React
shadowColor: "transparent",
}}
/>
<Stack.Screen.Title style={androidHeaderTitleStyle as Record<string, unknown>}>
<Stack.Screen.Title style={headerTitleStyle as Record<string, unknown>}>
{title}
</Stack.Screen.Title>
</Stack.Screen>
@@ -1,5 +1,5 @@
import { useLingui } from "@lingui/react/macro";
import { IconDeviceTv, IconMovie, IconUser } from "@tabler/icons-react-native";
import { IconDeviceTvOld, IconMovie, IconUser } from "@tabler/icons-react-native";
import { View } from "react-native";
import { useCSSVariable } from "uniwind";
@@ -9,23 +9,29 @@ import type { RecentlyViewedItem } from "@/lib/recently-viewed";
const TypeIcon = {
movie: IconMovie,
tv: IconDeviceTv,
tv: IconDeviceTvOld,
person: IconUser,
} as const;
function getTypeLabel(t: (strings: TemplateStringsArray, ...values: unknown[]) => string) {
return {
movie: t`Movie`,
tv: t`TV`,
person: t`Person`,
} as const;
}
export function RecentlyViewedRowContent({ item }: { item: RecentlyViewedItem }) {
const { t } = useLingui();
const mutedForeground = useCSSVariable("--color-muted-foreground") as string;
const Icon = TypeIcon[item.type];
const typeLabel: Record<string, string> = {
movie: t`Movie`,
tv: t`TV`,
person: t`Person`,
};
const departmentLabels: Record<string, string> = {
Acting: t`Actor`,
Directing: t`Director`,
Writing: t`Writer`,
Production: t`Producer`,
Editing: t`Editor`,
};
return (
<View className="flex-row items-center">
<View
@@ -56,12 +62,15 @@ export function RecentlyViewedRowContent({ item }: { item: RecentlyViewedItem })
{item.title}
</Text>
<View className="mt-1 flex-row items-center gap-2">
<View className="bg-secondary rounded-full px-2 py-0.5">
<View className="bg-secondary flex-row items-center gap-1 rounded-full px-2 py-0.5">
<Icon size={12} color={mutedForeground} />
<Text maxFontSizeMultiplier={1.0} className="text-muted-foreground text-xs">
{getTypeLabel(t)[item.type]}
{item.type === "person"
? departmentLabels[item.subtitle ?? ""]
: typeLabel[item.type]}
</Text>
</View>
{item.subtitle ? (
{item.type !== "person" && item.subtitle ? (
<Text className="text-muted-foreground text-xs">{item.subtitle}</Text>
) : null}
</View>