diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3d28a68 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +.agents/skills/ diff --git a/app.config.ts b/app.config.ts index d01170a..0a954a4 100644 --- a/app.config.ts +++ b/app.config.ts @@ -61,6 +61,9 @@ const config: ExpoConfig = { }, predictiveBackGestureEnabled: false, }, + locales: { + en: "./assets/locales/app-meta-en.json", + }, plugins: [ ...(IS_DEV ? ["expo-dev-client"] : []), "expo-router", @@ -69,6 +72,15 @@ const config: ExpoConfig = { "expo-image", "expo-secure-store", "expo-sharing", + [ + "expo-localization", + { + supportedLocales: { + ios: ["zh-Hans", "en"], + android: ["zh-Hans", "en"], + }, + }, + ], [ "expo-splash-screen", { diff --git a/app/(pages)/about/index.tsx b/app/(pages)/about/index.tsx index d661735..037fe29 100644 --- a/app/(pages)/about/index.tsx +++ b/app/(pages)/about/index.tsx @@ -18,12 +18,14 @@ import { MenuGroup, MenuItem } from "@/components/ui/menu-item"; import { IS_DEV } from "@/constants/is-dev"; import { Colors } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { useT } from "@/lib/i18n"; import { useUpdateStore } from "@/store/update"; const icon = require("@/assets/images/icon.png"); const uniLabel = require("@/assets/images/icon_uni_label.svg"); export default function AboutScreen() { + const t = useT(); const scheme = useColorScheme(); const isDark = scheme === "dark"; const version = Constants.expoConfig?.version ?? "N/A"; @@ -33,14 +35,17 @@ export default function AboutScreen() { const checking = useUpdateStore((s) => s.checking); const check = useUpdateStore((s) => s.check); - const copyToClipboard = useCallback(async (label: string, value: string) => { - await Clipboard.setStringAsync(value); - Toast.show({ - type: "success", - text1: `已复制${label}`, - position: "bottom", - }); - }, []); + const copyToClipboard = useCallback( + async (label: string, value: string) => { + await Clipboard.setStringAsync(value); + Toast.show({ + type: "success", + text1: t("about.copied", { label }), + position: "bottom", + }); + }, + [t], + ); const handleCheckUpdate = useCallback(async () => { await check(); @@ -49,8 +54,8 @@ export default function AboutScreen() { if (updated) { Toast.show({ type: "info", - text1: "发现新版本", - text2: `v${latest} 可用,点击下载`, + text1: t("about.newVersionFound"), + text2: t("about.newVersionTip", { v: latest ?? "" }), position: "bottom", onPress: () => { if (Platform.OS === "ios") { @@ -67,15 +72,15 @@ export default function AboutScreen() { } else { Toast.show({ type: "success", - text1: "当前已是最新版本", + text1: t("about.upToDate"), position: "bottom", }); } - }, [check]); + }, [check, t]); return ( <> - + - 掌上吾理 + {t("about.appName")} {version} @@ -94,30 +99,32 @@ export default function AboutScreen() { - + copyToClipboard("版本号", version)} + onPress={() => copyToClipboard(t("about.version"), version)} /> copyToClipboard("Commit", commit)} + onPress={() => copyToClipboard(t("about.commit"), commit)} /> - + - + Linking.openURL("https://iwut.tokenteam.net")} /> Linking.openURL("https://github.com/tokenteam/iwut")} /> diff --git a/app/(pages)/settings/appearance.tsx b/app/(pages)/settings/appearance.tsx index b187f3c..6685024 100644 --- a/app/(pages)/settings/appearance.tsx +++ b/app/(pages)/settings/appearance.tsx @@ -7,44 +7,80 @@ import { IconSymbol } from "@/components/ui/icon-symbol"; import { MenuGroup, MenuItem } from "@/components/ui/menu-item"; import { Colors } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { type Lang, useT } from "@/lib/i18n"; +import { useSettingsStore } from "@/store/settings"; import { type ThemeMode, useThemeStore } from "@/store/theme"; -const themeOptions: { mode: ThemeMode; icon: string; label: string }[] = [ - { mode: "system", icon: "brightness-auto", label: "跟随系统" }, - { mode: "light", icon: "light-mode", label: "浅色模式" }, - { mode: "dark", icon: "dark-mode", label: "深色模式" }, -]; - -const themeLabelMap: Record = { - system: "跟随系统", - light: "浅色模式", - dark: "深色模式", -}; - export default function AppearanceScreen() { + const t = useT(); const scheme = useColorScheme(); const iconColor = Colors[scheme === "dark" ? "dark" : "light"].icon; const tintColor = Colors[scheme === "dark" ? "dark" : "light"].tint; const themeMode = useThemeStore((s) => s.themeMode); const setThemeMode = useThemeStore((s) => s.setThemeMode); + const language = useSettingsStore((s) => s.language); + const setLanguage = useSettingsStore((s) => s.setLanguage); const [showThemePicker, setShowThemePicker] = useState(false); + const [showLangPicker, setShowLangPicker] = useState(false); + + const themeOptions: { mode: ThemeMode; icon: string; label: string }[] = [ + { + mode: "system", + icon: "brightness-auto", + label: t("appearance.themeSystem"), + }, + { mode: "light", icon: "light-mode", label: t("appearance.themeLight") }, + { mode: "dark", icon: "dark-mode", label: t("appearance.themeDark") }, + ]; + + const themeLabelMap: Record = { + system: t("appearance.themeSystem"), + light: t("appearance.themeLight"), + dark: t("appearance.themeDark"), + }; + + const langOptions: { lang: Lang; icon: string; label: string }[] = [ + { + lang: "system", + icon: "brightness-auto", + label: t("appearance.langSystem"), + }, + { lang: "zh", icon: "translate", label: t("appearance.langZh") }, + { lang: "en", icon: "translate", label: t("appearance.langEn") }, + ]; + + const langLabelMap: Record = { + system: t("appearance.langSystem"), + zh: t("appearance.langZh"), + en: t("appearance.langEn"), + }; return ( <> - + - + setShowThemePicker(true)} /> + + + setShowLangPicker(true)} + /> + ))} + + setShowLangPicker(false)} + > + {langOptions.map((opt) => ( + { + setLanguage(opt.lang); + setShowLangPicker(false); + }} + > + + + {opt.label} + + {language === opt.lang && ( + + )} + + ))} + ); } diff --git a/app/(pages)/settings/calendar.tsx b/app/(pages)/settings/calendar.tsx index a5c1e76..0381713 100644 --- a/app/(pages)/settings/calendar.tsx +++ b/app/(pages)/settings/calendar.tsx @@ -13,8 +13,10 @@ import Toast from "react-native-toast-message"; import { BottomSheet } from "@/components/ui/bottom-sheet"; import { IconSymbol } from "@/components/ui/icon-symbol"; import { MenuGroup, MenuItem } from "@/components/ui/menu-item"; +import { BUILTIN_PALETTE_NAME_KEYS } from "@/constants/course-palettes"; import { Colors } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { useT } from "@/lib/i18n"; import { deleteAppCalendar, syncCoursesToCalendar, @@ -34,6 +36,7 @@ function isCropCancelled(error: unknown) { } export default function CalendarSettingsScreen() { + const t = useT(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const scheme = useColorScheme(); const isDark = scheme === "dark"; @@ -70,14 +73,14 @@ export default function CalendarSettingsScreen() { setCalendarSync(true); Toast.show({ type: "success", - text1: "已同步到系统日历", - text2: `共写入 ${result.count} 条课程数据`, + text1: t("calendarSet.syncedToast"), + text2: t("calendarSet.syncedSub", { n: result.count }), position: "bottom", }); } else { Toast.show({ type: "error", - text1: "同步失败", + text1: t("calendarSet.syncFailed"), text2: result.error, position: "bottom", }); @@ -87,7 +90,7 @@ export default function CalendarSettingsScreen() { setCalendarSync(false); Toast.show({ type: "success", - text1: "已从系统日历移除", + text1: t("calendarSet.syncRemoved"), position: "bottom", }); } @@ -129,8 +132,8 @@ export default function CalendarSettingsScreen() { : undefined, format: "jpeg", compressImageQuality: 0.85, - cancelButtonText: "取消", - doneButtonText: "完成", + cancelButtonText: t("calendarSet.bgPickerCancel"), + doneButtonText: t("calendarSet.bgPickerDone"), }); tempCroppedUri = cropped.path; @@ -143,15 +146,15 @@ export default function CalendarSettingsScreen() { setBackgroundImageUri(dest.uri); Toast.show({ type: "success", - text1: "背景已设置", + text1: t("calendarSet.bgSetSuccess"), position: "bottom", }); } catch (error) { if (isCropCancelled(error)) return; Toast.show({ type: "error", - text1: "背景设置失败", - text2: "图片裁剪或保存时出现问题", + text1: t("calendarSet.bgSetFailed"), + text2: t("calendarSet.bgSetFailedSub"), position: "bottom", }); } finally { @@ -169,31 +172,42 @@ export default function CalendarSettingsScreen() { await deleteOldBg(backgroundImageUri); setBackgroundImageUri(null); setShowBgPicker(false); - Toast.show({ type: "success", text1: "背景已移除", position: "bottom" }); + Toast.show({ + type: "success", + text1: t("calendarSet.bgRemoved"), + position: "bottom", + }); }; + const paletteKey = BUILTIN_PALETTE_NAME_KEYS[colorPalette.name]; + const paletteDisplayName = paletteKey ? t(paletteKey) : colorPalette.name; + return ( <> - + - + 0 ? `${courseCount} 门课` : "暂无课程"} + label={t("calendarSet.courseManage")} + value={ + courseCount > 0 + ? t("calendarSet.courseCount", { n: courseCount }) + : t("calendarSet.noCourses") + } href="/settings/course/manage" /> - + @@ -202,17 +216,17 @@ export default function CalendarSettingsScreen() { } /> - + - + setShowBgPicker(true)} /> @@ -248,7 +266,7 @@ export default function CalendarSettingsScreen() { setShowBgPicker(false)} - title="课表背景" + title={t("calendarSet.bgSheetTitle")} > - 从相册选择 + {t("calendarSet.bgPickFromAlbum")} {backgroundImageUri && ( @@ -265,7 +283,9 @@ export default function CalendarSettingsScreen() { onPress={handleRemoveBg} > - 移除背景 + + {t("calendarSet.bgRemove")} + )} diff --git a/app/(pages)/settings/course/add.tsx b/app/(pages)/settings/course/add.tsx index e0d140f..a492068 100644 --- a/app/(pages)/settings/course/add.tsx +++ b/app/(pages)/settings/course/add.tsx @@ -15,9 +15,18 @@ import { import Toast from "react-native-toast-message"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { type TKey, useT } from "@/lib/i18n"; import { type Course, useCourseStore } from "@/store/course"; -const DAY_OPTIONS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]; +const DAY_KEYS: TKey[] = [ + "schedule.weekday.mon", + "schedule.weekday.tue", + "schedule.weekday.wed", + "schedule.weekday.thu", + "schedule.weekday.fri", + "schedule.weekday.sat", + "schedule.weekday.sun", +]; const MAX_WEEK = 20; const MAX_SECTION = 16; @@ -80,15 +89,21 @@ function weeksToRanges(weeks: Set): [number, number][] { return ranges; } -function formatWeeks(weeks: Set): string { - if (weeks.size === 0) return "未选择"; +function formatWeeks( + weeks: Set, + notSelectedLabel: string, + weeksSuffix: string, +): string { + if (weeks.size === 0) return notSelectedLabel; const ranges = weeksToRanges(weeks); return ( - ranges.map(([s, e]) => (s === e ? `${s}` : `${s}-${e}`)).join(", ") + " 周" + ranges.map(([s, e]) => (s === e ? `${s}` : `${s}-${e}`)).join(", ") + + weeksSuffix ); } export default function AddEditCourseScreen() { + const t = useT(); const router = useRouter(); const { name: editName } = useLocalSearchParams<{ name?: string }>(); const isEdit = !!editName; @@ -96,6 +111,10 @@ export default function AddEditCourseScreen() { const scheme = useColorScheme(); const isDark = scheme === "dark"; + const dayOptions = DAY_KEYS.map((k) => t(k)); + const weeksSuffix = t("courseAdd.weeksUnit"); + const notSelectedLabel = t("courseAdd.weeksNotSelected"); + const courses = useCourseStore((s) => s.courses); const addCourse = useCourseStore((s) => s.addCourse); const removeCoursesByName = useCourseStore((s) => s.removeCoursesByName); @@ -142,7 +161,7 @@ export default function AddEditCourseScreen() { if (slots.length <= 1) { Toast.show({ type: "error", - text1: "至少保留一个时段", + text1: t("courseAdd.minSlotRequired"), position: "bottom", }); return; @@ -156,7 +175,7 @@ export default function AddEditCourseScreen() { return prev; }); }, - [slots.length], + [slots.length, t], ); const inputBg = isDark ? "#262626" : "#f5f5f5"; @@ -172,7 +191,7 @@ export default function AddEditCourseScreen() { if (!trimmedName) { Toast.show({ type: "error", - text1: "请输入课程名称", + text1: t("courseAdd.needCourseName"), position: "bottom", }); return; @@ -182,7 +201,7 @@ export default function AddEditCourseScreen() { if (slot.weeks.size === 0) { Toast.show({ type: "error", - text1: `时段 ${i + 1} 未选择周次`, + text1: t("courseAdd.slotNoWeeks", { n: i + 1 }), position: "bottom", }); setExpandedIndex(i); @@ -191,7 +210,7 @@ export default function AddEditCourseScreen() { if (slot.sectionEnd < slot.sectionStart) { Toast.show({ type: "error", - text1: `时段 ${i + 1} 节次范围有误`, + text1: t("courseAdd.slotInvalidRange", { n: i + 1 }), position: "bottom", }); setExpandedIndex(i); @@ -222,7 +241,7 @@ export default function AddEditCourseScreen() { Toast.show({ type: "success", - text1: isEdit ? "课程已更新" : "课程已添加", + text1: isEdit ? t("courseAdd.courseUpdated") : t("courseAdd.courseAdded"), position: "bottom", }); router.back(); @@ -230,7 +249,11 @@ export default function AddEditCourseScreen() { return ( <> - + - 课程名称 + {t("courseAdd.courseName")} - 教师 + {t("courseAdd.teacher")} ))} @@ -315,7 +342,7 @@ export default function AddEditCourseScreen() { > - 添加时段 + {t("courseAdd.addSlot")} @@ -323,7 +350,9 @@ export default function AddEditCourseScreen() { onPress={handleSave} className="items-center rounded-xl bg-blue-500 py-3.5 active:bg-blue-600" > - 保存 + + {t("common.save")} + @@ -347,6 +376,10 @@ function SlotCard({ inputColor, placeholderColor, cardBg, + dayOptions, + t, + notSelectedLabel, + weeksSuffix, }: { slot: TimeSlot; index: number; @@ -363,6 +396,10 @@ function SlotCard({ inputColor: string; placeholderColor: string; cardBg: string; + dayOptions: string[]; + t: ReturnType; + notSelectedLabel: string; + weeksSuffix: string; }) { const toggleWeek = (w: number) => { const next = new Set(slot.weeks); @@ -381,7 +418,21 @@ function SlotCard({ onUpdate({ weeks: new Set() }); }; - const summary = `${DAY_OPTIONS[slot.day - 1]} 第${slot.sectionStart}-${slot.sectionEnd}节${slot.room ? ` ${slot.room}` : ""} | ${formatWeeks(slot.weeks)}`; + const weeksLabel = formatWeeks(slot.weeks, notSelectedLabel, weeksSuffix); + const summary = slot.room + ? t("courseAdd.slotSummaryWithRoom", { + weekday: dayOptions[slot.day - 1], + start: slot.sectionStart, + end: slot.sectionEnd, + room: slot.room, + weeks: weeksLabel, + }) + : t("courseAdd.slotSummary", { + weekday: dayOptions[slot.day - 1], + start: slot.sectionStart, + end: slot.sectionEnd, + weeks: weeksLabel, + }); return ( - 星期 + {t("courseAdd.weekday")} - {DAY_OPTIONS.map((label, i) => { + {dayOptions.map((label, i) => { const selected = slot.day === i + 1; return ( - 教室 + {t("courseAdd.room")} onUpdate({ room: v })} - placeholder="可选" + placeholder={t("courseAdd.roomPlaceholder")} placeholderTextColor={placeholderColor} style={{ fontSize: 15, @@ -494,14 +545,18 @@ function SlotCard({ }} > - 周次(可多选) + {t("courseAdd.weeks")} - 全选 + + {t("courseAdd.selectAll")} + - 清空 + + {t("courseAdd.clear")} + @@ -544,7 +599,7 @@ function SlotCard({ {/* 节次范围 */} - 节次范围 + {t("courseAdd.sectionRange")} - 第 {slot.sectionStart} - {slot.sectionEnd} 节 + {t("courseAdd.currentRange", { + start: slot.sectionStart, + end: slot.sectionEnd, + })} { + const dayLabels = getDayLabels(); const map = new Map< string, { @@ -39,13 +42,17 @@ export default function ManageCourseScreen() { map.set(c.name, { name: c.name, teacher: c.teacher, - summary: `${DAY_LABELS[c.day - 1]} 第${c.sectionStart}-${c.sectionEnd}节`, + summary: t("courseManage.summaryWithDay", { + weekday: dayLabels[c.day - 1], + start: c.sectionStart, + end: c.sectionEnd, + }), count: 1, imported: c.source === "imported", }); } return [...map.values()]; - }, [courses]); + }, [courses, t]); const handleDelete = (name: string) => setDeleteTarget(name); @@ -54,7 +61,7 @@ export default function ManageCourseScreen() { removeCoursesByName(deleteTarget); Toast.show({ type: "success", - text1: `已删除「${deleteTarget}」`, + text1: t("courseManage.deleted", { name: deleteTarget }), position: "bottom", }); setDeleteTarget(null); @@ -62,7 +69,7 @@ export default function ManageCourseScreen() { return ( <> - + - 添加课程 + {t("courseManage.addCourse")} @@ -100,7 +107,7 @@ export default function ManageCourseScreen() { {item.imported && ( - 导入 + {t("courseManage.importedTag")} )} @@ -108,7 +115,9 @@ export default function ManageCourseScreen() { {item.teacher ? `${item.teacher} · ` : ""} {item.summary} - {item.count > 1 ? ` 等 ${item.count} 个时段` : ""} + {item.count > 1 + ? t("courseManage.moreSlots", { n: item.count }) + : ""} - 暂无课程,点击上方按钮添加 + {t("courseManage.noCoursesHint")} )} @@ -145,7 +154,9 @@ export default function ManageCourseScreen() { className="mt-4 items-center rounded-xl bg-white py-3.5 active:bg-neutral-50 dark:bg-neutral-800 dark:active:bg-neutral-700" onPress={() => setClearVisible(true)} > - 清空课表 + + {t("courseManage.clearAll")} + )} @@ -153,9 +164,11 @@ export default function ManageCourseScreen() { setDeleteTarget(null)} - title="删除课程" - description={`确定要删除「${deleteTarget}」的所有时段吗?`} - confirmText="删除" + title={t("courseManage.deleteCourseTitle")} + description={t("courseManage.deleteCourseDesc", { + name: deleteTarget ?? "", + })} + confirmText={t("common.delete")} destructive onConfirm={confirmDelete} /> @@ -163,16 +176,16 @@ export default function ManageCourseScreen() { setClearVisible(false)} - title="清空课表" - description="确定要删除所有课程吗?此操作不可恢复。" - confirmText="清空" + title={t("courseManage.clearAllTitle")} + description={t("courseManage.clearAllDesc")} + confirmText={t("courseManage.clearAllConfirm")} destructive onConfirm={() => { setCourses([]); setClearVisible(false); Toast.show({ type: "success", - text1: "课表已清空", + text1: t("courseManage.cleared"), position: "bottom", }); }} diff --git a/app/(pages)/settings/course/palette.tsx b/app/(pages)/settings/course/palette.tsx index 0451325..382b410 100644 --- a/app/(pages)/settings/course/palette.tsx +++ b/app/(pages)/settings/course/palette.tsx @@ -8,20 +8,24 @@ import Toast from "react-native-toast-message"; import { BottomSheet } from "@/components/ui/bottom-sheet"; import { MenuGroup, MenuItem } from "@/components/ui/menu-item"; import { + BUILTIN_PALETTE_NAME_KEYS, BUILTIN_PALETTES, type ColorPalette, validateColorPalette, } from "@/constants/course-palettes"; +import { useT } from "@/lib/i18n"; import { useScheduleStore } from "@/store/schedule"; function PaletteRow({ palette, isActive, + displayName, onPress, onDelete, }: { palette: ColorPalette; isActive: boolean; + displayName: string; onPress: () => void; onDelete?: () => void; }) { @@ -39,7 +43,7 @@ function PaletteRow({ : "text-neutral-900 dark:text-neutral-100" }`} > - {palette.name} + {displayName} s.colorPalette); const setColorPalette = useScheduleStore((s) => s.setColorPalette); const customPalettes = useScheduleStore((s) => s.customPalettes); @@ -85,19 +90,28 @@ export default function PaletteScreen() { const [deleteTarget, setDeleteTarget] = useState(null); + const paletteName = (p: ColorPalette): string => { + const key = BUILTIN_PALETTE_NAME_KEYS[p.name]; + return key ? t(key) : p.name; + }; + const handleImport = async () => { try { const text = await Clipboard.getStringAsync(); if (!text.trim()) { - Toast.show({ type: "error", text1: "剪贴板为空", position: "bottom" }); + Toast.show({ + type: "error", + text1: t("palette.clipboardEmpty"), + position: "bottom", + }); return; } const data = JSON.parse(text); if (!validateColorPalette(data)) { Toast.show({ type: "error", - text1: "配色格式错误", - text2: "请检查 JSON 格式是否正确", + text1: t("palette.formatError"), + text2: t("palette.formatErrorSub"), position: "bottom", }); return; @@ -106,14 +120,14 @@ export default function PaletteScreen() { setColorPalette(data); Toast.show({ type: "success", - text1: `已导入配色方案:${data.name}`, + text1: t("palette.imported", { name: paletteName(data) }), position: "bottom", }); } catch { Toast.show({ type: "error", - text1: "配色格式错误", - text2: "无法解析 JSON", + text1: t("palette.formatError"), + text2: t("palette.parseError"), position: "bottom", }); } @@ -133,7 +147,7 @@ export default function PaletteScreen() { await Clipboard.setStringAsync(JSON.stringify(exported, null, 2)); Toast.show({ type: "success", - text1: "配色方案已复制到剪贴板", + text1: t("palette.exported"), position: "bottom", }); }; @@ -147,14 +161,14 @@ export default function PaletteScreen() { setDeleteTarget(null); Toast.show({ type: "success", - text1: `已删除「${deleteTarget}」`, + text1: t("palette.deleted", { name: deleteTarget }), position: "bottom", }); }; return ( <> - + setColorPalette(palette)} /> @@ -184,6 +199,7 @@ export default function PaletteScreen() { setColorPalette(palette)} onDelete={() => setDeleteTarget(palette.name)} /> @@ -192,17 +208,17 @@ export default function PaletteScreen() { )} - + @@ -211,10 +227,10 @@ export default function PaletteScreen() { setDeleteTarget(null)} - title="删除配色" + title={t("palette.deleteTitle")} > - 确定要删除「{deleteTarget}」吗? + {t("palette.deleteDesc", { name: deleteTarget ?? "" })} setDeleteTarget(null)} > - 取消 + {t("common.cancel")} - 确认删除 + + {t("palette.deleteConfirm")} + diff --git a/app/(pages)/settings/index.tsx b/app/(pages)/settings/index.tsx index 41f7799..641457d 100644 --- a/app/(pages)/settings/index.tsx +++ b/app/(pages)/settings/index.tsx @@ -24,6 +24,7 @@ import Toast from "react-native-toast-message"; import { BottomSheet } from "@/components/ui/bottom-sheet"; import { ConfirmSheet } from "@/components/ui/confirm-sheet"; import { MenuGroup, MenuItem } from "@/components/ui/menu-item"; +import { useT } from "@/lib/i18n"; import { reportError } from "@/lib/report"; import { registerBackgroundRefresh, @@ -36,6 +37,7 @@ import { useSettingsStore } from "@/store/settings"; const REMINDER_PRESETS = [15, 30, 60]; export default function SettingsScreen() { + const t = useT(); const hapticFeedback = useSettingsStore((s) => s.hapticFeedback); const setHapticFeedback = useSettingsStore((s) => s.setHapticFeedback); const openCourseOnLaunch = useSettingsStore((s) => s.openCourseOnLaunch); @@ -87,7 +89,7 @@ export default function SettingsScreen() { if (!val || val < 1 || val > 120) { Toast.show({ type: "error", - text1: "请输入 1-120 之间的数字", + text1: t("settings.reminderRangeError"), position: "bottom", }); return; @@ -121,14 +123,14 @@ export default function SettingsScreen() { Toast.show({ type: "success", - text1: "缓存已清除", + text1: t("settings.cacheCleared"), position: "bottom", }); } catch (e) { reportError(e, { module: "settings", action: "clear-cache" }); Toast.show({ type: "error", - text1: "清除失败", + text1: t("settings.clearCacheFailed"), position: "bottom", }); } finally { @@ -145,7 +147,7 @@ export default function SettingsScreen() { if (paths.length === 0) { Toast.show({ type: "info", - text1: "暂无日志", + text1: t("settings.exportNoLog"), position: "bottom", }); return; @@ -183,13 +185,13 @@ export default function SettingsScreen() { await Sharing.shareAsync(zipFile.uri, { UTI: "public.zip-archive", mimeType: "application/zip", - dialogTitle: "导出日志", + dialogTitle: t("settings.exportDialogTitle"), }); } catch (e) { reportError(e, { module: "settings", action: "export-logs" }); Toast.show({ type: "error", - text1: "导出失败", + text1: t("settings.exportFailed"), position: "bottom", }); } finally { @@ -199,16 +201,16 @@ export default function SettingsScreen() { return ( <> - + - + - + - 提前 {reminderMinutes} 分钟 + {t("settings.reminderTimeMins", { n: reminderMinutes })} } onPress={() => setReminderSheetVisible(true)} @@ -260,11 +262,11 @@ export default function SettingsScreen() { )} - + : undefined} onPress={() => setClearVisible(true)} @@ -272,7 +274,7 @@ export default function SettingsScreen() { : undefined} onPress={handleExportLogs} @@ -283,9 +285,9 @@ export default function SettingsScreen() { setClearVisible(false)} - title="清除缓存" - description="将清除缓存和临时数据,不会影响已保存的内容。" - confirmText="清除" + title={t("settings.clearCacheTitle")} + description={t("settings.clearCacheDesc")} + confirmText={t("settings.clearCacheConfirm")} destructive onConfirm={handleClearCache} /> @@ -293,21 +295,21 @@ export default function SettingsScreen() { setReminderSheetVisible(false)} - title="提醒时间" + title={t("settings.reminderTime")} > {REMINDER_PRESETS.map((mins) => ( handleReminderMinutesChange(mins)} /> ))} - 自定义 + {t("settings.custom")} - 分钟 + + {t("common.minutes")} + { clear(); setClearVisible(false); Toast.show({ type: "success", - text1: "账号已清除", + text1: t("wlan.accountCleared"), position: "bottom", }); }; return ( <> - + - + {hasSaved ? ( <> setClearVisible(true)} /> @@ -159,7 +161,7 @@ export default function WlanScreen() { )} @@ -169,10 +171,10 @@ export default function WlanScreen() { setSheetVisible(false)} - title="校园网账号" + title={t("wlan.sheetTitle")} > - 输入校园网账号和密码,保存后可一键连接。 + {t("wlan.sheetHint")} setSheetVisible(false)} > - 取消 + {t("wlan.cancel")} - 保存 + + {t("wlan.save")} + @@ -247,9 +251,9 @@ export default function WlanScreen() { setClearVisible(false)} - title="清除账号" - description="确定要清除已保存的校园网账号吗?" - confirmText="确认清除" + title={t("wlan.clearTitle")} + description={t("wlan.clearDesc")} + confirmText={t("wlan.clearConfirm")} destructive onConfirm={handleClear} /> diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index f9cc69c..8d8520f 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -6,6 +6,7 @@ import { type ColorValue } from "react-native"; import { Colors } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { useHaptics } from "@/hooks/use-haptics"; +import { useT } from "@/lib/i18n"; import { useSettingsStore } from "@/store/settings"; let hasLaunched = false; @@ -40,6 +41,7 @@ function TabIcon({ } export default function TabLayout() { + const t = useT(); const colorScheme = useColorScheme(); const theme = Colors[colorScheme === "dark" ? "dark" : "light"]; const haptic = useHaptics(); @@ -69,7 +71,7 @@ export default function TabLayout() { ( ( ( ( store.courses); const termStart = useCourseStore((store) => store.termStart); const lastImportType = useCourseStore((s) => s.lastImportType); @@ -150,7 +152,7 @@ export default function CourseScreen() { onPress={() => setShowWeekPicker(true)} > - 第 {week} 周 + {t("common.weekN", { n: week })} @@ -188,7 +190,10 @@ export default function CourseScreen() { marginTop: 1, }} > - {`${new Date().getMonth() + 1}月${new Date().getDate()}日`} + {t("common.monthDay", { + m: new Date().getMonth() + 1, + d: new Date().getDate(), + })} @@ -241,7 +246,7 @@ export default function CourseScreen() { color: isDark ? "#a3a3a3" : "#737373", }} > - 请先绑定智慧理工大账号 + {t("course.needBindTitle")} - 绑定后可自动从教务系统导入课表 + {t("course.needBindSub")} ({ @@ -274,7 +279,7 @@ export default function CourseScreen() { fontWeight: "500", }} > - 前往「我的」绑定 + {t("course.goBind")} @@ -298,7 +303,7 @@ export default function CourseScreen() { > - 研究生 + {t("course.master")} - 本科生 + {t("course.bachelor")} `第 ${i + 1} 周`, + items={Array.from({ length: MAX_WEEK }, (_, i) => + t("common.weekN", { n: i + 1 }), )} selectedIndex={week - 1} onSelect={(i) => setWeek(i + 1)} @@ -375,7 +379,7 @@ export default function CourseScreen() { setShowTypePicker(false)} - title="选择导入类型" + title={t("course.selectImportType")} > - 本科生 + {t("course.bachelor")} - 研究生 + {t("course.master")} diff --git a/app/(tabs)/function.tsx b/app/(tabs)/function.tsx index 52720f9..50e8268 100644 --- a/app/(tabs)/function.tsx +++ b/app/(tabs)/function.tsx @@ -16,66 +16,67 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { IS_DEV } from "@/constants/is-dev"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { type TKey, useT } from "@/lib/i18n"; type WebApp = { icon: React.ComponentProps["name"]; - label: string; + labelKey: TKey; color: string; uri: string; }; type Section = { - title: string; + titleKey: TKey; items: WebApp[]; }; const SECTIONS: Section[] = [ { - title: "学习工具", + titleKey: "fn.section.study", items: [ { icon: "book-outline", - label: "自习室查询", + labelKey: "fn.app.classroom", color: "#0d9488", uri: "https://classroom-iwut.tokenteam.net", }, { icon: "school-outline", - label: "教务系统", + labelKey: "fn.app.jwxt", color: "#8b5cf6", uri: "https://zhlgd.whut.edu.cn/tpass/login?service=https%3A%2F%2Fjwxt.whut.edu.cn%2Fjwapp%2Fsys%2Fhomeapp%2Findex.do%3FforceCas%3D1", }, { icon: "library-outline", - label: "图书馆管家", + labelKey: "fn.app.library", color: "#3b82f6", uri: "https://library-info-iwut.tokenteam.net", }, ], }, { - title: "生活服务", + titleKey: "fn.section.life", items: [ { icon: "card-outline", - label: "智寻卡片", + labelKey: "fn.app.card", color: "#10b981", uri: "https://cardcare-iwut.tokenteam.net", }, { icon: "flash-outline", - label: "电费查询", + labelKey: "fn.app.elec", color: "#eab308", uri: "https://zhlgd.whut.edu.cn/tpass/login?service=http://nyyzf.whut.edu.cn/MobileWebOnlineHall/#/", }, ], }, { - title: "校园资讯", + titleKey: "fn.section.info", items: [ { icon: "newspaper-outline", - label: "校园公告", + labelKey: "fn.app.campusNews", color: "#f97316", uri: "http://i.whut.edu.cn", }, @@ -88,6 +89,7 @@ function openWebApp(uri: string) { } export default function FunctionScreen() { + const t = useT(); const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; const { height } = useWindowDimensions(); @@ -107,7 +109,7 @@ export default function FunctionScreen() { className="text-[32px] font-bold tracking-tight text-neutral-900 dark:text-neutral-50" numberOfLines={1} > - 功能 + {t("fn.title")} {IS_DEV && ( - 校园服务,触手可及 + {t("fn.subtitle")} {SECTIONS.map((section) => ( - + - {section.title} + {t(section.titleKey)} {section.items.map((app) => ( openWebApp(app.uri)} /> @@ -176,7 +179,7 @@ export default function FunctionScreen() { - 打开网页 + {t("browser.openWeb")} @@ -220,10 +223,12 @@ export default function FunctionScreen() { function AppItem({ app, + label, isDark, onPress, }: { app: WebApp; + label: string; isDark: boolean; onPress: () => void; }) { @@ -252,7 +257,7 @@ function AppItem({ className="mt-2 text-xs text-neutral-700 dark:text-neutral-300" numberOfLines={1} > - {app.label} + {label} ); diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index fdca961..3f4ed3b 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -18,7 +18,7 @@ import Animated, { } from "react-native-reanimated"; import { SafeAreaView } from "react-native-safe-area-context"; -import { DAY_LABELS } from "@/components/layout/schedule"; +import { getDayLabels } from "@/components/layout/schedule"; import { AnnouncementBanner } from "@/components/ui/announcement-banner"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { useHaptics } from "@/hooks/use-haptics"; @@ -29,6 +29,7 @@ import { getTomorrowWeek, isVacation, } from "@/lib/date"; +import { type TKey, useT } from "@/lib/i18n"; import { filterActiveAnnouncements } from "@/services/announcements"; import { formatCourseSectionTimeRange, @@ -40,26 +41,69 @@ import { useCourseStore } from "@/store/course"; import { useScheduleStore } from "@/store/schedule"; import { useUpdateStore } from "@/store/update"; -const GREETINGS: { start: number; end: number; title: string; sub: string }[] = - [ - { start: 5, end: 8, title: "早安", sub: "新的一天,从此刻开始" }, - { start: 8, end: 11, title: "上午好", sub: "今天也要元气满满" }, - { start: 11, end: 13, title: "午安", sub: "记得好好吃饭哦" }, - { start: 13, end: 17, title: "下午好", sub: "继续加油" }, - { start: 17, end: 19, title: "傍晚了", sub: "忙碌了一天,辛苦啦" }, - { start: 19, end: 23, title: "晚上好", sub: "忙完了就早点休息" }, - { start: 23, end: 5, title: "夜深了", sub: "熬夜伤身,早点睡哦" }, - ]; +type GreetingSlot = { + start: number; + end: number; + titleKey: TKey; + subKey: TKey; +}; + +const GREETING_SLOTS: GreetingSlot[] = [ + { + start: 5, + end: 8, + titleKey: "home.greetEarlyMorning", + subKey: "home.greetEarlyMorningSub", + }, + { + start: 8, + end: 11, + titleKey: "home.greetMorning", + subKey: "home.greetMorningSub", + }, + { + start: 11, + end: 13, + titleKey: "home.greetNoon", + subKey: "home.greetNoonSub", + }, + { + start: 13, + end: 17, + titleKey: "home.greetAfternoon", + subKey: "home.greetAfternoonSub", + }, + { + start: 17, + end: 19, + titleKey: "home.greetEvening", + subKey: "home.greetEveningSub", + }, + { + start: 19, + end: 23, + titleKey: "home.greetNight", + subKey: "home.greetNightSub", + }, + { + start: 23, + end: 5, + titleKey: "home.greetLateNight", + subKey: "home.greetLateNightSub", + }, +]; const CARD_GAP = 10; +type Countdown = { kind: "start" | "end"; mins: number }; + function isCourseFinished(course: Course): boolean { const now = new Date(); const nowMin = now.getHours() * 60 + now.getMinutes(); return nowMin > (SECTION_TIMES[course.sectionEnd]?.[3] ?? 0); } -function getCourseCountdown(course: Course): string | null { +function getCourseCountdown(course: Course): Countdown | null { const now = new Date(); const nowMin = now.getHours() * 60 + now.getMinutes(); const startMin = SECTION_TIMES[course.sectionStart]?.[2] ?? 0; @@ -68,35 +112,23 @@ function getCourseCountdown(course: Course): string | null { if (nowMin > endMin) return null; if (nowMin < startMin) { const diff = startMin - nowMin; - return diff <= 60 ? `${diff} 分钟后开始` : null; + return diff <= 60 ? { kind: "start", mins: diff } : null; } - const remaining = endMin - nowMin; - return `${remaining} 分钟后结束`; + return { kind: "end", mins: endMin - nowMin }; } -function getGreeting() { +function getGreetingSlot(): GreetingSlot { const hour = new Date().getHours(); - const match = GREETINGS.find((g) => + const match = GREETING_SLOTS.find((g) => g.start < g.end ? hour >= g.start && hour < g.end : hour >= g.start || hour < g.end, ); - return match ?? GREETINGS[0]; -} - -function getDateContext(termStart: string, vacation: boolean) { - const now = new Date(); - const day = getCurrentDayOfWeek(); - const month = now.getMonth() + 1; - const date = now.getDate(); - if (vacation) { - return `假期中 · ${DAY_LABELS[day - 1]} · ${month}月${date}日`; - } - const week = getCurrentWeek(termStart); - return `第 ${week} 周 · ${DAY_LABELS[day - 1]} · ${month}月${date}日`; + return match ?? GREETING_SLOTS[0]; } export default function HomeScreen() { + const t = useT(); const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; const router = useRouter(); @@ -115,9 +147,30 @@ export default function HomeScreen() { [announcements, dismissedIds], ); - const greeting = getGreeting(); + const greetingSlot = getGreetingSlot(); + const greeting = { + title: t(greetingSlot.titleKey), + sub: t(greetingSlot.subKey), + }; const vacation = isVacation(termStart); - const dateContext = getDateContext(termStart, vacation); + + const now = new Date(); + const dayIdx = getCurrentDayOfWeek(); + const dayLabels = getDayLabels(); + const month = now.getMonth() + 1; + const date = now.getDate(); + const dateContext = vacation + ? t("home.vacationContext", { + weekday: dayLabels[dayIdx - 1], + m: month, + d: date, + }) + : t("home.weekContext", { + week: getCurrentWeek(termStart), + weekday: dayLabels[dayIdx - 1], + m: month, + d: date, + }); const week = getCurrentWeek(termStart); const today = getCurrentDayOfWeek(); @@ -276,8 +329,8 @@ export default function HomeScreen() { } const tabs = [ - { label: "今日", count: todayCourses.length }, - { label: "明日", count: tomorrowCourses.length }, + { label: t("home.tabToday"), count: todayCourses.length }, + { label: t("home.tabTomorrow"), count: tomorrowCourses.length }, ]; return ( @@ -485,6 +538,16 @@ export default function HomeScreen() { color={getCourseColor(course.name)} past={past} countdown={countdown} + countdownText={ + countdown + ? t( + countdown.kind === "start" + ? "home.countdownStartIn" + : "home.countdownEndIn", + { n: countdown.mins }, + ) + : null + } isDark={isDark} /> @@ -520,6 +583,7 @@ export default function HomeScreen() { color={getCourseColor(course.name)} past={false} countdown={null} + countdownText={null} isDark={isDark} /> @@ -546,12 +610,14 @@ function CourseCard({ color, past, countdown, + countdownText, isDark, }: { course: Course; color: string; past: boolean; - countdown: string | null; + countdown: Countdown | null; + countdownText: string | null; isDark: boolean; }) { const barColor = past @@ -612,13 +678,14 @@ function CourseCard({ > {course.name} - {countdown && ( + {countdown && countdownText && ( - {countdown} + {countdownText} )} @@ -691,17 +758,18 @@ function EmptyState({ isDark: boolean; variant?: "today" | "tomorrow"; }) { + const t = useT(); const isTomorrow = variant === "tomorrow"; const title = !hasCourses - ? "还没有课程" + ? t("home.emptyNoCourses") : isTomorrow - ? "明天没有课程" - : "今天没有课程"; + ? t("home.emptyTomorrowNone") + : t("home.emptyTodayNone"); const sub = !hasCourses - ? "前往「课程」标签页导入你的课表" + ? t("home.emptyNoCoursesSub") : isTomorrow - ? "可以放松一下啦!" - : "好好享受空闲时光吧~"; + ? t("home.emptyTomorrowNoneSub") + : t("home.emptyTodayNoneSub"); const iconName: React.ComponentProps["name"] = !hasCourses ? "calendar-outline" : isTomorrow @@ -759,6 +827,7 @@ function EmptyState({ } function VacationState({ isDark }: { isDark: boolean }) { + const t = useT(); return ( - 假期中 + {t("home.vacationTitle")} - 假期愉快~ + {t("home.vacationSub")} ); diff --git a/app/(tabs)/user.tsx b/app/(tabs)/user.tsx index e118cdb..2c24351 100644 --- a/app/(tabs)/user.tsx +++ b/app/(tabs)/user.tsx @@ -7,9 +7,11 @@ import { SafeAreaView } from "react-native-safe-area-context"; import { ConfirmSheet } from "@/components/ui/confirm-sheet"; import { MenuGroup, MenuItem } from "@/components/ui/menu-item"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { useT } from "@/lib/i18n"; import { useUserBindStore } from "@/store/user-bind"; function UserCard() { + const t = useT(); const router = useRouter(); const scheme = useColorScheme(); const isDark = scheme === "dark"; @@ -35,10 +37,10 @@ function UserCard() { - 绑定智慧理工大 + {t("user.bindTitle")} - 绑定后可自动登录校园服务 + {t("user.bindSubtitle")} setUnbindVisible(false)} - title="解除绑定" - description="确定要解除智慧理工大账号绑定吗?" - confirmText="解绑" + title={t("user.unbindTitle")} + description={t("user.unbindDesc")} + confirmText={t("user.unbind")} destructive onConfirm={() => { unbind(); @@ -108,6 +110,7 @@ function UserCard() { } export default function UserScreen() { + const t = useT(); return ( @@ -117,36 +120,41 @@ export default function UserScreen() { showsVerticalScrollIndicator={false} > - + - + - - + + diff --git a/app/_layout.tsx b/app/_layout.tsx index 5c5d67b..8efe71c 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -21,6 +21,7 @@ FileLogger.configure({ }); /* eslint-disable import/first */ +import "@/lib/i18n/bootstrap"; import { Feather, Ionicons } from "@expo/vector-icons"; import MaterialIcons from "@expo/vector-icons/MaterialIcons"; import { ThemeProvider } from "expo-router/react-navigation"; @@ -39,6 +40,7 @@ import Toast from "react-native-toast-message"; import { Themes } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { refreshSystemLocale } from "@/lib/i18n"; import { syncCoursesToCalendar } from "@/services/calendar-sync"; import { initNotificationChannel, @@ -102,6 +104,11 @@ function RootLayout() { if (Platform.OS === "ios") { showUpcomingLiveActivity().catch(() => {}); } + // On Android the app keeps running across system-language changes, so + // re-resolve the device locale whenever we come back to the foreground. + if (Platform.OS === "android") { + refreshSystemLocale(); + } useAnnouncementStore.getState().fetch(); }); return () => sub.remove(); diff --git a/app/browser/bind.tsx b/app/browser/bind.tsx index 7b4009f..613b496 100644 --- a/app/browser/bind.tsx +++ b/app/browser/bind.tsx @@ -1,4 +1,5 @@ import { IS_DEV } from "@/constants/is-dev"; +import { useT } from "@/lib/i18n"; import { reportError } from "@/lib/report"; import { useUserBindStore } from "@/store/user-bind"; import { router, Stack } from "expo-router"; @@ -148,6 +149,7 @@ const INJECTED_JS = `(function(){ })();true;`; export default function BindScreen() { + const t = useT(); const webview = useRef(null); const pendingCredentials = useRef<{ username: string; @@ -195,8 +197,8 @@ export default function BindScreen() { Toast.show({ type: "success", - text1: "绑定成功", - text2: `已绑定账号 ${username}`, + text1: t("user.bindSuccess"), + text2: t("user.bindSuccessSub", { username }), position: "bottom", }); @@ -209,7 +211,7 @@ export default function BindScreen() { return ( - + t(k)); +} interface SidebarLabel { label: string; @@ -41,14 +46,6 @@ const SECTION_GROUPS_FULL: number[][] = [ [14, 15, 16], ]; -const SIDEBAR_LABELS_FULL: SidebarLabel[] = [ - { label: "上\n午", firstSection: 1, lastSection: 5 }, - { label: "中\n课", firstSection: 6, lastSection: 7 }, - { label: "下\n午", firstSection: 8, lastSection: 12 }, - { label: "晚\n课", firstSection: 13, lastSection: 13 }, - { label: "晚\n上", firstSection: 14, lastSection: 16 }, -]; - const SECTION_GROUPS_COMPACT: number[][] = [ [1, 2], [3, 4, 5], @@ -56,11 +53,35 @@ const SECTION_GROUPS_COMPACT: number[][] = [ [14, 15, 16], ]; -const SIDEBAR_LABELS_COMPACT: SidebarLabel[] = [ - { label: "上\n午", firstSection: 1, lastSection: 5 }, - { label: "下\n午", firstSection: 8, lastSection: 12 }, - { label: "晚\n上", firstSection: 14, lastSection: 16 }, -]; +function getSidebarLabelsFull(): SidebarLabel[] { + return [ + { label: t("schedule.sidebar.morning"), firstSection: 1, lastSection: 5 }, + { label: t("schedule.sidebar.midday"), firstSection: 6, lastSection: 7 }, + { + label: t("schedule.sidebar.afternoon"), + firstSection: 8, + lastSection: 12, + }, + { + label: t("schedule.sidebar.eveningEarly"), + firstSection: 13, + lastSection: 13, + }, + { label: t("schedule.sidebar.night"), firstSection: 14, lastSection: 16 }, + ]; +} + +function getSidebarLabelsCompact(): SidebarLabel[] { + return [ + { label: t("schedule.sidebar.morning"), firstSection: 1, lastSection: 5 }, + { + label: t("schedule.sidebar.afternoon"), + firstSection: 8, + lastSection: 12, + }, + { label: t("schedule.sidebar.night"), firstSection: 14, lastSection: 16 }, + ]; +} const GAP_UNITS = 0; const HEADER_HEIGHT = 36; @@ -151,6 +172,7 @@ export function Schedule({ today?: number; termStart?: string; }>) { + const localT = useT(); const { width: screenWidth } = useWindowDimensions(); const colorScheme = useColorScheme(); const isDark = colorScheme === "dark"; @@ -165,12 +187,16 @@ export function Schedule({ const paletteColors = colorPalette.colors; const hasBgImage = !!backgroundImageUri; + const dayLabels = useMemo(() => DAY_KEYS.map((k) => localT(k)), [localT]); + const monthLabelSuffix = localT("common.monthSuffix"); + const layout = useMemo( () => showMidday - ? computeLayout(SECTION_GROUPS_FULL, SIDEBAR_LABELS_FULL) - : computeLayout(SECTION_GROUPS_COMPACT, SIDEBAR_LABELS_COMPACT), - [showMidday], + ? computeLayout(SECTION_GROUPS_FULL, getSidebarLabelsFull()) + : computeLayout(SECTION_GROUPS_COMPACT, getSidebarLabelsCompact()), + // eslint-disable-next-line react-hooks/exhaustive-deps + [showMidday, localT], ); const colorMap = useMemo( @@ -253,7 +279,7 @@ export function Schedule({ color: isToday ? "#fff" : isDark ? "#d4d4d4" : "#525252", }} > - {DAY_LABELS[dayIdx]} + {dayLabels[dayIdx]} - {DAY_LABELS[dayIdx]} + {dayLabels[dayIdx]} )} @@ -423,16 +449,18 @@ export function Schedule({ > {monthLabel.split("\n")[0]} - - 月 - + {monthLabelSuffix ? ( + + {monthLabelSuffix} + + ) : null} ) : null} @@ -550,38 +578,48 @@ export function Schedule({ marginTop: 6, }} > - {DAY_LABELS[selected.day - 1]} · 第 {selected.sectionStart}- - {selected.sectionEnd} 节 + {localT("schedule.weekdayWithSection", { + weekday: dayLabels[selected.day - 1], + start: selected.sectionStart, + end: selected.sectionEnd, + })} diff --git a/components/ui/announcement-banner.tsx b/components/ui/announcement-banner.tsx index f0c6c35..eb7a495 100644 --- a/components/ui/announcement-banner.tsx +++ b/components/ui/announcement-banner.tsx @@ -13,6 +13,7 @@ import { import Animated, { LinearTransition } from "react-native-reanimated"; import { useHaptics } from "@/hooks/use-haptics"; +import { useT } from "@/lib/i18n"; import type { Announcement, AnnouncementType } from "@/services/announcements"; import { useAnnouncementStore } from "@/store/announcements"; @@ -143,6 +144,7 @@ function AnnouncementCard({ isDark: boolean; onExpandedChange: (id: string, expanded: boolean) => void; }) { + const t = useT(); const router = useRouter(); const haptic = useHaptics(); const dismiss = useAnnouncementStore((s) => s.dismiss); @@ -267,7 +269,7 @@ function AnnouncementCard({ color: typeStyle.color, }} > - 查看详情 + {t("announcement.viewDetail")} void; }>) { + const t = useT(); + const resolvedConfirmText = confirmText ?? t("common.confirm"); + const resolvedCancelText = cancelText ?? t("common.cancel"); return ( @@ -32,7 +37,7 @@ export function ConfirmSheet({ onPress={onClose} > - {cancelText} + {resolvedCancelText} - {confirmText} + {resolvedConfirmText} diff --git a/components/ui/sms-prompt.tsx b/components/ui/sms-prompt.tsx index e285067..171bf07 100644 --- a/components/ui/sms-prompt.tsx +++ b/components/ui/sms-prompt.tsx @@ -11,6 +11,8 @@ import { View, } from "react-native"; +import { useT } from "@/lib/i18n"; + export function SmsPrompt({ visible, phoneTail, @@ -28,6 +30,7 @@ export function SmsPrompt({ onSubmit: () => void; onCancel: () => void; }>) { + const t = useT(); const canSubmit = code.trim().length > 0 && !submitting; const { height } = useWindowDimensions(); @@ -58,13 +61,13 @@ export function SmsPrompt({ color="#3b82f6" /> - 短信验证码 + {t("sms.title")} {phoneTail - ? `验证码已发送至手机尾号 ${phoneTail}` - : "请输入收到的短信验证码"} + ? t("sms.sentTo", { tail: phoneTail }) + : t("sms.enterCode")} diff --git a/constants/course-palettes.ts b/constants/course-palettes.ts index 5b5fe8a..d7cd1f1 100644 --- a/constants/course-palettes.ts +++ b/constants/course-palettes.ts @@ -1,3 +1,5 @@ +import type { TKey } from "@/lib/i18n"; + export interface ColorPalette { name: string; version: 1; @@ -5,6 +7,22 @@ export interface ColorPalette { overrides?: Record; } +// Maps the persisted Chinese palette name back to an i18n key so we can +// localize built-in palette names at render time without changing the +// stored schema (legacy MMKV state and exported JSON keep working). +export const BUILTIN_PALETTE_NAME_KEYS: Record = { + 默认: "palettes.default", + 马卡龙: "palettes.macaron", + 森林: "palettes.forest", + 星空: "palettes.starry", + 日落: "palettes.sunset", + 海洋: "palettes.ocean", + 樱花: "palettes.sakura", + 薄荷: "palettes.mint", + 莫兰迪: "palettes.morandi", + 霓虹: "palettes.neon", +}; + export const BUILTIN_PALETTES: ColorPalette[] = [ { name: "默认", diff --git a/lib/i18n/bootstrap.ts b/lib/i18n/bootstrap.ts new file mode 100644 index 0000000..c0e8d4e --- /dev/null +++ b/lib/i18n/bootstrap.ts @@ -0,0 +1,21 @@ +import { getMMKV } from "@/lib/storage"; + +import { type Lang, setLang } from "./index"; + +function readSavedLanguage(): Lang | null { + try { + const raw = getMMKV().getString("settings"); + if (!raw) return null; + const parsed = JSON.parse(raw) as { state?: { language?: unknown } }; + const lang = parsed?.state?.language; + if (lang === "zh" || lang === "en" || lang === "system") { + return lang; + } + } catch { + // Ignore malformed persisted state; fall through to system default. + } + return null; +} + +// Synchronous: MMKV reads are sync, so this runs before any component renders. +setLang(readSavedLanguage() ?? "system"); diff --git a/lib/i18n/index.ts b/lib/i18n/index.ts new file mode 100644 index 0000000..09d30d7 --- /dev/null +++ b/lib/i18n/index.ts @@ -0,0 +1,163 @@ +import { getLocales } from "expo-localization"; +import { useSyncExternalStore } from "react"; + +import { getSystemLanguageTag } from "@/modules/locale"; + +import enJson from "./locales/en.json"; +import zhJson from "./locales/zh.json"; + +export type Lang = "zh" | "en" | "system"; +export type ResolvedLang = "zh" | "en"; + +type WidenStrings = T extends string + ? string + : T extends readonly (infer U)[] + ? readonly WidenStrings[] + : T extends object + ? { [K in keyof T]: WidenStrings } + : T; + +export type Dict = WidenStrings; + +// Compile-time check: en must structurally match zh. +const _enCheck: Dict = enJson; +void _enCheck; + +type Leaves = T extends string + ? P extends `${infer Head}.` + ? Head + : never + : T extends object + ? { + [K in keyof T & string]: Leaves; + }[keyof T & string] + : never; + +export type TKey = Leaves; + +const dicts: Record = { + zh: zhJson as Dict, + en: enJson, +}; + +function resolveSystem(): ResolvedLang { + // Prefer the native module which reads the *device-level* locale via + // `Resources.getSystem()` / CFPreferences global domain. This bypasses our + // own per-app override and is the only way to correctly resolve "follow + // system" right after switching away from an explicit language. + try { + const nativeTag = getSystemLanguageTag(); + if (nativeTag) { + return nativeTag.toLowerCase().startsWith("zh") ? "zh" : "en"; + } + } catch { + // Fall through to the expo-localization based path below. + } + try { + const code = getLocales().at(0)?.languageCode ?? "zh"; + return code === "zh" ? "zh" : "en"; + } catch { + return "zh"; + } +} + +let currentLang: Lang = "system"; +let currentResolved: ResolvedLang = resolveSystem(); +const listeners = new Set<() => void>(); + +function notify() { + for (const l of listeners) l(); +} + +export function setLang(lang: Lang): void { + const resolved: ResolvedLang = lang === "system" ? resolveSystem() : lang; + const changed = lang !== currentLang || resolved !== currentResolved; + currentLang = lang; + currentResolved = resolved; + if (changed) notify(); +} + +export function getLang(): Lang { + return currentLang; +} + +export function getResolvedLang(): ResolvedLang { + return currentResolved; +} + +export function refreshSystemLocale(): void { + if (currentLang !== "system") return; + const resolved = resolveSystem(); + if (resolved !== currentResolved) { + currentResolved = resolved; + notify(); + } +} + +function getByPath(obj: unknown, path: string): string | undefined { + const parts = path.split("."); + let cur: unknown = obj; + for (const p of parts) { + if ( + cur && + typeof cur === "object" && + p in (cur as Record) + ) { + cur = (cur as Record)[p]; + } else { + return undefined; + } + } + return typeof cur === "string" ? cur : undefined; +} + +function interpolate( + template: string, + vars?: Record, +): string { + if (!vars) return template; + return template.replace(/\{(\w+)\}/g, (_, k) => + k in vars ? String(vars[k as keyof typeof vars]) : `{${k}}`, + ); +} + +export function t(key: TKey, vars?: Record): string { + const dict = dicts[currentResolved]; + const raw = + getByPath(dict, key) ?? getByPath(dicts.zh, key) ?? (key as string); + return interpolate(raw, vars); +} + +export function useT(): typeof t { + // Prevent React Compiler from wrapping the returned closure in an implicit + // useMemo. We *want* a fresh function reference per render (see comment + // below); auto-memoization would defeat the entire purpose of this hook. + "use no memo"; + const lang = useSyncExternalStore( + (cb) => { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; + }, + () => currentResolved, + () => currentResolved, + ); + // Return a fresh closure on every render. This is intentional and required + // because React Compiler (`reactCompiler: true` in app.config.ts) memoizes + // expressions like `t("some.key")` based on the identity of `t`. If `t` + // had a stable identity (e.g. by returning the module-level `t` or a + // useMemo-cached wrapper), the compiler may hoist or cache call results + // forever and language switches would never propagate to consumers. + // Producing a fresh function reference per render guarantees those cached + // expressions are invalidated. The closure captures the `lang` primitive + // resolved at render time so all dict lookups resolve to the current + // language at call time, without re-reading module-level mutable state. + // Refs: facebook/react#29195, i18next/react-i18next#1863 + PR #1884. + const dict = dicts[lang]; + return ((key, vars) => { + const raw = + getByPath(dict, key) ?? getByPath(dicts.zh, key) ?? (key as string); + return interpolate(raw, vars); + }) as typeof t; +} diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json new file mode 100644 index 0000000..27b2641 --- /dev/null +++ b/lib/i18n/locales/en.json @@ -0,0 +1,334 @@ +{ + "common": { + "confirm": "Confirm", + "cancel": "Cancel", + "ok": "OK", + "save": "Save", + "delete": "Delete", + "loading": "Loading", + "minutes": "min", + "minutesBefore": "{minutes} min before", + "weekN": "Week {n}", + "monthSuffix": "", + "monthDay": "{m}/{d}" + }, + "nav": { + "home": "Home", + "course": "Schedule", + "function": "Apps", + "user": "Profile" + }, + "home": { + "greetEarlyMorning": "Good morning", + "greetEarlyMorningSub": "A brand new day begins now", + "greetMorning": "Good morning", + "greetMorningSub": "Stay energetic today", + "greetNoon": "Good noon", + "greetNoonSub": "Don't skip your lunch", + "greetAfternoon": "Good afternoon", + "greetAfternoonSub": "Keep up the great work", + "greetEvening": "Good evening", + "greetEveningSub": "What a long day, well done", + "greetNight": "Good evening", + "greetNightSub": "Wrap up and rest soon", + "greetLateNight": "It's late", + "greetLateNightSub": "Get some sleep, take care", + "vacationContext": "On vacation · {weekday} · {m}/{d}", + "weekContext": "Week {week} · {weekday} · {m}/{d}", + "tabToday": "Today", + "tabTomorrow": "Tomorrow", + "countdownStartIn": "Starts in {n} min", + "countdownEndIn": "Ends in {n} min", + "emptyNoCourses": "No courses yet", + "emptyNoCoursesSub": "Open the Schedule tab to import your timetable", + "emptyTodayNone": "No classes today", + "emptyTodayNoneSub": "Enjoy your free time!", + "emptyTomorrowNone": "No classes tomorrow", + "emptyTomorrowNoneSub": "Time to relax!", + "vacationTitle": "On vacation", + "vacationSub": "Have a great break!" + }, + "course": { + "selectImportType": "Choose import type", + "bachelor": "Undergraduate", + "master": "Postgraduate", + "needBindTitle": "Link your iWUT account first", + "needBindSub": "Once linked, your timetable can be imported automatically", + "goBind": "Go to Profile to link", + "importing": "Importing", + "importingSub": "Sit back and relax...", + "importSuccess": "Imported", + "importSuccessSub": "Nice!", + "importFail": "Import failed", + "importFailSub": "Please check your connection and try again", + "smsCancelled": "SMS verification cancelled", + "importTimeout": "Request timed out, check your connection and retry", + "parseFailed": "Failed to parse timetable data", + "fetchUserFailed": "Failed to fetch user info", + "noTermData": "No courses found for term ({term})" + }, + "schedule": { + "weekday": { + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun" + }, + "sidebar": { + "morning": "AM", + "midday": "Mid", + "afternoon": "PM", + "eveningEarly": "Eve", + "night": "Night" + }, + "sectionRange": "Periods {start}-{end}", + "weekdayWithSection": "{weekday} · Periods {start}-{end}", + "room": "Room", + "teacher": "Teacher", + "weeks": "Weeks", + "weeksValue": "Weeks {start}-{end}", + "time": "Time", + "countdownEndTag": "end" + }, + "fn": { + "title": "Apps", + "subtitle": "Campus services at your fingertips", + "section": { + "study": "Study", + "life": "Life", + "info": "Campus Info" + }, + "app": { + "classroom": "Study Rooms", + "jwxt": "Academic System", + "library": "Library", + "card": "Campus Card", + "elec": "Electricity", + "campusNews": "Campus News" + }, + "openUrl": "Open URL" + }, + "user": { + "bindTitle": "Link iWUT account", + "bindSubtitle": "Auto-login to campus services after linking", + "unbindTitle": "Unlink account", + "unbindDesc": "Are you sure you want to unlink your iWUT account?", + "unbind": "Unlink", + "bindScreenTitle": "Link iWUT", + "bindSuccess": "Linked", + "bindSuccessSub": "Account {username} linked", + "menuTools": "Tools", + "menuSettings": "Settings", + "menuOther": "Other", + "menuWlan": "Campus Wi-Fi", + "menuGeneral": "General", + "menuAppearance": "Appearance", + "menuSchedule": "Schedule", + "menuAbout": "About" + }, + "settings": { + "generalTitle": "General", + "interaction": "Interaction", + "haptic": "Haptic feedback", + "openCourseOnLaunch": "Open Schedule on launch", + "notification": "Notifications", + "courseReminder": "Class reminder", + "reminderTime": "Reminder time", + "reminderTimeMins": "{n} min before", + "storage": "Storage", + "clearCache": "Clear cache", + "exportLogs": "Export logs", + "clearCacheTitle": "Clear cache", + "clearCacheDesc": "This will clear cached and temporary data. Saved content won't be affected.", + "clearCacheConfirm": "Clear", + "cacheCleared": "Cache cleared", + "clearCacheFailed": "Failed to clear", + "exportNoLog": "No logs available", + "exportFailed": "Export failed", + "exportDialogTitle": "Export logs", + "reminderRangeError": "Please enter a number between 1 and 120", + "custom": "Custom", + "customPlaceholder": "1-120" + }, + "appearance": { + "title": "Appearance", + "themeGroup": "Theme", + "theme": "Theme", + "themeSystem": "Follow system", + "themeLight": "Light", + "themeDark": "Dark", + "languageGroup": "Language", + "language": "Language", + "langSystem": "Follow system", + "langZh": "简体中文", + "langEn": "English" + }, + "calendarSet": { + "title": "Schedule", + "courseGroup": "Courses", + "courseManage": "Manage courses", + "courseCount": "{n} courses", + "noCourses": "No courses", + "displayGroup": "Display", + "scrollWeekend": "Scroll horizontally on weekends", + "showMidday": "Show midday periods", + "syncGroup": "Sync", + "syncCalendar": "Sync to system calendar", + "syncedToast": "Synced to system calendar", + "syncedSub": "{n} class entries written", + "syncFailed": "Sync failed", + "syncRemoved": "Removed from system calendar", + "customGroup": "Personalize", + "palette": "Color palette", + "bg": "Background", + "bgSet": "Set", + "bgNone": "None", + "bgSheetTitle": "Schedule background", + "bgPickFromAlbum": "Pick from album", + "bgRemove": "Remove background", + "bgSetSuccess": "Background updated", + "bgSetFailed": "Failed to set background", + "bgSetFailedSub": "Something went wrong while cropping or saving", + "bgRemoved": "Background removed", + "bgPickerCancel": "Cancel", + "bgPickerDone": "Done" + }, + "courseAdd": { + "titleAdd": "Add course", + "titleEdit": "Edit course", + "courseName": "Course name", + "courseNamePlaceholder": "e.g. Calculus", + "teacher": "Teacher", + "teacherPlaceholder": "Optional", + "minSlotRequired": "Keep at least one time slot", + "courseUpdated": "Course updated", + "courseAdded": "Course added", + "needCourseName": "Please enter a course name", + "slotNoWeeks": "Slot {n}: no weeks selected", + "slotInvalidRange": "Slot {n}: invalid period range", + "addSlot": "Add slot", + "weekday": "Weekday", + "room": "Room", + "roomPlaceholder": "Optional", + "weeks": "Weeks (multi-select)", + "selectAll": "All", + "clear": "Clear", + "sectionRange": "Period range", + "currentRange": "Period {start} - {end}", + "weeksNotSelected": "Not selected", + "weeksUnit": "", + "slotSummaryWithRoom": "{weekday} P{start}-{end} {room} | {weeks}", + "slotSummary": "{weekday} P{start}-{end} | {weeks}" + }, + "courseManage": { + "title": "Manage courses", + "addCourse": "Add course", + "importedTag": "Imported", + "noCoursesHint": "No courses yet. Tap the button above to add one.", + "clearAll": "Clear all", + "clearAllTitle": "Clear timetable", + "clearAllDesc": "Delete every course? This cannot be undone.", + "clearAllConfirm": "Clear", + "cleared": "Timetable cleared", + "deleteCourseTitle": "Delete course", + "deleteCourseDesc": "Delete all slots of \"{name}\"?", + "deleted": "Deleted \"{name}\"", + "summaryWithDay": "{weekday} P{start}-{end}", + "moreSlots": " and {n} more slots" + }, + "palette": { + "title": "Color palette", + "actions": "Actions", + "importFromClipboard": "Import from clipboard", + "exportToClipboard": "Export to clipboard", + "clipboardEmpty": "Clipboard is empty", + "formatError": "Invalid palette format", + "formatErrorSub": "Please verify the JSON format", + "parseError": "Failed to parse JSON", + "imported": "Palette imported: {name}", + "exported": "Palette copied to clipboard", + "deleteTitle": "Delete palette", + "deleteDesc": "Delete \"{name}\"?", + "deleteConfirm": "Delete", + "deleted": "Deleted \"{name}\"" + }, + "palettes": { + "default": "Default", + "macaron": "Macaron", + "forest": "Forest", + "starry": "Starry", + "sunset": "Sunset", + "ocean": "Ocean", + "sakura": "Sakura", + "mint": "Mint", + "morandi": "Morandi", + "neon": "Neon" + }, + "wlan": { + "title": "Campus Wi-Fi", + "accountGroup": "Account", + "currentAccount": "Current account", + "editAccount": "Edit account", + "clearAccount": "Clear account", + "setupAccount": "Set up Wi-Fi account", + "sheetTitle": "Campus Wi-Fi account", + "sheetHint": "Enter your campus Wi-Fi credentials. One tap to connect after saving.", + "username": "Username", + "password": "Password", + "save": "Save", + "cancel": "Cancel", + "needCreds": "Please enter username and password", + "connectOk": "Network is reachable, no sign-in needed", + "connectFail": "Connection failed", + "accountCleared": "Account cleared", + "clearTitle": "Clear account", + "clearDesc": "Clear the saved campus Wi-Fi account?", + "clearConfirm": "Clear", + "errNotCampus": "Not on campus Wi-Fi. Connect to the campus network and retry.", + "errNetwork": "Network error. Check your connection and try again." + }, + "about": { + "title": "About", + "appName": "iWUT", + "infoGroup": "Info", + "version": "Version", + "commit": "Commit", + "copied": "Copied {label}", + "updateGroup": "Update", + "checkUpdate": "Check for update", + "newVersionAvailable": "New version {v}", + "newVersionFound": "New version available", + "newVersionTip": "v{v} is out, tap to download", + "upToDate": "You're on the latest version", + "linksGroup": "Links", + "website": "Website", + "github": "GitHub" + }, + "sms": { + "title": "SMS verification code", + "sentTo": "Code sent to phone ending in {tail}", + "enterCode": "Enter the SMS code you received", + "placeholder": "6 digits" + }, + "announcement": { + "viewDetail": "View details" + }, + "browser": { + "openWeb": "Open URL" + }, + "notif": { + "channelName": "Class reminders", + "channelDesc": "Shows countdown notifications before class starts" + }, + "calSync": { + "title": "iWUT - My timetable", + "teacherNotes": "Teacher: {teacher}", + "errNoPermission": "Calendar permission not granted", + "errNoData": "No course data or term start date", + "errWriteFail": "Failed to write events", + "errUnknown": "Unknown error" + } +} diff --git a/lib/i18n/locales/zh.json b/lib/i18n/locales/zh.json new file mode 100644 index 0000000..a62549f --- /dev/null +++ b/lib/i18n/locales/zh.json @@ -0,0 +1,334 @@ +{ + "common": { + "confirm": "确认", + "cancel": "取消", + "ok": "确定", + "save": "保存", + "delete": "删除", + "loading": "加载中", + "minutes": "分钟", + "minutesBefore": "提前 {minutes} 分钟", + "weekN": "第 {n} 周", + "monthSuffix": "月", + "monthDay": "{m}月{d}日" + }, + "nav": { + "home": "首页", + "course": "课程", + "function": "功能", + "user": "我的" + }, + "home": { + "greetEarlyMorning": "早安", + "greetEarlyMorningSub": "新的一天,从此刻开始", + "greetMorning": "上午好", + "greetMorningSub": "今天也要元气满满", + "greetNoon": "午安", + "greetNoonSub": "记得好好吃饭哦", + "greetAfternoon": "下午好", + "greetAfternoonSub": "继续加油", + "greetEvening": "傍晚了", + "greetEveningSub": "忙碌了一天,辛苦啦", + "greetNight": "晚上好", + "greetNightSub": "忙完了就早点休息", + "greetLateNight": "夜深了", + "greetLateNightSub": "熬夜伤身,早点睡哦", + "vacationContext": "假期中 · {weekday} · {m}月{d}日", + "weekContext": "第 {week} 周 · {weekday} · {m}月{d}日", + "tabToday": "今日", + "tabTomorrow": "明日", + "countdownStartIn": "{n} 分钟后开始", + "countdownEndIn": "{n} 分钟后结束", + "emptyNoCourses": "还没有课程", + "emptyNoCoursesSub": "前往「课程」标签页导入你的课表", + "emptyTodayNone": "今天没有课程", + "emptyTodayNoneSub": "好好享受空闲时光吧~", + "emptyTomorrowNone": "明天没有课程", + "emptyTomorrowNoneSub": "可以放松一下啦!", + "vacationTitle": "假期中", + "vacationSub": "假期愉快~" + }, + "course": { + "selectImportType": "选择导入类型", + "bachelor": "本科生", + "master": "研究生", + "needBindTitle": "请先绑定智慧理工大账号", + "needBindSub": "绑定后可自动从教务系统导入课表", + "goBind": "前往「我的」绑定", + "importing": "正在导入", + "importingSub": "头抬起,坐和放宽...", + "importSuccess": "导入成功", + "importSuccessSub": "好耶!", + "importFail": "导入失败", + "importFailSub": "请检查网络连接并重试", + "smsCancelled": "已取消短信验证", + "importTimeout": "加载超时,请检查网络连接并重试", + "parseFailed": "课表数据解析失败", + "fetchUserFailed": "获取用户信息失败", + "noTermData": "当前学期({term})无课程数据" + }, + "schedule": { + "weekday": { + "mon": "周一", + "tue": "周二", + "wed": "周三", + "thu": "周四", + "fri": "周五", + "sat": "周六", + "sun": "周日" + }, + "sidebar": { + "morning": "上\n午", + "midday": "中\n课", + "afternoon": "下\n午", + "eveningEarly": "晚\n课", + "night": "晚\n上" + }, + "sectionRange": "第 {start}-{end} 节", + "weekdayWithSection": "{weekday} · 第 {start}-{end} 节", + "room": "教室", + "teacher": "教师", + "weeks": "周次", + "weeksValue": "第 {start}-{end} 周", + "time": "时间", + "countdownEndTag": "end" + }, + "fn": { + "title": "功能", + "subtitle": "校园服务,触手可及", + "section": { + "study": "学习工具", + "life": "生活服务", + "info": "校园资讯" + }, + "app": { + "classroom": "自习室查询", + "jwxt": "教务系统", + "library": "图书馆管家", + "card": "智寻卡片", + "elec": "电费查询", + "campusNews": "校园公告" + }, + "openUrl": "打开网页" + }, + "user": { + "bindTitle": "绑定智慧理工大", + "bindSubtitle": "绑定后可自动登录校园服务", + "unbindTitle": "解除绑定", + "unbindDesc": "确定要解除智慧理工大账号绑定吗?", + "unbind": "解绑", + "bindScreenTitle": "智慧理工大绑定", + "bindSuccess": "绑定成功", + "bindSuccessSub": "已绑定账号 {username}", + "menuTools": "工具", + "menuSettings": "设置", + "menuOther": "其他", + "menuWlan": "校园网连接", + "menuGeneral": "通用", + "menuAppearance": "外观", + "menuSchedule": "课表", + "menuAbout": "关于" + }, + "settings": { + "generalTitle": "通用设置", + "interaction": "交互", + "haptic": "触感反馈", + "openCourseOnLaunch": "将课程页设为首页", + "notification": "通知", + "courseReminder": "课前提醒", + "reminderTime": "提醒时间", + "reminderTimeMins": "提前 {n} 分钟", + "storage": "存储", + "clearCache": "清除缓存", + "exportLogs": "导出日志", + "clearCacheTitle": "清除缓存", + "clearCacheDesc": "将清除缓存和临时数据,不会影响已保存的内容。", + "clearCacheConfirm": "清除", + "cacheCleared": "缓存已清除", + "clearCacheFailed": "清除失败", + "exportNoLog": "暂无日志", + "exportFailed": "导出失败", + "exportDialogTitle": "导出日志", + "reminderRangeError": "请输入 1-120 之间的数字", + "custom": "自定义", + "customPlaceholder": "1-120" + }, + "appearance": { + "title": "外观设置", + "themeGroup": "主题", + "theme": "主题", + "themeSystem": "跟随系统", + "themeLight": "浅色模式", + "themeDark": "深色模式", + "languageGroup": "语言", + "language": "语言", + "langSystem": "跟随系统", + "langZh": "简体中文", + "langEn": "English" + }, + "calendarSet": { + "title": "课表设置", + "courseGroup": "课程", + "courseManage": "课程管理", + "courseCount": "{n} 门课", + "noCourses": "暂无课程", + "displayGroup": "显示", + "scrollWeekend": "周末课表滚动查看", + "showMidday": "显示中课", + "syncGroup": "同步", + "syncCalendar": "同步到系统日历", + "syncedToast": "已同步到系统日历", + "syncedSub": "共写入 {n} 条课程数据", + "syncFailed": "同步失败", + "syncRemoved": "已从系统日历移除", + "customGroup": "个性化", + "palette": "配色方案", + "bg": "课表背景", + "bgSet": "已设置", + "bgNone": "无", + "bgSheetTitle": "课表背景", + "bgPickFromAlbum": "从相册选择", + "bgRemove": "移除背景", + "bgSetSuccess": "背景已设置", + "bgSetFailed": "背景设置失败", + "bgSetFailedSub": "图片裁剪或保存时出现问题", + "bgRemoved": "背景已移除", + "bgPickerCancel": "取消", + "bgPickerDone": "完成" + }, + "courseAdd": { + "titleAdd": "添加课程", + "titleEdit": "编辑课程", + "courseName": "课程名称", + "courseNamePlaceholder": "如:高等数学", + "teacher": "教师", + "teacherPlaceholder": "可选", + "minSlotRequired": "至少保留一个时段", + "courseUpdated": "课程已更新", + "courseAdded": "课程已添加", + "needCourseName": "请输入课程名称", + "slotNoWeeks": "时段 {n} 未选择周次", + "slotInvalidRange": "时段 {n} 节次范围有误", + "addSlot": "添加时段", + "weekday": "星期", + "room": "教室", + "roomPlaceholder": "可选", + "weeks": "周次(可多选)", + "selectAll": "全选", + "clear": "清空", + "sectionRange": "节次范围", + "currentRange": "第 {start} - {end} 节", + "weeksNotSelected": "未选择", + "weeksUnit": " 周", + "slotSummaryWithRoom": "{weekday} 第{start}-{end}节 {room} | {weeks}", + "slotSummary": "{weekday} 第{start}-{end}节 | {weeks}" + }, + "courseManage": { + "title": "课程管理", + "addCourse": "添加课程", + "importedTag": "导入", + "noCoursesHint": "暂无课程,点击上方按钮添加", + "clearAll": "清空课表", + "clearAllTitle": "清空课表", + "clearAllDesc": "确定要删除所有课程吗?此操作不可恢复。", + "clearAllConfirm": "清空", + "cleared": "课表已清空", + "deleteCourseTitle": "删除课程", + "deleteCourseDesc": "确定要删除「{name}」的所有时段吗?", + "deleted": "已删除「{name}」", + "summaryWithDay": "{weekday} 第{start}-{end}节", + "moreSlots": " 等 {n} 个时段" + }, + "palette": { + "title": "配色方案", + "actions": "操作", + "importFromClipboard": "从剪贴板导入", + "exportToClipboard": "导出到剪贴板", + "clipboardEmpty": "剪贴板为空", + "formatError": "配色格式错误", + "formatErrorSub": "请检查 JSON 格式是否正确", + "parseError": "无法解析 JSON", + "imported": "已导入配色方案:{name}", + "exported": "配色方案已复制到剪贴板", + "deleteTitle": "删除配色", + "deleteDesc": "确定要删除「{name}」吗?", + "deleteConfirm": "确认删除", + "deleted": "已删除「{name}」" + }, + "palettes": { + "default": "默认", + "macaron": "马卡龙", + "forest": "森林", + "starry": "星空", + "sunset": "日落", + "ocean": "海洋", + "sakura": "樱花", + "mint": "薄荷", + "morandi": "莫兰迪", + "neon": "霓虹" + }, + "wlan": { + "title": "校园网连接", + "accountGroup": "账号", + "currentAccount": "当前账号", + "editAccount": "修改账号", + "clearAccount": "清除账号", + "setupAccount": "设置校园网账号", + "sheetTitle": "校园网账号", + "sheetHint": "输入校园网账号和密码,保存后可一键连接。", + "username": "账号", + "password": "密码", + "save": "保存", + "cancel": "取消", + "needCreds": "请输入账号和密码", + "connectOk": "网络通畅,无需连接", + "connectFail": "连接失败", + "accountCleared": "账号已清除", + "clearTitle": "清除账号", + "clearDesc": "确定要清除已保存的校园网账号吗?", + "clearConfirm": "确认清除", + "errNotCampus": "非校园网环境,请连接校园网后重试", + "errNetwork": "网络连接异常,请检查网络连接并重试" + }, + "about": { + "title": "关于", + "appName": "掌上吾理", + "infoGroup": "信息", + "version": "版本号", + "commit": "Commit", + "copied": "已复制{label}", + "updateGroup": "更新", + "checkUpdate": "检查更新", + "newVersionAvailable": "新版本 {v}", + "newVersionFound": "发现新版本", + "newVersionTip": "v{v} 可用,点击下载", + "upToDate": "当前已是最新版本", + "linksGroup": "链接", + "website": "官方网站", + "github": "GitHub" + }, + "sms": { + "title": "短信验证码", + "sentTo": "验证码已发送至手机尾号 {tail}", + "enterCode": "请输入收到的短信验证码", + "placeholder": "6位数字" + }, + "announcement": { + "viewDetail": "查看详情" + }, + "browser": { + "openWeb": "打开网页" + }, + "notif": { + "channelName": "课程提醒", + "channelDesc": "在课程开始前显示倒计时通知" + }, + "calSync": { + "title": "掌上吾理-我的课表", + "teacherNotes": "教师: {teacher}", + "errNoPermission": "没有日历访问权限", + "errNoData": "没有课程数据或学期开始时间", + "errWriteFail": "事件写入失败", + "errUnknown": "未知错误" + } +} diff --git a/modules/locale/android/build.gradle b/modules/locale/android/build.gradle new file mode 100644 index 0000000..a27cda9 --- /dev/null +++ b/modules/locale/android/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'dev.tokenteam.iwut' + +expoModule { + canBePublished = false +} + +android { + namespace "dev.tokenteam.iwut.locale" +} + +dependencies { + implementation "androidx.appcompat:appcompat:1.7.0" +} diff --git a/modules/locale/android/src/main/AndroidManifest.xml b/modules/locale/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/modules/locale/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/modules/locale/android/src/main/java/dev/tokenteam/iwut/locale/LocaleModule.kt b/modules/locale/android/src/main/java/dev/tokenteam/iwut/locale/LocaleModule.kt new file mode 100644 index 0000000..c1b8c7c --- /dev/null +++ b/modules/locale/android/src/main/java/dev/tokenteam/iwut/locale/LocaleModule.kt @@ -0,0 +1,55 @@ +package dev.tokenteam.iwut.locale + +import android.app.LocaleManager +import android.content.res.Resources +import android.os.Build +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.os.LocaleListCompat +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class LocaleModule : Module() { + override fun definition() = ModuleDefinition { + Name("LocaleSwitcher") + + AsyncFunction("setApplicationLocales") { tag: String? -> + val locales = if (tag.isNullOrEmpty()) { + LocaleListCompat.getEmptyLocaleList() + } else { + LocaleListCompat.forLanguageTags(tag) + } + AppCompatDelegate.setApplicationLocales(locales) + null + } + + // Returns the device-level system language tag, independent of any + // per-app locale override set via `setApplicationLocales`. + // + // IMPORTANT: We must NOT rely on `Resources.getSystem().configuration` + // here. On Android 13+, `AppCompatDelegate.setApplicationLocales` + // updates the process-wide default Locale, which leaks into the + // `Configuration` returned by `Resources.getSystem()` until the next + // Activity recreate. That made switching back to "follow system" + // appear to do nothing until the user backgrounded and reopened the + // app (which triggers an Activity refresh). + // + // `LocaleManager.systemLocales` (API 33+) is the authoritative source + // for the device-level locale list and is explicitly documented to + // ignore per-app overrides. We use it whenever available and only + // fall back to `Resources.getSystem()` on Android 12 and below, where + // per-app locales do not exist at the OS level, so the system + // Resources cannot be polluted by them. + Function("getSystemLanguageTag") { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val ctx = appContext.reactContext + val lm = ctx?.getSystemService(LocaleManager::class.java) + val systemLocales = lm?.systemLocales + if (systemLocales != null && !systemLocales.isEmpty) { + return@Function systemLocales.get(0).toLanguageTag() + } + } + val locales = Resources.getSystem().configuration.locales + if (locales.isEmpty) null else locales.get(0).toLanguageTag() + } + } +} diff --git a/modules/locale/expo-module.config.json b/modules/locale/expo-module.config.json new file mode 100644 index 0000000..74743c9 --- /dev/null +++ b/modules/locale/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["android", "ios"], + "android": { + "modules": ["dev.tokenteam.iwut.locale.LocaleModule"] + }, + "ios": { + "modules": ["LocaleModule"] + } +} diff --git a/modules/locale/index.ts b/modules/locale/index.ts new file mode 100644 index 0000000..2fa4e3c --- /dev/null +++ b/modules/locale/index.ts @@ -0,0 +1,56 @@ +import { requireNativeModule } from "expo-modules-core"; + +interface LocaleNativeModule { + /** + * Apply a per-app language preference at the OS level. This is what makes the + * App display name (launcher icon label), system permission dialogs, and + * other OS-rendered surfaces follow the user's in-app language choice rather + * than the device-wide language. + * + * - Android: uses `AppCompatDelegate.setApplicationLocales`, which delegates + * to the Android 13+ `LocaleManager` when available and falls back to + * in-storage tracking on older versions. Takes effect immediately for new + * Activities; the launcher refreshes the app name on the next foreground. + * - iOS: writes `AppleLanguages` to standard user defaults. The launcher + * label refreshes on the next cold start; permission dialogs that have not + * been shown yet pick up the new language on next trigger. + * + * Pass an empty string / null to clear the override and follow the system + * language again. + */ + setApplicationLocales(tag: string | null): Promise; + + /** + * Synchronously read the *device-level* preferred language as a BCP-47 tag, + * bypassing any per-app locale override we may have set ourselves. Returns + * `null` if the platform cannot resolve a language. + * + * This is the only reliable way to implement "follow system" when the user + * has previously applied a per-app override: `expo-localization.getLocales()` + * returns the *effective* locale (override applied), which would make + * switching back to "system" appear to do nothing. + */ + getSystemLanguageTag(): string | null; +} + +const LocaleModule = requireNativeModule("LocaleSwitcher"); + +/** + * Set the application-level locale override. Tag must be a BCP-47 language tag + * (e.g. "en", "zh-Hans"). Pass null or empty string to clear the override. + */ +export async function setApplicationLocales(tag: string | null): Promise { + await LocaleModule.setApplicationLocales(tag ?? null); +} + +/** + * Read the device-level system language as a BCP-47 tag, ignoring per-app + * overrides. Returns `null` if unavailable. + */ +export function getSystemLanguageTag(): string | null { + try { + return LocaleModule.getSystemLanguageTag(); + } catch { + return null; + } +} diff --git a/modules/locale/ios/Locale.podspec b/modules/locale/ios/Locale.podspec new file mode 100644 index 0000000..278a65b --- /dev/null +++ b/modules/locale/ios/Locale.podspec @@ -0,0 +1,19 @@ +Pod::Spec.new do |s| + s.name = 'Locale' + s.version = '1.0.0' + s.summary = '.' + s.homepage = 'https://github.com/tokenteam/iwut' + s.author = 'tokenteam' + s.platforms = { :ios => '16.4' } + s.swift_version = '5.9' + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.source_files = '**/*.{h,m,swift}' + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } +end diff --git a/modules/locale/ios/LocaleModule.swift b/modules/locale/ios/LocaleModule.swift new file mode 100644 index 0000000..beb1d21 --- /dev/null +++ b/modules/locale/ios/LocaleModule.swift @@ -0,0 +1,43 @@ +import ExpoModulesCore +import Foundation + +public class LocaleModule: Module { + public func definition() -> ModuleDefinition { + Name("LocaleSwitcher") + + AsyncFunction("setApplicationLocales") { (tag: String?) in + let defaults = UserDefaults.standard + if let tag = tag, !tag.isEmpty { + defaults.set([tag], forKey: "AppleLanguages") + } else { + defaults.removeObject(forKey: "AppleLanguages") + } + // Force-sync so changes are persisted before the next launch read. + defaults.synchronize() + } + + // Returns the device-level preferred language tag, ignoring per-app + // overrides written via `setApplicationLocales`. We use the global + // CFPreferences API with `kCFPreferencesAnyApplication`, which reads + // from the user's global domain rather than the current app's domain, + // so it bypasses our own `AppleLanguages` override. + // + // Caveat: on iOS, per-app locale overrides only take effect on cold + // launch anyway, so the value returned here is also the value that + // will be effective on next launch when running in "system" mode. + Function("getSystemLanguageTag") { () -> String? in + let langs = CFPreferencesCopyValue( + "AppleLanguages" as CFString, + kCFPreferencesAnyApplication, + kCFPreferencesCurrentUser, + kCFPreferencesAnyHost + ) as? [String] + if let first = langs?.first, !first.isEmpty { + return first + } + // Fallback: best-effort via NSLocale, may include our own override + // but better than returning nil. + return Locale.preferredLanguages.first + } + } +} diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt index a5d55b8..1a11ea4 100644 --- a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt @@ -1,6 +1,9 @@ package dev.tokenteam.iwut.widget +import android.app.LocaleManager import android.content.Context +import android.content.res.Configuration +import android.os.Build import com.google.gson.Gson import com.google.gson.annotations.SerializedName import java.text.SimpleDateFormat @@ -28,7 +31,6 @@ data class ScheduleWidgetData( object ScheduleData { private val gson = Gson() - private val DAY_NAMES = arrayOf("", "周一", "周二", "周三", "周四", "周五", "周六", "周日") fun load(context: Context): ScheduleWidgetData? { val prefs = context.getSharedPreferences("widget_data", Context.MODE_PRIVATE) @@ -40,8 +42,48 @@ object ScheduleData { } } + /** + * Returns a Context whose resources resolve strings using the in-app + * language synced from React Native, falling back to the device-level + * locale when nothing has been synced yet (e.g. widget added before + * first launch). + * + * The fallback intentionally avoids returning `context` as-is: on Android + * 13+, the app process's `context.resources.configuration.locales` can + * carry a stale per-app override left over from a previous in-app choice, + * which would make the widget render in the wrong language. We instead + * derive the locale from `LocaleManager.systemLocales` (API 33+) or + * `Resources.getSystem()` (older) so the widget always reflects either + * the explicit in-app choice or the true device locale. + */ + fun localizedContext(context: Context): Context { + val prefs = context.getSharedPreferences("widget_data", Context.MODE_PRIVATE) + val stored = prefs.getString("lang", null) + val locale: Locale = if (!stored.isNullOrEmpty()) { + Locale.forLanguageTag(stored) + } else { + systemLocale(context) + } + val config = Configuration(context.resources.configuration).apply { + setLocale(locale) + } + return context.createConfigurationContext(config) + } + + private fun systemLocale(context: Context): Locale { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val lm = context.getSystemService(LocaleManager::class.java) + val systemLocales = lm?.systemLocales + if (systemLocales != null && !systemLocales.isEmpty) { + return systemLocales.get(0) + } + } + val locales = android.content.res.Resources.getSystem().configuration.locales + return if (locales.isEmpty) Locale.getDefault() else locales.get(0) + } + fun getCurrentWeek(termStart: String): Int { - val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.US) val startDate = try { sdf.parse(termStart) ?: return 1 } catch (e: Exception) { @@ -71,14 +113,22 @@ object ScheduleData { return if (today == 7) week + 1 else week } - fun getWeekStr(week: Int): String = "第${week}周" + fun getWeekStr(context: Context, week: Int): String = + context.getString(R.string.widget_week_n, week) - fun getDateStr(): String { + fun getDateStr(context: Context): String { val cal = Calendar.getInstance() - return "${cal.get(Calendar.MONTH) + 1}月${cal.get(Calendar.DAY_OF_MONTH)}日" + return context.getString( + R.string.widget_month_day, + cal.get(Calendar.MONTH) + 1, + cal.get(Calendar.DAY_OF_MONTH), + ) } - fun getDayOfWeekStr(day: Int): String = DAY_NAMES.getOrElse(day) { "" } + fun getDayOfWeekStr(context: Context, day: Int): String { + val arr = context.resources.getStringArray(R.array.widget_weekdays) + return arr.getOrElse(day) { "" } + } fun parseTimeToMinutes(time: String): Int { val parts = time.split(":") diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt index d32b7ff..f39f3cb 100644 --- a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt @@ -50,10 +50,12 @@ class ScheduleWidget : AppWidgetProvider() { ) { val views = RemoteViews(context.packageName, R.layout.widget_schedule) val data = ScheduleData.load(context) + val ctx = ScheduleData.localizedContext(context) if (data == null || data.termStart.isEmpty()) { views.setViewVisibility(R.id.course_group, View.GONE) views.setViewVisibility(R.id.all_done_group, View.VISIBLE) + views.setTextViewText(R.id.tv_all_done, ctx.getString(R.string.widget_all_done)) setOnClickAction(context, views) appWidgetManager.updateAppWidget(appWidgetId, views) return @@ -64,9 +66,9 @@ class ScheduleWidget : AppWidgetProvider() { val tomorrowDay = ScheduleData.getTomorrowDayOfWeek() val tomorrowWeek = ScheduleData.getTomorrowWeek(data.termStart) - views.setTextViewText(R.id.tv_week, ScheduleData.getWeekStr(week)) - views.setTextViewText(R.id.tv_date, ScheduleData.getDateStr()) - views.setTextViewText(R.id.tv_day_of_week, ScheduleData.getDayOfWeekStr(today)) + views.setTextViewText(R.id.tv_week, ScheduleData.getWeekStr(ctx, week)) + views.setTextViewText(R.id.tv_date, ScheduleData.getDateStr(ctx)) + views.setTextViewText(R.id.tv_day_of_week, ScheduleData.getDayOfWeekStr(ctx, today)) val now = Calendar.getInstance() val nowMin = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) @@ -89,6 +91,7 @@ class ScheduleWidget : AppWidgetProvider() { if (combined.isEmpty()) { views.setViewVisibility(R.id.course_group, View.GONE) views.setViewVisibility(R.id.all_done_group, View.VISIBLE) + views.setTextViewText(R.id.tv_all_done, ctx.getString(R.string.widget_all_done)) appWidgetManager.updateAppWidget(appWidgetId, views) return } @@ -96,10 +99,13 @@ class ScheduleWidget : AppWidgetProvider() { views.setViewVisibility(R.id.course_group, View.VISIBLE) views.setViewVisibility(R.id.all_done_group, View.GONE) + val todayLabel = ctx.getString(R.string.widget_today) + val tomorrowLabel = ctx.getString(R.string.widget_tomorrow) + val (c1, c1IsToday) = combined[0] views.setViewVisibility(R.id.course_row_1, View.VISIBLE) views.setTextViewText(R.id.course_1_name, c1.name) - views.setTextViewText(R.id.course_1_tag, if (c1IsToday) "今天" else "明天") + views.setTextViewText(R.id.course_1_tag, if (c1IsToday) todayLabel else tomorrowLabel) views.setTextViewText(R.id.course_1_room, c1.room) views.setTextViewText(R.id.course_1_time, "${c1.startTime}-${c1.endTime}") @@ -108,24 +114,39 @@ class ScheduleWidget : AppWidgetProvider() { views.setViewVisibility(R.id.course_row_2, View.VISIBLE) views.setViewVisibility(R.id.tv_no_more, View.GONE) views.setTextViewText(R.id.course_2_name, c2.name) - views.setTextViewText(R.id.course_2_tag, if (c2IsToday) "今天" else "明天") + views.setTextViewText(R.id.course_2_tag, if (c2IsToday) todayLabel else tomorrowLabel) views.setTextViewText(R.id.course_2_room, c2.room) views.setTextViewText(R.id.course_2_time, "${c2.startTime}-${c2.endTime}") } else { views.setViewVisibility(R.id.course_row_2, View.GONE) views.setViewVisibility(R.id.tv_no_more, View.VISIBLE) + views.setTextViewText(R.id.tv_no_more, ctx.getString(R.string.widget_no_more)) } - val hintText: String - if (upcomingToday.isEmpty() && tomorrowCourses.isEmpty()) { - hintText = "今天和明天都没有课啦~" - } else { - val todayHint = - if (upcomingToday.isEmpty()) "今天没有课啦," else "今天还有${upcomingToday.size}节课," - val tomorrowHint = - if (tomorrowCourses.isEmpty()) "明天没有课啦~" else "明天还有${tomorrowCourses.size}节课" - hintText = todayHint + tomorrowHint - } + val hintText: String = + if (upcomingToday.isEmpty() && tomorrowCourses.isEmpty()) { + ctx.getString(R.string.widget_both_done) + } else { + val todayHint = if (upcomingToday.isEmpty()) { + ctx.getString(R.string.widget_today_done) + } else { + ctx.resources.getQuantityString( + R.plurals.widget_today_remaining, + upcomingToday.size, + upcomingToday.size, + ) + } + val tomorrowHint = if (tomorrowCourses.isEmpty()) { + ctx.getString(R.string.widget_tomorrow_done) + } else { + ctx.resources.getQuantityString( + R.plurals.widget_tomorrow_remaining, + tomorrowCourses.size, + tomorrowCourses.size, + ) + } + todayHint + tomorrowHint + } views.setViewVisibility(R.id.tv_course_hint, View.VISIBLE) views.setTextViewText(R.id.tv_course_hint, hintText) diff --git a/modules/widget/android/src/main/res/layout/widget_schedule.xml b/modules/widget/android/src/main/res/layout/widget_schedule.xml index 51d7ab4..c3b0cb2 100644 --- a/modules/widget/android/src/main/res/layout/widget_schedule.xml +++ b/modules/widget/android/src/main/res/layout/widget_schedule.xml @@ -249,7 +249,7 @@ android:layout_below="@id/course_row_1" android:layout_marginTop="17dp" android:layout_marginStart="5dp" - android:text="@string/widget_no_more_course" + android:text="@string/widget_no_more" android:textColor="@color/widget_schedule_text_primary" android:textSize="@dimen/widget_all_done_text_size" android:visibility="gone" @@ -283,7 +283,7 @@ android:layout_height="wrap_content" android:layout_marginStart="5dp" android:layout_marginTop="16dp" - android:text="@string/widget_all_courses_done" + android:text="@string/widget_all_done" android:textColor="@color/widget_schedule_text_primary" android:textSize="@dimen/widget_all_done_text_size" tools:ignore="SpUsage" /> diff --git a/modules/widget/android/src/main/res/values-en/widget_strings.xml b/modules/widget/android/src/main/res/values-en/widget_strings.xml new file mode 100644 index 0000000..8b4caa1 --- /dev/null +++ b/modules/widget/android/src/main/res/values-en/widget_strings.xml @@ -0,0 +1,30 @@ + + + All done — enjoy your break! + No more classes + Today + Tomorrow + No classes today or tomorrow — enjoy! + No classes left today, + + %d class left today, + %d classes left today, + + No classes tomorrow + + %d class tomorrow + %d classes tomorrow + + + + Mon + Tue + Wed + Thu + Fri + Sat + Sun + + Week %d + %1$d/%2$d + diff --git a/modules/widget/android/src/main/res/values/widget_strings.xml b/modules/widget/android/src/main/res/values/widget_strings.xml index f01c643..1904dad 100644 --- a/modules/widget/android/src/main/res/values/widget_strings.xml +++ b/modules/widget/android/src/main/res/values/widget_strings.xml @@ -1,5 +1,28 @@ - 没有更多课啦,放松一下吧~ - 没有更多课啦~ + 没有更多课啦,放松一下吧~ + 没有更多课啦~ + 今天 + 明天 + 今天和明天都没有课啦~ + 今天没有课啦, + + 今天还有%d节课, + + 明天没有课啦~ + + 明天还有%d节课 + + + + 周一 + 周二 + 周三 + 周四 + 周五 + 周六 + 周日 + + 第%d周 + %1$d月%2$d日 diff --git a/modules/widget/index.ts b/modules/widget/index.ts index bdb931a..6de7ad4 100644 --- a/modules/widget/index.ts +++ b/modules/widget/index.ts @@ -14,6 +14,18 @@ export async function setWidgetData( await WidgetModule.setWidgetData(key, JSON.stringify(data)); } +/** + * Writes a raw string value to the widget's shared storage (no JSON wrapping). + * Use this for scalar settings like the current language tag, which the + * native widget code reads as a plain string. + */ +export async function setWidgetString( + key: string, + value: string, +): Promise { + await WidgetModule.setWidgetData(key, value); +} + export async function reloadWidgets(): Promise { await WidgetModule.reloadWidgets(); } diff --git a/package.json b/package.json index 296f242..d4b239a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "android": "expo start --android", "ios": "expo start --ios", "lint": "expo lint", - "format": "prettier --write ." + "format": "prettier --write . --ignore-path .gitignore --ignore-path .prettierignore" }, "dependencies": { "@bacons/apple-targets": "^4.0.6", @@ -34,6 +34,7 @@ "expo-insights": "~56.0.9", "expo-linear-gradient": "~56.0.4", "expo-linking": "~56.0.8", + "expo-localization": "~56.0.4", "expo-router": "~56.2.1", "expo-secure-store": "~56.0.3", "expo-sharing": "~56.0.9", diff --git a/services/calendar-sync.ts b/services/calendar-sync.ts index bfb4f7b..3a90b64 100644 --- a/services/calendar-sync.ts +++ b/services/calendar-sync.ts @@ -2,11 +2,16 @@ import * as Calendar from "expo-calendar"; import { Platform } from "react-native"; import { getTermWeekMonday } from "@/lib/date"; +import { t } from "@/lib/i18n"; import { reportError } from "@/lib/report"; import { SECTION_TIMES } from "@/services/course-time"; import { type Course, useCourseStore } from "@/store/course"; -const CALENDAR_TITLE = "掌上吾理-我的课表"; +// Calendar entries are re-created on every sync, so we use the current locale +// at sync time rather than caching a value at module load. +function getCalendarTitle(): string { + return t("calSync.title"); +} const CALENDAR_COLOR = "#007AFF"; export async function requestCalendarPermission(): Promise { @@ -18,20 +23,24 @@ async function findAppCalendar(): Promise { const calendars = await Calendar.getCalendarsAsync( Calendar.EntityTypes.EVENT, ); - const found = calendars.find((c) => c.title === CALENDAR_TITLE); + // Match by either the current locale's title or the legacy zh title so we + // can clean up stale calendars after a language switch. + const candidates = new Set([t("calSync.title"), "掌上吾理-我的课表"]); + const found = calendars.find((c) => candidates.has(c.title ?? "")); return found?.id ?? null; } async function createAppCalendar(): Promise { + const title = getCalendarTitle(); if (Platform.OS === "ios") { const defaultCalendar = await Calendar.getDefaultCalendarAsync(); const id = await Calendar.createCalendarAsync({ - title: CALENDAR_TITLE, + title, color: CALENDAR_COLOR, entityType: Calendar.EntityTypes.EVENT, sourceId: defaultCalendar.source.id, source: defaultCalendar.source, - name: CALENDAR_TITLE, + name: title, ownerAccount: "personal", accessLevel: Calendar.CalendarAccessLevel.OWNER, }); @@ -47,7 +56,7 @@ async function createAppCalendar(): Promise { )?.source; const id = await Calendar.createCalendarAsync({ - title: CALENDAR_TITLE, + title, color: CALENDAR_COLOR, entityType: Calendar.EntityTypes.EVENT, sourceId: localSource?.id, @@ -55,10 +64,10 @@ async function createAppCalendar(): Promise { localSource ?? ({ isLocalAccount: true, - name: CALENDAR_TITLE, + name: title, type: Calendar.SourceType?.LOCAL ?? ("LOCAL" as Calendar.SourceType), } as Calendar.Source), - name: CALENDAR_TITLE, + name: title, ownerAccount: "personal", accessLevel: Calendar.CalendarAccessLevel.OWNER, }); @@ -96,12 +105,12 @@ export async function syncCoursesToCalendar(): Promise<{ }> { const hasPermission = await requestCalendarPermission(); if (!hasPermission) { - return { success: false, count: 0, error: "没有日历访问权限" }; + return { success: false, count: 0, error: t("calSync.errNoPermission") }; } const { courses, termStart } = useCourseStore.getState(); if (!termStart || courses.length === 0) { - return { success: false, count: 0, error: "没有课程数据或学期开始时间" }; + return { success: false, count: 0, error: t("calSync.errNoData") }; } try { @@ -141,12 +150,12 @@ export async function syncCoursesToCalendar(): Promise<{ } if (count === 0 && reported) { - return { success: false, count: 0, error: "事件写入失败" }; + return { success: false, count: 0, error: t("calSync.errWriteFail") }; } return { success: true, count }; } catch (e) { reportError(e, { module: "calendar-sync" }); - const msg = e instanceof Error ? e.message : "未知错误"; + const msg = e instanceof Error ? e.message : t("calSync.errUnknown"); return { success: false, count: 0, error: msg }; } } @@ -186,7 +195,9 @@ function createEventsForCourse( startDate: startDate.getTime() as unknown as Date, endDate: endDate.getTime() as unknown as Date, alarms: [{ relativeOffset: -15 }], - notes: course.teacher ? `教师: ${course.teacher}` : undefined, + notes: course.teacher + ? t("calSync.teacherNotes", { teacher: course.teacher }) + : undefined, timeZone: "Asia/Shanghai", }); } diff --git a/services/course-notification.ts b/services/course-notification.ts index 2921c02..fdc8134 100644 --- a/services/course-notification.ts +++ b/services/course-notification.ts @@ -2,6 +2,8 @@ import * as BackgroundTask from "expo-background-task"; import * as TaskManager from "expo-task-manager"; import { Platform } from "react-native"; +import { getCurrentWeek, getTermWeekMonday } from "@/lib/date"; +import { t } from "@/lib/i18n"; import { cancelAll, createChannel, @@ -11,7 +13,6 @@ import { import { SECTION_TIMES } from "@/services/course-time"; import { useCourseStore } from "@/store/course"; import { useSettingsStore } from "@/store/settings"; -import { getCurrentWeek, getTermWeekMonday } from "@/lib/date"; const CHANNEL_ID = "course_reminder"; const BACKGROUND_TASK_NAME = "course-reminder-refresh"; @@ -48,7 +49,14 @@ export async function unregisterBackgroundRefresh(): Promise { export async function initNotificationChannel(): Promise { if (Platform.OS === "android") { - await createChannel(CHANNEL_ID, "课程提醒", "在课程开始前显示倒计时通知"); + // Android caches the channel name/description after first creation, so + // language changes won't update an already-created channel. Fresh installs + // (or new channels) still pick up the current locale. + await createChannel( + CHANNEL_ID, + t("notif.channelName"), + t("notif.channelDesc"), + ); } } diff --git a/services/get-course.tsx b/services/get-course.tsx index 1328d85..888c48a 100644 --- a/services/get-course.tsx +++ b/services/get-course.tsx @@ -21,6 +21,7 @@ import { WebView, type WebViewMessageEvent } from "react-native-webview"; import { IS_DEV } from "@/constants/is-dev"; import { useZhlgdAutoLogin } from "@/hooks/use-zhlgd-autologin"; +import { useT } from "@/lib/i18n"; import { reportError } from "@/lib/report"; import { syncWidgetData } from "@/services/widget-sync"; import { type Course, type ImportType, useCourseStore } from "@/store/course"; @@ -30,7 +31,15 @@ const BACHELOR_LOGIN_URL = "https://zhlgd.whut.edu.cn/tpass/login?service=https%3A%2F%2Fjwxt.whut.edu.cn%2Fjwapp%2Fsys%2Fhomeapp%2Findex.do%3FforceCas%3D1"; const BACHELOR_HOME_PREFIX = "https://jwxt.whut.edu.cn/jwapp/sys/homeapp/"; -const BACHELOR_FETCH_SCRIPT = `(async function() { +function jsString(s: string): string { + return JSON.stringify(s); +} + +function buildBachelorFetchScript(messages: { + fetchUserFailed: string; + noTermData: string; +}): string { + return `(async function() { var log = function(s){ window.ReactNativeWebView.postMessage(JSON.stringify({type:'debug', message:s})); }; try { log('script started, url=' + location.href); @@ -46,7 +55,7 @@ const BACHELOR_FETCH_SCRIPT = `(async function() { var term = (ud.welcomeInfo && ud.welcomeInfo.xnxqdm) || ''; log('user=' + xh + ' term=' + term); if (!xh || !term) { - window.ReactNativeWebView.postMessage(JSON.stringify({type:'error', message:'获取用户信息失败'})); + window.ReactNativeWebView.postMessage(JSON.stringify({type:'error', message:${jsString(messages.fetchUserFailed)}})); return; } var resp = await fetch('/jwapp/sys/kcbcxby/modules/xskcb/cxxskcb.do', { @@ -63,7 +72,7 @@ const BACHELOR_FETCH_SCRIPT = `(async function() { var data = JSON.parse(text); var rows = data.datas && data.datas.cxxskcb && data.datas.cxxskcb.rows; if (!rows || !rows.length) { - window.ReactNativeWebView.postMessage(JSON.stringify({type:'error', message:'当前学期(' + term + ')无课程数据'})); + window.ReactNativeWebView.postMessage(JSON.stringify({type:'error', message:${jsString(messages.noTermData)}.replace('{term}', term)})); return; } var courses = []; @@ -102,6 +111,7 @@ const BACHELOR_FETCH_SCRIPT = `(async function() { })); } })(); true;`; +} // 研究生 const MASTER_LOGIN_URL = @@ -207,6 +217,7 @@ export interface GetCourseHandle { export const GetCourse = forwardRef( function GetCourse(_, ref) { + const t = useT(); const [importing, setImporting] = useState(false); const [importType, setImportType] = useState("bachelor"); const webview = useRef(null); @@ -220,7 +231,7 @@ export const GetCourse = forwardRef( sms, smsNode, } = useZhlgdAutoLogin(webview, { - onCancel: () => finishRef.current(false, "已取消短信验证"), + onCancel: () => finishRef.current(false, t("course.smsCancelled")), }); useImperativeHandle(ref, () => ({ @@ -262,36 +273,39 @@ export const GetCourse = forwardRef( opacity: interpolate(ripple.value, [0, 0.4, 1], [0.4, 0.15, 0]), })); - const finish = useCallback((success: boolean, message?: string) => { - setImporting(false); - injected.current = false; - if (success) { - Toast.show({ - type: "success", - text1: "导入成功", - text2: "好耶!", - position: "bottom", - }); - } else { - Toast.show({ - type: "error", - text1: "导入失败", - text2: message || "请检查网络连接并重试", - position: "bottom", - }); - } - }, []); + const finish = useCallback( + (success: boolean, message?: string) => { + setImporting(false); + injected.current = false; + if (success) { + Toast.show({ + type: "success", + text1: t("course.importSuccess"), + text2: t("course.importSuccessSub"), + position: "bottom", + }); + } else { + Toast.show({ + type: "error", + text1: t("course.importFail"), + text2: message || t("course.importFailSub"), + position: "bottom", + }); + } + }, + [t], + ); finishRef.current = finish; useEffect(() => { if (!importing || sms.visible) return; const timeout = setTimeout(() => { if (!injected.current) { - finish(false, "加载超时,请检查网络连接并重试"); + finish(false, t("course.importTimeout")); } }, 30000); return () => clearTimeout(timeout); - }, [importing, sms.visible, finish]); + }, [importing, sms.visible, finish, t]); const handleError = useCallback( (syntheticEvent: { @@ -303,9 +317,9 @@ export const GetCourse = forwardRef( webviewUrl: url, webviewCode: code, }); - finish(false, "请检查网络连接并重试"); + finish(false, t("course.importFailSub")); }, - [importType, finish], + [importType, finish, t], ); const handleLoadEnd = useCallback( @@ -317,8 +331,12 @@ export const GetCourse = forwardRef( if (importType === "bachelor" && url.startsWith(BACHELOR_HOME_PREFIX)) { injected.current = true; + const script = buildBachelorFetchScript({ + fetchUserFailed: t("course.fetchUserFailed"), + noTermData: t("course.noTermData"), + }); setTimeout(() => { - webview.current?.injectJavaScript(BACHELOR_FETCH_SCRIPT); + webview.current?.injectJavaScript(script); }, 1500); } @@ -332,7 +350,7 @@ export const GetCourse = forwardRef( }, 3000); } }, - [autoLoginOnLoadEnd, importType], + [autoLoginOnLoadEnd, importType, t], ); const handleMessage = useCallback( @@ -383,7 +401,7 @@ export const GetCourse = forwardRef( })); if (courses.length === 0) { - finish(false, "课表数据解析失败"); + finish(false, t("course.parseFailed")); return; } @@ -410,7 +428,7 @@ export const GetCourse = forwardRef( })); if (courses.length === 0) { - finish(false, "课表数据解析失败"); + finish(false, t("course.parseFailed")); return; } @@ -421,7 +439,7 @@ export const GetCourse = forwardRef( finish(true); } }, - [importType, finish, autoLoginOnMessage], + [importType, finish, autoLoginOnMessage, t], ); if (!importing) return null; @@ -490,7 +508,7 @@ export const GetCourse = forwardRef( color: "#1f2937", }} > - 正在导入 + {t("course.importing")} ( color: "#9ca3af", }} > - 头抬起,坐和放宽... + {t("course.importingSub")} diff --git a/services/widget-sync.ts b/services/widget-sync.ts index 58084ef..e645eee 100644 --- a/services/widget-sync.ts +++ b/services/widget-sync.ts @@ -1,4 +1,9 @@ -import { reloadWidgets, setWidgetData } from "@/modules/widget"; +import { getLang } from "@/lib/i18n"; +import { + reloadWidgets, + setWidgetData, + setWidgetString, +} from "@/modules/widget"; import { SECTION_TIMES } from "@/services/course-time"; import { useCourseStore } from "@/store/course"; @@ -20,6 +25,30 @@ interface ScheduleWidgetData { updatedAt: string; } +/** + * Push the user's explicit language choice to the widget's shared storage so + * its native code can render in the same language as the app. Safe to call + * any time the language changes; widgets will pick it up on their next + * refresh. + * + * For "system" we intentionally push an empty string rather than the current + * resolved tag. This delegates the resolution to the widget's own native + * code, which reads `LocaleManager.systemLocales` (Android 13+) or the + * equivalent device-level API. Two benefits: + * + * 1. Switching back to "follow system" takes effect immediately on the + * widget without depending on the in-process locale state of the RN + * runtime, which may still be stale right after the switch. + * 2. Subsequent changes to the *device* language while the app stays in + * "system" mode are picked up by the widget on its next refresh, + * without requiring the RN runtime to be alive to re-sync. + */ +export async function syncWidgetLang(): Promise { + const choice = getLang(); + const tag = choice === "zh" ? "zh-Hans" : choice === "en" ? "en" : ""; + await setWidgetString("lang", tag); +} + export async function syncWidgetData(): Promise { const { courses, termStart } = useCourseStore.getState(); if (!termStart || courses.length === 0) return; @@ -43,5 +72,6 @@ export async function syncWidgetData(): Promise { }; await setWidgetData("schedule", data as unknown as Record); + await syncWidgetLang(); await reloadWidgets(); } diff --git a/services/wlan.ts b/services/wlan.ts index 8a113b0..4c15e6e 100644 --- a/services/wlan.ts +++ b/services/wlan.ts @@ -2,6 +2,7 @@ import { Platform } from "react-native"; import TcpSocket from "react-native-tcp-socket"; import WifiManager from "react-native-wifi-reborn"; +import { t } from "@/lib/i18n"; import { reportWifiConnectivity } from "@/modules/network-reporter"; const GATEWAY = "http://172.30.21.100"; @@ -43,7 +44,7 @@ function getNasId(): Promise { socket.on("error", () => { socket.destroy(); - reject(new Error("网络连接异常,请检查网络连接并重试")); + reject(new Error(t("wlan.errNetwork"))); }); }); } @@ -56,7 +57,7 @@ export async function login( try { await WifiManager.forceWifiUsageWithOptions(true, { noInternet: true }); } catch { - throw new Error("非校园网环境,请连接校园网后重试"); + throw new Error(t("wlan.errNotCampus")); } } diff --git a/store/settings.ts b/store/settings.ts index 19b55fb..c3b7d27 100644 --- a/store/settings.ts +++ b/store/settings.ts @@ -1,7 +1,11 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +import { type Lang, setLang } from "@/lib/i18n"; import { zustandStorage } from "@/lib/storage"; +import { setApplicationLocales } from "@/modules/locale"; +import { reloadWidgets } from "@/modules/widget"; +import { syncWidgetLang } from "@/services/widget-sync"; interface SettingsStore { hapticFeedback: boolean; @@ -9,11 +13,13 @@ interface SettingsStore { courseReminder: boolean; reminderMinutes: number; calendarSync: boolean; + language: Lang; setHapticFeedback: (value: boolean) => void; setOpenCourseOnLaunch: (value: boolean) => void; setCourseReminder: (value: boolean) => void; setReminderMinutes: (value: number) => void; setCalendarSync: (value: boolean) => void; + setLanguage: (value: Lang) => void; } export const useSettingsStore = create()( @@ -24,16 +30,56 @@ export const useSettingsStore = create()( courseReminder: false, reminderMinutes: 30, calendarSync: false, + language: "system", setHapticFeedback: (value: boolean) => set({ hapticFeedback: value }), setOpenCourseOnLaunch: (value: boolean) => set({ openCourseOnLaunch: value }), setCourseReminder: (value: boolean) => set({ courseReminder: value }), setReminderMinutes: (value: number) => set({ reminderMinutes: value }), setCalendarSync: (value: boolean) => set({ calendarSync: value }), + setLanguage: (value: Lang) => { + set({ language: value }); + // Sync OS-level locale first so the App display name (launcher icon + // label) and system permission dialogs follow the in-app choice. + // "system" clears the override and falls back to the device language. + const tag = value === "zh" ? "zh-Hans" : value === "en" ? "en" : null; + void setApplicationLocales(tag).catch(() => { + // Native module unavailable in some environments (e.g. Expo Go); + // RN-side translations still work, the native surfaces just stay + // on the previous OS locale. + }); + // Resolve and notify RN-side listeners. `setLang("system")` reads the + // device-level locale via the native module, so it does not depend on + // the async `setApplicationLocales` call above having completed. + setLang(value); + // Push the resolved language to native widgets and refresh them so + // their text matches the new language immediately. + void syncWidgetLang() + .then(() => reloadWidgets()) + .catch(() => { + // Widget module may not be available in every build target. + }); + }, }), { name: "settings", storage: zustandStorage, + onRehydrateStorage: () => (state) => { + if (state?.language) { + setLang(state.language); + // Re-apply the OS-level override on every cold start so the launcher + // label / permission dialogs stay aligned with the user's in-app + // choice (Android persists per-app locales itself, but iOS reads + // AppleLanguages from UserDefaults which we manage here). + const tag = + state.language === "zh" + ? "zh-Hans" + : state.language === "en" + ? "en" + : null; + void setApplicationLocales(tag).catch(() => {}); + } + }, }, ), ); diff --git a/targets/widget/Models/WidgetData.swift b/targets/widget/Models/WidgetData.swift index 5dad51c..56779ee 100644 --- a/targets/widget/Models/WidgetData.swift +++ b/targets/widget/Models/WidgetData.swift @@ -28,8 +28,6 @@ struct ScheduleWidgetData: Codable { } struct ScheduleHelper { - private static let dayNames = ["", "周一", "周二", "周三", "周四", "周五", "周六", "周日"] - static func currentWeek(termStart: String) -> Int { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" @@ -60,19 +58,18 @@ struct ScheduleHelper { } static func weekStr(week: Int) -> String { - "第\(week)周" + String(format: WidgetStrings.localized("widget.weekDisplay"), week) } static func dateStr(for date: Date = .now) -> String { let cal = Calendar.current let month = cal.component(.month, from: date) let day = cal.component(.day, from: date) - return "\(month)月\(day)日" + return String(format: WidgetStrings.localized("widget.monthDay"), month, day) } static func dayOfWeekStr(day: Int) -> String { - guard day >= 1 && day <= 7 else { return "" } - return dayNames[day] + WidgetStrings.dayOfWeek(day) } static func parseTimeToMinutes(_ time: String) -> Int { diff --git a/targets/widget/ScheduleTimelineProvider.swift b/targets/widget/ScheduleTimelineProvider.swift index 92de3b3..d22b6e2 100644 --- a/targets/widget/ScheduleTimelineProvider.swift +++ b/targets/widget/ScheduleTimelineProvider.swift @@ -21,7 +21,7 @@ struct ScheduleEntry: TimelineEntry { } var weekStr: String { - guard let data = data else { return "第-周" } + guard let data = data else { return WidgetStrings.localized("widget.weekUnknown") } return ScheduleHelper.weekStr(week: ScheduleHelper.currentWeek(termStart: data.termStart)) } diff --git a/targets/widget/ScheduleWidget.swift b/targets/widget/ScheduleWidget.swift index 22cee59..028da62 100644 --- a/targets/widget/ScheduleWidget.swift +++ b/targets/widget/ScheduleWidget.swift @@ -15,8 +15,12 @@ struct ScheduleWidget: Widget { } } .safeContentMarginsDisabled() - .configurationDisplayName("课程表") - .description("今天有什么课?看这里就够啦~") + // configurationDisplayName / description show in the system widget + // gallery and cannot read App Group preferences (the widget is not + // instantiated yet). Resolve via standard bundle lookup so they + // follow the device locale instead. + .configurationDisplayName(LocalizedStringKey("widget.displayName")) + .description(LocalizedStringKey("widget.description")) .supportedFamilies([.systemMedium]) } } diff --git a/targets/widget/Views/CourseInfoView.swift b/targets/widget/Views/CourseInfoView.swift index 45d855b..d5b8b5f 100644 --- a/targets/widget/Views/CourseInfoView.swift +++ b/targets/widget/Views/CourseInfoView.swift @@ -5,7 +5,9 @@ struct CourseInfoView: View { let isToday: Bool private var tagText: String { - isToday ? "今天" : "明天" + isToday + ? WidgetStrings.localized("widget.today") + : WidgetStrings.localized("widget.tomorrow") } var body: some View { @@ -30,7 +32,9 @@ struct CourseInfoView: View { } HStack(alignment: .bottom, spacing: 0) { - Text(course.room.isEmpty ? "暂无教室信息" : course.room) + Text(course.room.isEmpty + ? WidgetStrings.localized("widget.noRoom") + : course.room) .font(.system(size: 12)) .lineLimit(2) .foregroundColor(Color("TextSecondary")) diff --git a/targets/widget/Views/EmptyCourseView.swift b/targets/widget/Views/EmptyCourseView.swift index 058e233..65f346c 100644 --- a/targets/widget/Views/EmptyCourseView.swift +++ b/targets/widget/Views/EmptyCourseView.swift @@ -3,7 +3,7 @@ import SwiftUI struct EmptyCourseView: View { var body: some View { HStack(spacing: 0) { - Text("没有更多课啦,放松一下吧~") + Text(WidgetStrings.localized("widget.allDone")) .foregroundColor(Color("TextPrimary")) .font(.system(size: 14)) .padding(.leading, 8) diff --git a/targets/widget/Views/ScheduleWidgetEntryView.swift b/targets/widget/Views/ScheduleWidgetEntryView.swift index 84b34b9..9fd5473 100644 --- a/targets/widget/Views/ScheduleWidgetEntryView.swift +++ b/targets/widget/Views/ScheduleWidgetEntryView.swift @@ -16,21 +16,27 @@ struct ScheduleWidgetEntryView: View { let tomorrowCount = entry.tomorrowCourses.count if upcomingCount == 0 && tomorrowCount == 0 { - return "今天和明天都没有课啦~" + return WidgetStrings.localized("widget.bothDone") } let todayPart: String if upcomingCount == 0 { - todayPart = "今天没有课啦," + todayPart = WidgetStrings.localized("widget.todayDone") } else { - todayPart = "今天还有\(upcomingCount)节课," + let key = upcomingCount == 1 + ? "widget.todayRemaining.one" + : "widget.todayRemaining.other" + todayPart = String(format: WidgetStrings.localized(key), upcomingCount) } let tomorrowPart: String if tomorrowCount == 0 { - tomorrowPart = "明天没有课啦~" + tomorrowPart = WidgetStrings.localized("widget.tomorrowDone") } else { - tomorrowPart = "明天还有\(tomorrowCount)节课" + let key = tomorrowCount == 1 + ? "widget.tomorrowRemaining.one" + : "widget.tomorrowRemaining.other" + tomorrowPart = String(format: WidgetStrings.localized(key), tomorrowCount) } return todayPart + tomorrowPart @@ -62,7 +68,7 @@ struct ScheduleWidgetEntryView: View { } if displayCourses.count == 1 { - Text("没有更多课啦~") + Text(WidgetStrings.localized("widget.noMore")) .foregroundColor(Color("TextPrimary")) .font(.system(size: 12)) .padding(.top, 8) diff --git a/targets/widget/WidgetStrings.swift b/targets/widget/WidgetStrings.swift new file mode 100644 index 0000000..3d02287 --- /dev/null +++ b/targets/widget/WidgetStrings.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Localization helper for the widget. Resolves strings using the in-app +/// language synced from React Native via App Group `UserDefaults` (key: +/// `lang`), falling back to the device locale when nothing has been synced +/// yet (e.g. widget added before first app launch). +enum WidgetStrings { + static func localized(_ key: String) -> String { + let bundle = preferredBundle() + return bundle.localizedString(forKey: key, value: nil, table: nil) + } + + /// Returns the localized day-of-week label for ISO weekday 1..7 (Mon..Sun). + static func dayOfWeek(_ day: Int) -> String { + switch day { + case 1: return localized("widget.weekday.mon") + case 2: return localized("widget.weekday.tue") + case 3: return localized("widget.weekday.wed") + case 4: return localized("widget.weekday.thu") + case 5: return localized("widget.weekday.fri") + case 6: return localized("widget.weekday.sat") + case 7: return localized("widget.weekday.sun") + default: return "" + } + } + + private static func preferredBundle() -> Bundle { + let defaults = UserDefaults(suiteName: "group.dev.tokenteam.iwut") + let tag = defaults?.string(forKey: "lang") ?? "" + guard !tag.isEmpty else { return .main } + + // Try the exact tag first (e.g. "zh-Hans"), then the base language + // (e.g. "zh") so callers can pass either form. + let candidates: [String] + if tag.contains("-") { + let base = String(tag.split(separator: "-").first ?? "") + candidates = [tag, base] + } else { + candidates = [tag] + } + + for candidate in candidates { + if let path = Bundle.main.path(forResource: candidate, ofType: "lproj"), + let bundle = Bundle(path: path) { + return bundle + } + } + return .main + } +} diff --git a/targets/widget/en.lproj/Localizable.strings b/targets/widget/en.lproj/Localizable.strings new file mode 100644 index 0000000..7010750 --- /dev/null +++ b/targets/widget/en.lproj/Localizable.strings @@ -0,0 +1,24 @@ +"widget.allDone" = "All done — enjoy your break!"; +"widget.noMore" = "No more classes"; +"widget.today" = "Today"; +"widget.tomorrow" = "Tomorrow"; +"widget.bothDone" = "No classes today or tomorrow — enjoy!"; +"widget.todayDone" = "No classes left today, "; +"widget.tomorrowDone" = "No classes tomorrow"; +"widget.todayRemaining.one" = "%d class left today, "; +"widget.todayRemaining.other" = "%d classes left today, "; +"widget.tomorrowRemaining.one" = "%d class tomorrow"; +"widget.tomorrowRemaining.other" = "%d classes tomorrow"; +"widget.weekDisplay" = "Week %d"; +"widget.monthDay" = "%1$d/%2$d"; +"widget.noRoom" = "No room"; +"widget.weekUnknown" = "Week -"; +"widget.displayName" = "Schedule"; +"widget.description" = "Your classes for today."; +"widget.weekday.mon" = "Mon"; +"widget.weekday.tue" = "Tue"; +"widget.weekday.wed" = "Wed"; +"widget.weekday.thu" = "Thu"; +"widget.weekday.fri" = "Fri"; +"widget.weekday.sat" = "Sat"; +"widget.weekday.sun" = "Sun"; diff --git a/targets/widget/zh-Hans.lproj/Localizable.strings b/targets/widget/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..54c0f6b --- /dev/null +++ b/targets/widget/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,24 @@ +"widget.allDone" = "没有更多课啦,放松一下吧~"; +"widget.noMore" = "没有更多课啦~"; +"widget.today" = "今天"; +"widget.tomorrow" = "明天"; +"widget.bothDone" = "今天和明天都没有课啦~"; +"widget.todayDone" = "今天没有课啦,"; +"widget.tomorrowDone" = "明天没有课啦~"; +"widget.todayRemaining.one" = "今天还有%d节课,"; +"widget.todayRemaining.other" = "今天还有%d节课,"; +"widget.tomorrowRemaining.one" = "明天还有%d节课"; +"widget.tomorrowRemaining.other" = "明天还有%d节课"; +"widget.weekDisplay" = "第%d周"; +"widget.monthDay" = "%1$d月%2$d日"; +"widget.noRoom" = "暂无教室信息"; +"widget.weekUnknown" = "第-周"; +"widget.displayName" = "课程表"; +"widget.description" = "今天有什么课?看这里就够啦~"; +"widget.weekday.mon" = "周一"; +"widget.weekday.tue" = "周二"; +"widget.weekday.wed" = "周三"; +"widget.weekday.thu" = "周四"; +"widget.weekday.fri" = "周五"; +"widget.weekday.sat" = "周六"; +"widget.weekday.sun" = "周日";