Files
sofa/apps/native/src/components/ui/select-modal.tsx
T
ad0497ccd6 feat: add i18n with LingUI, Crowdin integration, and typed error codes (#16)
* feat: add i18n with LingUI, Crowdin integration, and typed error codes

- Add `@sofa/i18n` shared package with LingUI v5 (v6-ready config using
  `defineConfig` + `@lingui/format-po`), eager English + lazy-loaded
  fr/de/es/it/pt catalogs, `Intl`-based date/number format utilities,
  and test helpers
- Wire `@lingui/vite-plugin` + babel macro plugin for web, and
  `@lingui/metro-transformer` + babel config for native
- Add `I18nProvider` to both app roots with locale auto-detection
  (navigator.language / expo-localization) and persistence
  (localStorage / MMKV)
- Wrap all ~512 user-facing strings across web and native with LingUI
  macros (`<Trans>`, `useLingui`/`t`, `i18n._(msg`...`)`, `plural`)
- Add language switcher to Settings in both apps
- Add `@sofa/api/errors` with 13 typed `AppErrorCode` values and
  `appErrorData()` helper for contract `.errors()` schemas
- Update all oRPC contract error definitions with typed `data` fields;
  convert import procedure `throw new Error()` to `ORPCError` with codes
- Add per-app `error-messages.ts` utilities that map error codes to
  localized strings; update global `QueryCache.onError` handlers to stop
  leaking raw `error.message` to users
- Add `crowdin.yml` config and `.github/workflows/crowdin.yml` for
  automated source upload on merge and translation pull via dispatch
- Add `lingui.config.ts` at repo root with `bun run i18n:extract` and
  `bun run i18n:compile` convenience scripts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add Crowdin language mapping and ignore context file

* fix(i18n): address 20 localization quality issues

- Stop leaking raw error.message to users in change-password and
  account-section; use localized fallbacks instead
- Add typed error data to discover contract's .errors() schema
- Replace all manual English plural suffixes with LingUI plural() macro
  in episode counts, backup counts, star ratings, title/image counts
- Replace date-fns formatDistanceToNow and hardcoded date patterns with
  locale-aware formatDate/formatRelativeTime from @sofa/i18n/format
- Convert integration-configs from module-scope translations (frozen at
  import time) to lazy getIntegrationConfigs(i18n) function
- Combine split Trans fragments into single translatable units in
  backup-schedule retention sentence and stats-display period labels
- Localize TV media type badge and full episode accessibilityLabel
- Replace Android-incompatible Alert.alert language picker (max 3
  buttons) with zeego DropdownMenu
- Await async locale initialization before hiding splash screen so
  non-English users don't see English flash on cold start
- Add @lingui/core as direct native app dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(i18n): add Intl polyfills for Hermes, replace DropdownMenu language picker, and remove date-fns
- Add `@formatjs/intl-*` polyfills loaded at app startup in `intl-polyfills.ts` so Hermes on Android has full `Intl` support for locale-aware formatting
- Alias `@formatjs/icu-messageformat-parser` to its `no-parser` variant in metro config to reduce bundle size
- Replace the zeego `DropdownMenu` language picker in Settings with a new `SelectModal` component that works reliably on all platforms without the Android 3-button limit
- Replace `date-fns` `format`/`parseISO` in `person/[id].tsx` with `formatDate` from `@sofa/i18n/format`; remove `date-fns` from native and web `package.json`
- Refactor `StatusActionButton` to use a `StatusLabel` component with `<Trans>` rather than a `getStatusConfig(t)` factory so labels are always reactive to locale changes
- Fix crowdin workflow to use root `bun run i18n:compile` script instead of `cd packages/i18n && bun run compile`
- Wrap root layout `GestureHandlerRootView` in `SafeAreaProvider` and add `.catch` to locale-ready promise to prevent unhandled rejections blocking the splash screen

* fix(i18n): localize integration status helpers, fix stats-display Trans wrapping, and clean up SelectModal
- Localize `webhookStatus` and `listStatus` in `integration-card.tsx` using `i18n._(msg`...`)` so status strings are translated instead of always rendering in English
- Wrap "Movies {select}" and "Episodes {select}" in a single `<Trans>` in `stats-display.tsx` so the period selector element is embedded inside the translatable unit rather than concatenated outside it
- Fix locale activation order in Settings: close the modal first, then `activateLocale`, and only call `setPersistedLocale` on success to avoid persisting a locale that failed to load
- Remove the redundant `SafeAreaProvider`/`SafeAreaView` wrapper from `SelectModal` — the root layout already provides `SafeAreaProvider`
- Recompile all six `.po`/`.ts` catalogs to pick up new and updated message strings

* chore(i18n): crowdin sync

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 12:16:10 -04:00

90 lines
2.6 KiB
TypeScript

import { type Icon, IconCheck } from "@tabler/icons-react-native";
import { Modal, Pressable, View } from "react-native";
import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text";
import { ScaledIcon } from "./scaled-icon";
export interface SelectModalOption {
value: string;
label: string;
}
export interface SelectModalProps {
/** Label displayed on the trigger row */
label: string;
/** Icon displayed in the modal title */
icon?: Icon;
/** Currently selected value */
selection: string;
/** Available options */
options: SelectModalOption[];
/** Whether the modal is open */
open: boolean;
/** Called when the modal is opened or closed */
onOpenChange: (open: boolean) => void;
/** Called when an option is selected */
onSelect: (value: string) => void;
}
export function SelectModal({
label,
icon: Icon,
selection,
options,
open,
onOpenChange,
onSelect,
}: SelectModalProps) {
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const primaryColor = useCSSVariable("--color-primary") as string;
return (
<Modal
visible={open}
transparent
animationType="fade"
onRequestClose={() => onOpenChange(false)}
>
<Pressable
className="flex-1 items-center justify-center bg-black/60"
onPress={() => onOpenChange(false)}
>
<Pressable
className="mx-8 w-full max-w-sm overflow-hidden rounded-2xl bg-card"
onPress={(e) => e.stopPropagation()}
>
<View className="flex-row items-center border-border/50 border-b px-5 py-4">
{Icon && <ScaledIcon icon={Icon} size={20} color={mutedFgColor} />}
<Text className="ml-1.5 font-medium text-base text-foreground">
{label}
</Text>
</View>
{options.map((option) => {
const isSelected = option.value === selection;
return (
<Pressable
key={option.value}
className="flex-row items-center px-5 py-3.5 active:bg-primary/5"
onPress={() => {
onOpenChange(false);
onSelect(option.value);
}}
>
<Text
className={`flex-1 text-base ${isSelected ? "font-medium text-primary" : "text-foreground"}`}
>
{option.label}
</Text>
{isSelected && (
<IconCheck size={18} color={primaryColor} strokeWidth={2} />
)}
</Pressable>
);
})}
</Pressable>
</Pressable>
</Modal>
);
}