Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions app/(pages)/settings/calendar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Stack } from "expo-router";
import { useMemo, useState } from "react";
import {
ActivityIndicator,
Pressable,
ScrollView,
Switch,
Expand All @@ -14,8 +15,13 @@ 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 {
deleteAppCalendar,
syncCoursesToCalendar,
} from "@/services/calendar-sync";
import { useCourseStore } from "@/store/course";
import { useScheduleStore } from "@/store/schedule";
import { useSettingsStore } from "@/store/settings";

function isCropCancelled(error: unknown) {
const re = /cancell?ed/i;
Expand Down Expand Up @@ -44,14 +50,49 @@ export default function CalendarSettingsScreen() {
);

const courses = useCourseStore((s) => s.courses);
const calendarSync = useSettingsStore((s) => s.calendarSync);
const setCalendarSync = useSettingsStore((s) => s.setCalendarSync);

const [showBgPicker, setShowBgPicker] = useState(false);
const [syncing, setSyncing] = useState(false);

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();
setSyncing(false);
if (result.success) {
setCalendarSync(true);
Toast.show({
type: "success",
text1: "已同步到系统日历",
text2: `共写入 ${result.count} 条课程数据`,
position: "bottom",
});
} else {
Toast.show({
type: "error",
text1: "同步失败",
text2: result.error,
position: "bottom",
});
}
} else {
await deleteAppCalendar();
setCalendarSync(false);
Toast.show({
type: "success",
text1: "已从系统日历移除",
position: "bottom",
});
}
};

