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() {
-
+
-
+
-
+
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" = "周日";