diff --git a/app/(pages)/settings/calendar.tsx b/app/(pages)/settings/calendar.tsx index 5783a14..82d12f8 100644 --- a/app/(pages)/settings/calendar.tsx +++ b/app/(pages)/settings/calendar.tsx @@ -1,15 +1,30 @@ import { Stack } from "expo-router"; import { useMemo, useState } from "react"; -import { ActivityIndicator, ScrollView, Switch } from "react-native"; +import { + ActivityIndicator, + Platform, + Pressable, + ScrollView, + Switch, + Text, + View, +} from "react-native"; import Toast from "react-native-toast-message"; +import { BottomSheet } from "@/components/ui/bottom-sheet"; +import { ConfirmSheet } from "@/components/ui/confirm-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 { useMarkRouteInteractive } from "@/hooks/use-mark-route-interactive"; import { useT } from "@/lib/i18n"; import { + APP_LOCAL_CALENDAR_ID, deleteAppCalendar, + getWritableCalendars, + requestCalendarPermission, syncCoursesToCalendar, + type CalendarInfo, } from "@/services/calendar-sync"; import { useCourseStore } from "@/store/course"; import { useScheduleStore } from "@/store/schedule"; @@ -30,21 +45,65 @@ export default function CalendarSettingsScreen() { const colorPalette = useScheduleStore((s) => s.colorPalette); const courses = useCourseStore((s) => s.courses); + const termStart = useCourseStore((s) => s.termStart); const calendarSync = useSettingsStore((s) => s.calendarSync); const setCalendarSync = useSettingsStore((s) => s.setCalendarSync); + const syncedCalendarIds = useSettingsStore((s) => s.syncedCalendarIds); const [syncing, setSyncing] = useState(false); + const [pickerVisible, setPickerVisible] = useState(false); + const [writableCalendars, setWritableCalendars] = useState( + [], + ); + const [selectedCalendarIds, setSelectedCalendarIds] = useState>( + () => new Set([APP_LOCAL_CALENDAR_ID]), + ); + const [confirmRemoveVisible, setConfirmRemoveVisible] = useState(false); + const [pendingOn, setPendingOn] = useState(false); + const displaySwitchOn = calendarSync || pickerVisible || pendingOn; + const localCalendarSelected = selectedCalendarIds.has(APP_LOCAL_CALENDAR_ID); + const externalCalendarSelected = [...selectedCalendarIds].some( + (id) => id !== APP_LOCAL_CALENDAR_ID, + ); const courseCount = useMemo(() => { const names = new Set(courses.map((c) => c.name)); return names.size; }, [courses]); - const handleCalendarSyncToggle = async (value: boolean) => { - if (value) { - setSyncing(true); - const result = await syncCoursesToCalendar(); + const showSyncError = (message?: string) => { + Toast.show({ + type: "error", + text1: t("calendarSet.syncFailed"), + text2: message, + position: "bottom", + }); + }; + + const performRemove = async () => { + setSyncing(true); + try { + const result = await deleteAppCalendar(); + if (result.success) { + setCalendarSync(false); + Toast.show({ + type: "success", + text1: t("calendarSet.syncRemoved"), + position: "bottom", + }); + } else { + showSyncError(result.error); + } + } finally { setSyncing(false); + } + }; + + const doSync = async (calendarIds?: string[]) => { + setPickerVisible(false); + setSyncing(true); + try { + const result = await syncCoursesToCalendar(calendarIds); if (result.success) { setCalendarSync(true); Toast.show({ @@ -60,24 +119,68 @@ export default function CalendarSettingsScreen() { position: "bottom", }); } else { - Toast.show({ - type: "error", - text1: t("calendarSet.syncFailed"), - text2: result.error, - position: "bottom", - }); + showSyncError(result.error); } - } else { - await deleteAppCalendar(); - setCalendarSync(false); - Toast.show({ - type: "success", - text1: t("calendarSet.syncRemoved"), - position: "bottom", - }); + } finally { + setSyncing(false); } }; + const handleCalendarSyncToggle = async (value: boolean) => { + if (!value) { + const syncedToNonLocal = syncedCalendarIds.some( + (id) => id !== APP_LOCAL_CALENDAR_ID, + ); + if (Platform.OS === "android" && syncedToNonLocal) { + setConfirmRemoveVisible(true); + return; + } + await performRemove(); + return; + } + + if (!termStart || courses.length === 0) { + showSyncError(t("calSync.errNoData")); + return; + } + + setPendingOn(true); + + try { + const hasPerm = await requestCalendarPermission(); + if (!hasPerm) { + showSyncError(t("calSync.errNoPermission")); + return; + } + + const calendars = await getWritableCalendars(); + + if (calendars.length === 0) { + await doSync(undefined); + return; + } + + setWritableCalendars(calendars); + setSelectedCalendarIds(new Set([APP_LOCAL_CALENDAR_ID])); + setPickerVisible(true); + } catch (error) { + showSyncError( + error instanceof Error ? error.message : t("calSync.errUnknown"), + ); + } finally { + setPendingOn(false); + } + }; + + const toggleCalendar = (id: string) => { + setSelectedCalendarIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + const paletteKey = BUILTIN_PALETTE_NAME_KEYS[colorPalette.name]; const paletteDisplayName = paletteKey ? t(paletteKey) : colorPalette.name; @@ -144,7 +247,8 @@ export default function CalendarSettingsScreen() { ) : ( ) @@ -168,6 +272,106 @@ export default function CalendarSettingsScreen() { /> + + setPickerVisible(false)} + title={t("calendarSet.pickerTitle")} + > + + {t("calendarSet.pickerHint")} + + + + {t("calendarSet.pickerLocalGroup")} + + toggleCalendar(APP_LOCAL_CALENDAR_ID)} + right={ + + } + /> + + {writableCalendars.length > 0 && ( + <> + + {t("calendarSet.pickerOther")} + + {writableCalendars.map((calendar) => { + const selected = selectedCalendarIds.has(calendar.id); + return ( + toggleCalendar(calendar.id)} + right={ + + } + /> + ); + })} + + )} + + + {externalCalendarSelected && ( + + {t("calendarSet.pickerOtherWarning")} + + )} + + + 0 + ? "bg-blue-500 active:bg-blue-600" + : "bg-neutral-300 dark:bg-neutral-700" + }`} + disabled={selectedCalendarIds.size === 0} + onPress={() => void doSync([...selectedCalendarIds])} + > + + {t("calendarSet.pickerSync")} + + + + + + setConfirmRemoveVisible(false)} + title={t("calendarSet.removeConfirmTitle")} + description={t("calendarSet.removeConfirmDesc")} + confirmText={t("calendarSet.removeConfirmOk")} + destructive + onConfirm={() => { + setConfirmRemoveVisible(false); + void performRemove(); + }} + /> ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index e90a4a3..3fafa92 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -47,7 +47,10 @@ import { UpdateModal } from "@/components/ui/update-modal"; import { Colors, Themes } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { refreshSystemLocale } from "@/lib/i18n"; -import { syncCoursesToCalendar } from "@/services/calendar-sync"; +import { + clearSyncedCalendarData, + syncCoursesToCalendar, +} from "@/services/calendar-sync"; import { initNotificationChannel, registerBackgroundRefresh, @@ -175,7 +178,14 @@ function RootLayout() { syncWidgetData().catch(() => {}); scheduleWeeklyReminders().catch(() => {}); if (useSettingsStore.getState().calendarSync) { - syncCoursesToCalendar().catch(() => {}); + const ids = useSettingsStore.getState().syncedCalendarIds; + if (!state.termStart || state.courses.length === 0) { + clearSyncedCalendarData().catch(() => {}); + } else { + syncCoursesToCalendar(ids.length > 0 ? ids : undefined).catch( + () => {}, + ); + } } }, 400); } diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 3a5f378..e890601 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -109,8 +109,22 @@ export default function OnboardingScreen() { const handleCalendarToggle = async (value: boolean) => { if (!value) { - await deleteAppCalendar(); - setCalendarSync(false); + setCalendarBusy(true); + try { + const result = await deleteAppCalendar(); + if (result.success) { + setCalendarSync(false); + } else { + Toast.show({ + type: "error", + text1: t("calendarSet.syncFailed"), + text2: result.error, + position: "bottom", + }); + } + } finally { + setCalendarBusy(false); + } return; } if (!hasCourses) { diff --git a/lib/i18n/locales/en.json b/lib/i18n/locales/en.json index 01bebe9..0d09674 100644 --- a/lib/i18n/locales/en.json +++ b/lib/i18n/locales/en.json @@ -306,12 +306,22 @@ "showMidday": "Show midday periods", "showOtherWeek": "Show other week courses", "syncGroup": "Sync", - "syncCalendar": "Sync to system calendar", - "syncedToast": "Synced to system calendar", + "syncCalendar": "Sync to calendar", + "syncedToast": "Synced to calendar", "syncedSub": "{n} class entries written", "syncedPartialSub": "{n} class entries written, {m} failed", "syncFailed": "Sync failed", - "syncRemoved": "Removed from system calendar", + "syncRemoved": "Removed from calendar", + "pickerTitle": "Choose calendar", + "pickerHint": "Courses will be written to the selected calendar", + "pickerLocalGroup": "Default", + "pickerLocal": "Local calendar", + "pickerOther": "Other calendars", + "pickerSync": "Sync", + "pickerOtherWarning": "Syncing to a third-party/cloud calendar can be slow; courses may take a while to fully appear after the system finishes syncing in the background.", + "removeConfirmTitle": "Remove calendar sync", + "removeConfirmDesc": "When removing courses from other calendars, you may need to pull down the notification shade and confirm the deletion manually. Keep an eye out for a system notification.", + "removeConfirmOk": "Remove", "customGroup": "Personalize", "palette": "Color palette", "visualStyle": "Schedule appearance" diff --git a/lib/i18n/locales/zh.json b/lib/i18n/locales/zh.json index 789bbb9..9f37570 100644 --- a/lib/i18n/locales/zh.json +++ b/lib/i18n/locales/zh.json @@ -306,12 +306,22 @@ "showMidday": "显示中课", "showOtherWeek": "显示非本周课程", "syncGroup": "同步", - "syncCalendar": "同步到系统日历", - "syncedToast": "已同步到系统日历", + "syncCalendar": "同步到日历", + "syncedToast": "已同步到日历", "syncedSub": "共写入 {n} 条课程数据", "syncedPartialSub": "已写入 {n} 条课程数据,{m} 条写入失败", "syncFailed": "同步失败", - "syncRemoved": "已从系统日历移除", + "syncRemoved": "已从日历移除", + "pickerTitle": "选择同步日历", + "pickerHint": "课程将写入所选日历", + "pickerLocalGroup": "默认", + "pickerLocal": "本地日历", + "pickerOther": "其他日历", + "pickerSync": "同步", + "pickerOtherWarning": "同步到第三方/云日历可能较慢,课程需等待系统后台同步后才会完整显示", + "removeConfirmTitle": "移除日历同步", + "removeConfirmDesc": "移除其他日历中的课程时,可能需要你下拉通知栏手动确认删除,请留意系统通知。", + "removeConfirmOk": "继续移除", "customGroup": "个性化", "palette": "配色方案", "visualStyle": "课表外观" diff --git a/services/calendar-sync.ts b/services/calendar-sync.ts index bd3157a..8c71b7a 100644 --- a/services/calendar-sync.ts +++ b/services/calendar-sync.ts @@ -3,7 +3,8 @@ import { createCalendar, EntityTypes, type ExpoCalendar, - type ExpoCalendarEvent, + ExpoCalendarEvent, + Frequency, getCalendars, getDefaultCalendarSync, requestCalendarPermissions, @@ -13,8 +14,6 @@ import { Platform } from "react-native"; import { getTermClassTimeMs } from "@/lib/date"; import { t } from "@/lib/i18n"; -import enJson from "@/lib/i18n/locales/en.json"; -import zhJson from "@/lib/i18n/locales/zh.json"; import { reportError } from "@/lib/report"; import { getMMKV } from "@/lib/storage"; import { createTaskQueue } from "@/lib/task-queue"; @@ -22,36 +21,83 @@ import { SECTION_TIMES } from "@/services/course-time"; import { type Course, useCourseStore } from "@/store/course"; import { useSettingsStore } from "@/store/settings"; -// 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"); +// iOS 仅按持久化 ID 识别应用日历,避免误删用户创建的同名日历。 +const CALENDAR_IDS_STORAGE_KEY = "calendar-sync.calendarId"; + +function getStoredCalendarIds(): string[] { + const raw = getMMKV().getString(CALENDAR_IDS_STORAGE_KEY); + if (!raw) return []; + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return parsed.filter((id): id is string => typeof id === "string"); + } + } catch {} + return [raw]; } -// iOS 无法像 Android 那样用固定 name 定位日历,因此持久化创建出的日历 id, -// 并用所有语言的标题做兜底匹配,避免切换语言后产生残留/重复日历。 -const CALENDAR_ID_STORAGE_KEY = "calendar-sync.calendarId"; -const KNOWN_CALENDAR_TITLES = new Set([ - zhJson.calSync.title, - enJson.calSync.title, -]); +function setStoredCalendarIds(ids: string[]): void { + const uniqueIds = [...new Set(ids)]; + if (uniqueIds.length === 0) { + getMMKV().remove(CALENDAR_IDS_STORAGE_KEY); + } else { + getMMKV().set(CALENDAR_IDS_STORAGE_KEY, JSON.stringify(uniqueIds)); + } +} + +// 外部日历中的事件只能按创建时记录的 ID 删除。 +const SYNCED_EVENT_IDS_KEY = "calendar-sync.eventIds"; +const LEGACY_EVENT_IDS_KEY = "__legacy__"; +type StoredEventIds = Record; -function getStoredCalendarId(): string | null { - return getMMKV().getString(CALENDAR_ID_STORAGE_KEY) ?? null; +function getStoredEventIds(): StoredEventIds { + const raw = getMMKV().getString(SYNCED_EVENT_IDS_KEY); + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + return { + [LEGACY_EVENT_IDS_KEY]: parsed.filter( + (id): id is string => typeof id === "string", + ), + }; + } + if (parsed && typeof parsed === "object") { + return Object.fromEntries( + Object.entries(parsed).flatMap(([calendarId, ids]) => { + if (!Array.isArray(ids)) return []; + const validIds = ids.filter( + (id): id is string => typeof id === "string", + ); + return validIds.length > 0 ? [[calendarId, validIds]] : []; + }), + ); + } + } catch { + return {}; + } + return {}; } -function setStoredCalendarId(id: string | null): void { - if (id == null) { - getMMKV().remove(CALENDAR_ID_STORAGE_KEY); +function setStoredEventIds(idsByCalendar: StoredEventIds): void { + const entries = Object.entries(idsByCalendar).flatMap(([calendarId, ids]) => { + const uniqueIds = [...new Set(ids)]; + return uniqueIds.length > 0 ? [[calendarId, uniqueIds] as const] : []; + }); + if (entries.length === 0) { + getMMKV().remove(SYNCED_EVENT_IDS_KEY); } else { - getMMKV().set(CALENDAR_ID_STORAGE_KEY, id); + getMMKV().set( + SYNCED_EVENT_IDS_KEY, + JSON.stringify(Object.fromEntries(entries)), + ); } } + const CALENDAR_COLOR = "#007AFF"; -// Android 上用作 ACCOUNT_NAME / OWNER_ACCOUNT 的固定标识,使用 ASCII 字符以 -// 避开部分 OEM Calendar Provider 对非 ASCII 账户名的隐式拒绝。 +const ANDROID_CALENDAR_READY_DELAY_MS = 200; +// 部分 Android 日历 Provider 会拒绝非 ASCII 账户名。 const APP_ACCOUNT_NAME = "iwut.tokenteam.dev"; -// Android Calendars.NAME 与 Calendar.title 独立,便于多语言切换后定位。 const CALENDAR_INTERNAL_NAME = "iwut_schedule"; export async function requestCalendarPermission(): Promise { @@ -59,15 +105,14 @@ export async function requestCalendarPermission(): Promise { return status === "granted"; } -function isAppCalendar(c: ExpoCalendar): boolean { +function isAppCalendar(calendar: ExpoCalendar): boolean { if (Platform.OS === "android") { return ( - c.name === CALENDAR_INTERNAL_NAME || c.source?.name === APP_ACCOUNT_NAME + calendar.name === CALENDAR_INTERNAL_NAME || + calendar.source?.name === APP_ACCOUNT_NAME ); } - const storedId = getStoredCalendarId(); - if (storedId && c.id === storedId) return true; - return KNOWN_CALENDAR_TITLES.has(c.title ?? ""); + return getStoredCalendarIds().includes(calendar.id); } async function findAppCalendars(): Promise { @@ -75,16 +120,27 @@ async function findAppCalendars(): Promise { return calendars.filter(isAppCalendar); } -async function deleteCalendarSafe(calendar: ExpoCalendar): Promise { +async function deleteCalendarSafe(calendar: ExpoCalendar): Promise { try { await calendar.delete(); + return true; } catch { - // 清理动作不应阻塞主流程 + return false; } } +async function deleteCalendars(calendars: ExpoCalendar[]): Promise { + const results = await Promise.all( + calendars.map(async (calendar) => ({ + id: calendar.id, + deleted: await deleteCalendarSafe(calendar), + })), + ); + return results.filter((result) => !result.deleted).map((result) => result.id); +} + async function createAppCalendar(): Promise { - const title = getCalendarTitle(); + const title = t("calSync.title"); if (Platform.OS === "ios") { const defaultCalendar = getDefaultCalendarSync(); return createCalendar({ @@ -99,10 +155,6 @@ async function createAppCalendar(): Promise { }); } - // Android 固定使用 ASCII ACCOUNT_NAME + LOCAL source,并显式开启 isVisible / - // isSynced / allowsModifications。Android 文档建议这些字段为 true,避免 Calendar - // Provider 行为异常;同时使用 ASCII OWNER_ACCOUNT 与 source.name 保持一致,便于 - // 多语言切换后通过 source.name 重新定位本应用的日历。 return createCalendar({ title, name: CALENDAR_INTERNAL_NAME, @@ -111,7 +163,7 @@ async function createAppCalendar(): Promise { source: { isLocalAccount: true, name: APP_ACCOUNT_NAME, - type: SourceType?.LOCAL ?? ("LOCAL" as SourceType), + type: SourceType.LOCAL, }, ownerAccount: APP_ACCOUNT_NAME, accessLevel: CalendarAccessLevel.OWNER, @@ -141,174 +193,428 @@ function formatEventDateForReport(value: unknown): string { return String(value); } -// 同步与删除都是"先清理再重建/移除"的多步流程,必须串行执行, -// 否则课程变更连续触发时可能产生重复日历或残留事件。 +export interface CalendarInfo { + id: string; + title: string; + color?: string; + accountName: string; +} + +function toCalendarInfo(calendar: ExpoCalendar): CalendarInfo { + return { + id: calendar.id, + title: calendar.title || "—", + color: calendar.color ?? undefined, + accountName: calendar.source?.name ?? "", + }; +} + +export const APP_LOCAL_CALENDAR_ID = "__iwut_local__"; + +export async function getWritableCalendars(): Promise { + const calendars = await getCalendars(EntityTypes.EVENT); + return calendars + .filter( + (calendar) => !isAppCalendar(calendar) && calendar.allowsModifications, + ) + .map(toCalendarInfo); +} + const calendarQueue = createTaskQueue(); -export function syncCoursesToCalendar(): Promise<{ +interface SyncResult { success: boolean; count: number; failed: number; error?: string; -}> { - return calendarQueue(doSyncCoursesToCalendar); } -async function doSyncCoursesToCalendar(): Promise<{ - success: boolean; - count: number; - failed: number; - error?: string; -}> { - const hasPermission = await requestCalendarPermission(); - if (!hasPermission) { +export function syncCoursesToCalendar( + targetCalendarIds?: string[], +): Promise { + return calendarQueue(async () => { + try { + return await doSyncCoursesToCalendar(targetCalendarIds); + } catch (error) { + reportError(error, { + module: "calendar-sync", + operation: "sync", + platform: Platform.OS, + platformVersion: String(Platform.Version), + }); + return { + success: false, + count: 0, + failed: 0, + error: error instanceof Error ? error.message : t("calSync.errUnknown"), + }; + } + }); +} + +async function doSyncCoursesToCalendar( + targetCalendarIds?: string[], +): Promise { + const { courses, termStart } = useCourseStore.getState(); + if (!termStart || courses.length === 0) { return { success: false, count: 0, failed: 0, - error: t("calSync.errNoPermission"), + error: t("calSync.errNoData"), }; } - const { courses, termStart } = useCourseStore.getState(); - if (!termStart || courses.length === 0) { + const hasPermission = await requestCalendarPermission(); + if (!hasPermission) { return { success: false, count: 0, failed: 0, - error: t("calSync.errNoData"), + error: t("calSync.errNoPermission"), }; } const reminderMinutes = useSettingsStore.getState().reminderMinutes; - // expo-calendar/next 在 Android 上把 relativeOffset 直接写入 Reminders.MINUTES, - // iOS 仍是 EventKit 的相对起始时间偏移。 + // Android 使用正数分钟,iOS 使用相对开始时间的负偏移。 const reminderOffset = Platform.OS === "android" ? reminderMinutes : -reminderMinutes; - // 每次同步先彻底清理旧日历再重建 - const stale = await findAppCalendars(); - for (const cal of stale) { - await deleteCalendarSafe(cal); + const requestedIds = new Set(targetCalendarIds ?? []); + const useLocalCalendar = + requestedIds.size === 0 || requestedIds.has(APP_LOCAL_CALENDAR_ID); + requestedIds.delete(APP_LOCAL_CALENDAR_ID); + + // 新事件成功写入前保留旧数据,避免同步失败后丢失已有日程。 + const calendars = await getCalendars(EntityTypes.EVENT); + const staleAppCalendars = calendars.filter(isAppCalendar); + const oldAppCalendarIds = getStoredCalendarIds(); + const oldExternalEventIds = getStoredEventIds(); + + const targets: ExpoCalendar[] = []; + let newAppCalendar: ExpoCalendar | null = null; + let unresolvedTargetCount = 0; + + if (requestedIds.size > 0) { + const resolvedExternalIds = new Set(); + for (const calendar of calendars) { + if ( + requestedIds.has(calendar.id) && + calendar.allowsModifications && + !isAppCalendar(calendar) + ) { + targets.push(calendar); + resolvedExternalIds.add(calendar.id); + } + } + unresolvedTargetCount += requestedIds.size - resolvedExternalIds.size; } - setStoredCalendarId(null); - let calendar: ExpoCalendar | null = null; + if (useLocalCalendar) { + try { + newAppCalendar = await createAppCalendar(); + targets.push(newAppCalendar); + if (Platform.OS === "android") { + await new Promise((resolve) => + setTimeout(resolve, ANDROID_CALENDAR_READY_DELAY_MS), + ); + } + } catch (error) { + reportError(error, { module: "calendar-sync", platform: Platform.OS }); + unresolvedTargetCount++; + if (targets.length === 0) { + return { + success: false, + count: 0, + failed: 0, + error: + error instanceof Error ? error.message : t("calSync.errUnknown"), + }; + } + } + } - try { - calendar = await createAppCalendar(); - setStoredCalendarId(calendar.id); + if (targets.length === 0) { + return { + success: false, + count: 0, + failed: 0, + error: t("calSync.errWriteFail"), + }; + } - if (Platform.OS === "android") { - await new Promise((r) => setTimeout(r, 200)); - } + const courseEvents = courses.flatMap((course) => { + const event = createEventForCourse(course, termStart, reminderOffset); + return event ? [{ course, event }] : []; + }); + if (courseEvents.length === 0) { + if (newAppCalendar) await deleteCalendarSafe(newAppCalendar); + return { + success: false, + count: 0, + failed: 0, + error: t("calSync.errWriteFail"), + }; + } - let count = 0; - let failed = 0; - let reported = false; - for (const course of courses) { - const events = createEventsForCourse(course, termStart, reminderOffset); - for (const eventData of events) { - try { - await calendar.createEvent(eventData); - count++; - } catch (e) { - failed++; - if (!reported) { - // 仅上报首个失败事件,附带足以定位的上下文 - reported = true; - reportError(e, { - module: "calendar-sync", - course: course.name, - day: course.day, - section: `${course.sectionStart}-${course.sectionEnd}`, - calendarId: calendar.id, - startDate: formatEventDateForReport(eventData.startDate), - endDate: formatEventDateForReport(eventData.endDate), - timeZone: eventData.timeZone, - platform: Platform.OS, - platformVersion: String(Platform.Version), - }); - } + const writeResults: Array<{ + calendar: ExpoCalendar; + createdIds: string[]; + createdCount: number; + complete: boolean; + }> = []; + let reported = false; + + for (const calendar of targets) { + const createdIds: string[] = []; + let createdCount = 0; + let writeFailed = false; + for (const { course, event } of courseEvents) { + try { + const created = await calendar.createEvent(event); + createdCount++; + if (calendar !== newAppCalendar) createdIds.push(created.id); + } catch (error) { + writeFailed = true; + if (!reported) { + reported = true; + reportError(error, { + module: "calendar-sync", + course: course.name, + day: course.day, + section: `${course.sectionStart}-${course.sectionEnd}`, + calendarId: calendar.id, + startDate: formatEventDateForReport(event.startDate), + endDate: formatEventDateForReport(event.endDate), + timeZone: event.timeZone, + platform: Platform.OS, + platformVersion: String(Platform.Version), + }); } } } + writeResults.push({ + calendar, + createdIds, + createdCount, + complete: !writeFailed && createdCount === courseEvents.length, + }); + } - if (count === 0) { - // 彻底失败时应删掉空日历 - await deleteCalendarSafe(calendar); - setStoredCalendarId(null); - return { - success: false, - count: 0, - failed, - error: t("calSync.errWriteFail"), - }; + const completedResults = writeResults.filter((result) => result.complete); + const count = completedResults.length * courseEvents.length; + const failed = + (unresolvedTargetCount + writeResults.length - completedResults.length) * + courseEvents.length; + + const nextExternalEventIds: StoredEventIds = {}; + const externalResults = writeResults.filter( + (result) => result.calendar !== newAppCalendar, + ); + const currentExternalIds = new Set( + externalResults.map((result) => result.calendar.id), + ); + + for (const result of externalResults) { + const oldIds = oldExternalEventIds[result.calendar.id] ?? []; + if (result.complete) { + nextExternalEventIds[result.calendar.id] = [ + ...(await deleteEventIds(oldIds)), + ...result.createdIds, + ]; + } else { + nextExternalEventIds[result.calendar.id] = [ + ...oldIds, + ...(await deleteEventIds(result.createdIds)), + ]; } - return { success: true, count, failed }; - } catch (e) { - reportError(e, { - module: "calendar-sync", - platform: Platform.OS, - platformVersion: String(Platform.Version), - }); - if (calendar) { - await deleteCalendarSafe(calendar); - setStoredCalendarId(null); + } + + const allExternalTargetsComplete = + requestedIds.size === externalResults.length && + externalResults.every((result) => result.complete); + for (const [calendarId, ids] of Object.entries(oldExternalEventIds)) { + if (currentExternalIds.has(calendarId)) continue; + const canDelete = + completedResults.length > 0 && + (calendarId !== LEGACY_EVENT_IDS_KEY || allExternalTargetsComplete); + const remainingIds = canDelete ? await deleteEventIds(ids) : ids; + if (remainingIds.length > 0) { + nextExternalEventIds[calendarId] = remainingIds; } - const msg = e instanceof Error ? e.message : t("calSync.errUnknown"); - return { success: false, count: 0, failed: 0, error: msg }; } + setStoredEventIds(nextExternalEventIds); + + const localResult = writeResults.find( + (result) => result.calendar === newAppCalendar, + ); + const staleAppCalendarIds = staleAppCalendars.map((calendar) => calendar.id); + if (localResult?.complete && newAppCalendar) { + const failedOldCalendarIds = await deleteCalendars(staleAppCalendars); + setStoredCalendarIds([...failedOldCalendarIds, newAppCalendar.id]); + } else if (useLocalCalendar) { + const rollbackFailed = + newAppCalendar && !(await deleteCalendarSafe(newAppCalendar)) + ? [newAppCalendar.id] + : []; + setStoredCalendarIds([ + ...oldAppCalendarIds, + ...staleAppCalendarIds, + ...rollbackFailed, + ]); + } else if (completedResults.length > 0) { + setStoredCalendarIds(await deleteCalendars(staleAppCalendars)); + } + + if (count === 0) { + return { + success: false, + count: 0, + failed, + error: t("calSync.errWriteFail"), + }; + } + + const resolvedCalendarIds = targets.map((calendar) => + calendar === newAppCalendar ? APP_LOCAL_CALENDAR_ID : calendar.id, + ); + useSettingsStore.getState().setSyncedCalendarIds(resolvedCalendarIds); + + return { success: true, count, failed }; } type EventInput = Omit, "id" | "organizer">; -function createEventsForCourse( +function createEventForCourse( course: Course, termStart: string, reminderOffset: number, -): EventInput[] { - const events: EventInput[] = []; - - for (let week = course.weekStart; week <= course.weekEnd; week++) { - const startTime = - course.startTime || SECTION_TIMES[course.sectionStart]?.[0]; - const endTime = course.endTime || SECTION_TIMES[course.sectionEnd]?.[1]; - if (!startTime || !endTime) continue; - - const startMs = getTermClassTimeMs(termStart, week, course.day, startTime); - const endMs = getTermClassTimeMs(termStart, week, course.day, endTime); - if (startMs == null || endMs == null || startMs >= endMs) continue; - - const startDate = new Date(startMs); - const endDate = new Date(endMs); - - events.push({ - title: course.name, - location: formatLocation(course.room), - // next API 会把 Date 转成 ISO 字符串;Android 原生 EventInputRecord 需要 string。 - startDate, - endDate, - alarms: [{ relativeOffset: reminderOffset }], - notes: course.teacher - ? t("calSync.teacherNotes", { teacher: course.teacher }) - : undefined, - timeZone: "Asia/Shanghai", - }); +): EventInput | null { + const startTime = course.startTime || SECTION_TIMES[course.sectionStart]?.[0]; + const endTime = course.endTime || SECTION_TIMES[course.sectionEnd]?.[1]; + if (!startTime || !endTime) return null; + + // 每个课程时段只创建一个按周重复事件,减少云日历写入量。 + const startMs = getTermClassTimeMs( + termStart, + course.weekStart, + course.day, + startTime, + ); + const endMs = getTermClassTimeMs( + termStart, + course.weekStart, + course.day, + endTime, + ); + if (startMs == null || endMs == null || startMs >= endMs) return null; + + const occurrence = course.weekEnd - course.weekStart + 1; + if (occurrence < 1) return null; + + const event: EventInput = { + title: course.name, + location: formatLocation(course.room), + startDate: new Date(startMs), + endDate: new Date(endMs), + alarms: [{ relativeOffset: reminderOffset }], + notes: course.teacher + ? t("calSync.teacherNotes", { teacher: course.teacher }) + : undefined, + timeZone: "Asia/Shanghai", + }; + + if (occurrence > 1) { + event.recurrenceRule = { + frequency: Frequency.WEEKLY, + interval: 1, + occurrence, + }; } - return events; + return event; } -export function deleteAppCalendar(): Promise { - return calendarQueue(async () => { +async function deleteEventIds(ids: string[]): Promise { + const failedIds: string[] = []; + for (const id of ids) { + let event: ExpoCalendarEvent; + try { + event = await ExpoCalendarEvent.get(id); + } catch (error) { + if (!isEventNotFoundError(error)) failedIds.push(id); + continue; + } + try { + await event.delete(); + } catch { + failedIds.push(id); + } + } + return failedIds; +} + +function isEventNotFoundError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ERR_EVENT_NOT_FOUND" + ); +} + +interface RemoveSyncResult { + success: boolean; + error?: string; +} + +async function doRemoveSyncedCalendarData( + preserveTargets: boolean, +): Promise { + try { const hasPermission = await requestCalendarPermission(); - if (!hasPermission) return; + if (!hasPermission) { + return { success: false, error: t("calSync.errNoPermission") }; + } + + const appCalendars = await findAppCalendars(); + const failedAppCalendarIds = await deleteCalendars(appCalendars); + setStoredCalendarIds(failedAppCalendarIds); - const calendars = await findAppCalendars(); - for (const cal of calendars) { - await deleteCalendarSafe(cal); + const failedEventIds: StoredEventIds = {}; + for (const [calendarId, ids] of Object.entries(getStoredEventIds())) { + const remainingIds = await deleteEventIds(ids); + if (remainingIds.length > 0) failedEventIds[calendarId] = remainingIds; } - setStoredCalendarId(null); - }); + setStoredEventIds(failedEventIds); + + if ( + failedAppCalendarIds.length > 0 || + Object.keys(failedEventIds).length > 0 + ) { + return { success: false, error: t("calSync.errWriteFail") }; + } + + if (!preserveTargets) useSettingsStore.getState().setSyncedCalendarIds([]); + return { success: true }; + } catch (error) { + reportError(error, { + module: "calendar-sync", + operation: "delete", + platform: Platform.OS, + platformVersion: String(Platform.Version), + }); + return { + success: false, + error: error instanceof Error ? error.message : t("calSync.errUnknown"), + }; + } +} + +export function clearSyncedCalendarData(): Promise { + return calendarQueue(() => doRemoveSyncedCalendarData(true)); +} + +export function deleteAppCalendar(): Promise { + return calendarQueue(() => doRemoveSyncedCalendarData(false)); } diff --git a/store/settings.ts b/store/settings.ts index f5a8d60..ab9b8b8 100644 --- a/store/settings.ts +++ b/store/settings.ts @@ -14,6 +14,8 @@ interface SettingsStore { examReminder: boolean; reminderMinutes: number; calendarSync: boolean; + /** Calendar IDs the user chose to sync courses into. */ + syncedCalendarIds: string[]; language: Lang; setHapticFeedback: (value: boolean) => void; setOpenCourseOnLaunch: (value: boolean) => void; @@ -21,6 +23,7 @@ interface SettingsStore { setExamReminder: (value: boolean) => void; setReminderMinutes: (value: number) => void; setCalendarSync: (value: boolean) => void; + setSyncedCalendarIds: (ids: string[]) => void; setLanguage: (value: Lang) => void; } @@ -33,6 +36,7 @@ export const useSettingsStore = create()( examReminder: false, reminderMinutes: 30, calendarSync: false, + syncedCalendarIds: [], language: "system", setHapticFeedback: (value: boolean) => set({ hapticFeedback: value }), setOpenCourseOnLaunch: (value: boolean) => @@ -41,6 +45,7 @@ export const useSettingsStore = create()( setExamReminder: (value: boolean) => set({ examReminder: value }), setReminderMinutes: (value: number) => set({ reminderMinutes: value }), setCalendarSync: (value: boolean) => set({ calendarSync: value }), + setSyncedCalendarIds: (ids: string[]) => set({ syncedCalendarIds: ids }), setLanguage: (value: Lang) => { set({ language: value }); // Sync OS-level locale first so the App display name (launcher icon