const deleteOldBg = async (uri: string | null) => {
if (!uri) return;
try {
Expand All @@ -69,9 +110,8 @@ export default function CalendarSettingsScreen() {
try {
const ImagePicker = await import("expo-image-picker");
const { File, Paths } = await import("expo-file-system");
const ExpoImageCropTool = (
await import("@bsky.app/expo-image-crop-tool")
).default;
const ExpoImageCropTool = (await import("@bsky.app/expo-image-crop-tool"))
.default;

const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
Expand Down Expand Up @@ -168,6 +208,25 @@ export default function CalendarSettingsScreen() {
/>
</MenuGroup>

<MenuGroup title="同步">
<MenuItem
icon="event"
iconBg="#FF9500"
label="同步到系统日历"
showArrow={false}
right={
syncing ? (
<ActivityIndicator size="small" />
) : (
<Switch
value={calendarSync}
onValueChange={handleCalendarSyncToggle}
/>
)
}
/>
</MenuGroup>

<MenuGroup title="个性化">
<MenuItem
icon="palette"
Expand Down
5 changes: 5 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import Toast from "react-native-toast-message";

import { Themes } from "@/constants/theme";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { syncCoursesToCalendar } from "@/services/calendar-sync";
import {
initNotificationChannel,
registerBackgroundRefresh,
Expand All @@ -44,6 +45,7 @@ import {
} from "@/services/course-notification";
import { syncWidgetData } from "@/services/widget-sync";
import { useCourseStore } from "@/store/course";
import { useSettingsStore } from "@/store/settings";
import { useThemeStore } from "@/store/theme";
import { useUpdateStore } from "@/store/update";

Expand Down Expand Up @@ -108,6 +110,9 @@ function RootLayout() {
) {
syncWidgetData().catch(() => {});
scheduleWeeklyReminders().catch(() => {});
if (useSettingsStore.getState().calendarSync) {
syncCoursesToCalendar().catch(() => {});
}
}
});
return unsub;
Expand Down
29 changes: 15 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,31 @@
"@react-navigation/native": "^7.1.33",
"@sentry/react-native": "~7.11.0",
"@tailwindcss/postcss": "^4.2.4",
"expo": "~55.0.19",
"expo": "~55.0.23",
"expo-background-task": "~55.0.17",
"expo-build-properties": "~55.0.13",
"expo-calendar": "~55.0.14",
"expo-clipboard": "~55.0.13",
"expo-constants": "~55.0.15",
"expo-dev-client": "~55.0.30",
"expo-device": "~55.0.15",
"expo-file-system": "~55.0.17",
"expo-font": "~55.0.6",
"expo-constants": "~55.0.16",
"expo-dev-client": "~55.0.32",
"expo-device": "~55.0.16",
"expo-file-system": "~55.0.19",
"expo-font": "~55.0.7",
"expo-haptics": "~55.0.14",
"expo-image": "~55.0.9",
"expo-image-picker": "~55.0.19",
"expo-image": "~55.0.10",
"expo-image-picker": "~55.0.20",
"expo-insights": "^55.0.16",
"expo-linear-gradient": "~55.0.13",
"expo-linking": "~55.0.14",
"expo-router": "~55.0.13",
"expo-linking": "~55.0.15",
"expo-router": "~55.0.14",
"expo-secure-store": "~55.0.13",
"expo-sharing": "~55.0.18",
"expo-splash-screen": "~55.0.19",
"expo-status-bar": "~55.0.5",
"expo-system-ui": "~55.0.16",
"expo-splash-screen": "~55.0.20",
"expo-status-bar": "~55.0.6",
"expo-system-ui": "~55.0.17",
"expo-task-manager": "~55.0.15",
"expo-updates": "~55.0.21",
"expo-web-browser": "~55.0.14",
"expo-web-browser": "~55.0.15",
"jszip": "^3.10.1",
"nativewind": "^5.0.0-preview.3",
"react": "19.2.0",
Expand Down
174 changes: 174 additions & 0 deletions services/calendar-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import * as Calendar from "expo-calendar";
import { Platform } from "react-native";

import { getTermWeekMonday } from "@/lib/date";
import { reportError } from "@/lib/report";
import { SECTION_TIMES } from "@/services/course-time";
import { type Course, useCourseStore } from "@/store/course";

const CALENDAR_TITLE = "掌上吾理-我的课表";
const CALENDAR_COLOR = "#007AFF";

export async function requestCalendarPermission(): Promise<boolean> {
const { status } = await Calendar.requestCalendarPermissionsAsync();
return status === "granted";
}

async function findAppCalendar(): Promise<string | null> {
const calendars = await Calendar.getCalendarsAsync(
Calendar.EntityTypes.EVENT,
);
const found = calendars.find((c) => c.title === CALENDAR_TITLE);
return found?.id ?? null;
}

async function createAppCalendar(): Promise<string> {
if (Platform.OS === "ios") {
const defaultCalendar = await Calendar.getDefaultCalendarAsync();
const id = await Calendar.createCalendarAsync({
title: CALENDAR_TITLE,
color: CALENDAR_COLOR,
entityType: Calendar.EntityTypes.EVENT,
sourceId: defaultCalendar.source.id,
source: defaultCalendar.source,
name: CALENDAR_TITLE,
ownerAccount: "personal",
accessLevel: Calendar.CalendarAccessLevel.OWNER,
});
return id;
}

// Android 需要找到一个可用的 local source
const calendars = await Calendar.getCalendarsAsync(
Calendar.EntityTypes.EVENT,
);
const localSource = calendars.find(
(c) => c.source && c.source.isLocalAccount,
)?.source;

const id = await Calendar.createCalendarAsync({
title: CALENDAR_TITLE,
color: CALENDAR_COLOR,
entityType: Calendar.EntityTypes.EVENT,
sourceId: localSource?.id,
source:
localSource ??
({
isLocalAccount: true,
name: CALENDAR_TITLE,
type: Calendar.SourceType?.LOCAL ?? ("LOCAL" as Calendar.SourceType),
} as Calendar.Source),
name: CALENDAR_TITLE,
ownerAccount: "personal",
accessLevel: Calendar.CalendarAccessLevel.OWNER,
});
return id;
}

function formatLocation(room: string | undefined): string | undefined {
if (!room) return undefined;
if (
room.startsWith("马区") ||
room.startsWith("南湖") ||
room.startsWith("余区")
) {
return `武理-${room}`;
}
return room;
}

function buildEventDate(
monday: Date,
dayOfWeek: number,
timeStr: string,
): Date {
const date = new Date(monday);
date.setDate(date.getDate() + (dayOfWeek - 1));
const [h, m] = timeStr.split(":").map(Number);
date.setHours(h, m, 0, 0);
return date;
}

export async function syncCoursesToCalendar(): Promise<{
success: boolean;
count: number;
error?: string;
}> {
const hasPermission = await requestCalendarPermission();
if (!hasPermission) {
return { success: false, count: 0, error: "没有日历访问权限" };
}

const { courses, termStart } = useCourseStore.getState();
if (!termStart || courses.length === 0) {
return { success: false, count: 0, error: "没有课程数据或学期开始时间" };
}

try {
// 每次同步先删除旧日历再重建
const existingId = await findAppCalendar();
if (existingId) {
await Calendar.deleteCalendarAsync(existingId);
}

const calendarId = await createAppCalendar();

let count = 0;
for (const course of courses) {
const events = createEventsForCourse(course, termStart);
for (const eventData of events) {
await Calendar.createEventAsync(calendarId, eventData);
count++;
}
}

return { success: true, count };
} catch (e) {
reportError(e, { module: "calendar-sync" });
const msg = e instanceof Error ? e.message : "未知错误";
return { success: false, count: 0, error: msg };
}
}

type EventInput = Omit<Partial<Calendar.Event>, "id" | "organizer">;

function createEventsForCourse(
course: Course,
termStart: string,
): EventInput[] {
const events: EventInput[] = [];

for (let week = course.weekStart; week <= course.weekEnd; week++) {
const monday = getTermWeekMonday(termStart, week);
if (!monday) continue;

const startTime = SECTION_TIMES[course.sectionStart];
const endTime = SECTION_TIMES[course.sectionEnd];
if (!startTime || !endTime) continue;

const startDate = buildEventDate(monday, course.day, startTime[0]);
const endDate = buildEventDate(monday, course.day, endTime[1]);

events.push({
title: course.name,
location: formatLocation(course.room),
startDate,
endDate,
alarms: [{ relativeOffset: -15 }],
notes: course.teacher ? `教师: ${course.teacher}` : undefined,
timeZone: "Asia/Shanghai",
});
}

return events;
}

export async function deleteAppCalendar(): Promise<void> {
const hasPermission = await requestCalendarPermission();
if (!hasPermission) return;

const calendarId = await findAppCalendar();
if (calendarId) {
await Calendar.deleteCalendarAsync(calendarId);
}
}
4 changes: 4 additions & 0 deletions store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ interface SettingsStore {
openCourseOnLaunch: boolean;
courseReminder: boolean;
reminderMinutes: number;
calendarSync: boolean;
setHapticFeedback: (value: boolean) => void;
setOpenCourseOnLaunch: (value: boolean) => void;
setCourseReminder: (value: boolean) => void;
setReminderMinutes: (value: number) => void;
setCalendarSync: (value: boolean) => void;
}

export const useSettingsStore = create<SettingsStore>()(
Expand All @@ -21,11 +23,13 @@ export const useSettingsStore = create<SettingsStore>()(
openCourseOnLaunch: false,
courseReminder: false,
reminderMinutes: 30,
calendarSync: false,
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 }),
}),
{
name: "settings",
Expand Down
Loading
Loading