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
244 changes: 224 additions & 20 deletions app/(pages)/settings/calendar.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<CalendarInfo[]>(
[],
);
const [selectedCalendarIds, setSelectedCalendarIds] = useState<Set<string>>(
() => 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({
Expand All @@ -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;

Expand Down Expand Up @@ -144,7 +247,8 @@ export default function CalendarSettingsScreen() {
<ActivityIndicator size="small" />
) : (
<Switch
value={calendarSync}
value={displaySwitchOn}
disabled={pendingOn}
onValueChange={handleCalendarSyncToggle}
/>
)
Expand All @@ -168,6 +272,106 @@ export default function CalendarSettingsScreen() {
/>
</MenuGroup>
</ScrollView>

<BottomSheet
visible={pickerVisible}
onClose={() => setPickerVisible(false)}
title={t("calendarSet.pickerTitle")}
>
<Text className="px-5 pb-3 text-sm text-neutral-500 dark:text-neutral-400">
{t("calendarSet.pickerHint")}
</Text>
<ScrollView style={{ maxHeight: 320 }}>
<Text className="px-5 pb-1 text-xs uppercase text-neutral-400 dark:text-neutral-500">
{t("calendarSet.pickerLocalGroup")}
</Text>
<MenuItem
icon="event"
iconBg="#007AFF"
label={t("calendarSet.pickerLocal")}
value="iwut"
showArrow={false}
onPress={() => toggleCalendar(APP_LOCAL_CALENDAR_ID)}
right={
<IconSymbol
name={
localCalendarSelected
? "check-circle"
: "radio-button-unchecked"
}
size={22}
color={localCalendarSelected ? "#007AFF" : "#A3A3A3"}
/>
}
/>

{writableCalendars.length > 0 && (
<>
<Text className="px-5 pb-1 pt-3 text-xs uppercase text-neutral-400 dark:text-neutral-500">
{t("calendarSet.pickerOther")}
</Text>
{writableCalendars.map((calendar) => {
const selected = selectedCalendarIds.has(calendar.id);
return (
<MenuItem
key={calendar.id}
icon="event"
iconBg={calendar.color || "#9CA3AF"}
label={calendar.title}
value={calendar.accountName}
showArrow={false}
onPress={() => toggleCalendar(calendar.id)}
right={
<IconSymbol
name={
selected ? "check-circle" : "radio-button-unchecked"
}
size={22}
color={selected ? "#007AFF" : "#A3A3A3"}
/>
}
/>
);
})}
</>
)}
</ScrollView>

{externalCalendarSelected && (
<Text className="mx-5 mt-2 rounded-xl bg-orange-50 px-3 py-2 text-xs leading-5 text-orange-800 dark:bg-orange-950/40 dark:text-orange-300">
{t("calendarSet.pickerOtherWarning")}
</Text>
)}

<View className="px-5 pt-3">
<Pressable
className={`items-center rounded-xl py-3 ${
selectedCalendarIds.size > 0
? "bg-blue-500 active:bg-blue-600"
: "bg-neutral-300 dark:bg-neutral-700"
}`}
disabled={selectedCalendarIds.size === 0}
onPress={() => void doSync([...selectedCalendarIds])}
>
<Text className="text-base font-medium text-white">
{t("calendarSet.pickerSync")}
</Text>
</Pressable>
</View>
</BottomSheet>

<ConfirmSheet
visible={confirmRemoveVisible}
onClose={() => setConfirmRemoveVisible(false)}
title={t("calendarSet.removeConfirmTitle")}
description={t("calendarSet.removeConfirmDesc")}
confirmText={t("calendarSet.removeConfirmOk")}
destructive
onConfirm={() => {
setConfirmRemoveVisible(false);
void performRemove();
}}
/>
</>
);
}
14 changes: 12 additions & 2 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
}
Expand Down
18 changes: 16 additions & 2 deletions app/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment thread
Copilot marked this conversation as resolved.
return;
}
if (!hasCourses) {
Expand Down
16 changes: 13 additions & 3 deletions lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading