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
2 changes: 2 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const config: ExpoConfig = {
supportsTablet: true,
infoPlist: {
ITSAppUsesNonExemptEncryption: false,
NSSupportsLiveActivities: true,
NSLocationWhenInUseUsageDescription:
"用于在连接校园网时读取当前 Wi-Fi 相关信息并完成网络认证",
NSAppTransportSecurity: {
Expand Down Expand Up @@ -87,6 +88,7 @@ const config: ExpoConfig = {
},
],
"@sentry/react-native",
"expo-background-task",
"@bacons/apple-targets",
"./plugins/with-gradle-props.js",
],
Expand Down
160 changes: 158 additions & 2 deletions app/(pages)/settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,99 @@
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import Constants from "expo-constants";
import * as Device from "expo-device";
import { Directory, File, Paths } from "expo-file-system";
import { Image } from "expo-image";
import { Stack } from "expo-router";
import * as Sharing from "expo-sharing";
import JSZip from "jszip";
import { useState } from "react";
import { ActivityIndicator, ScrollView, Switch } from "react-native";
import { useRef, useState } from "react";
import {
ActivityIndicator,
PermissionsAndroid,
Platform,
Pressable,
ScrollView,
Switch,
Text,
TextInput,
View,
} from "react-native";
import { FileLogger } from "react-native-file-logger";
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 { reportError } from "@/lib/report";
import {
registerBackgroundRefresh,
scheduleWeeklyReminders,
unregisterBackgroundRefresh,
} from "@/services/course-notification";
import { useScheduleStore } from "@/store/schedule";
import { useSettingsStore } from "@/store/settings";

const REMINDER_PRESETS = [15, 30, 60];

export default function SettingsScreen() {
const hapticFeedback = useSettingsStore((s) => s.hapticFeedback);
const setHapticFeedback = useSettingsStore((s) => s.setHapticFeedback);
const openCourseOnLaunch = useSettingsStore((s) => s.openCourseOnLaunch);
const setOpenCourseOnLaunch = useSettingsStore(
(s) => s.setOpenCourseOnLaunch,
);
const courseReminder = useSettingsStore((s) => s.courseReminder);
const setCourseReminder = useSettingsStore((s) => s.setCourseReminder);
const reminderMinutes = useSettingsStore((s) => s.reminderMinutes);
const setReminderMinutes = useSettingsStore((s) => s.setReminderMinutes);

const [clearVisible, setClearVisible] = useState(false);
const [clearing, setClearing] = useState(false);
const [exporting, setExporting] = useState(false);
const [reminderSheetVisible, setReminderSheetVisible] = useState(false);
const [customMinutes, setCustomMinutes] = useState("");
const customInputRef = useRef<TextInput>(null);

const handleCourseReminderChange = async (value: boolean) => {
// 检查通知权限
if (value && Platform.OS === "android") {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) return;
}

setCourseReminder(value);
await scheduleWeeklyReminders();
if (value) {
await registerBackgroundRefresh();
} else {
await unregisterBackgroundRefresh();
}
};

const handleReminderMinutesChange = async (value: number) => {
if (value < 1 || value > 120) return;
setReminderMinutes(value);
setCustomMinutes("");
setReminderSheetVisible(false);
if (courseReminder) {
await scheduleWeeklyReminders();
}
};

const handleCustomMinutesSubmit = () => {
const val = parseInt(customMinutes, 10);
if (!val || val < 1 || val > 120) {
Toast.show({
type: "error",
text1: "请输入 1-120 之间的数字",
position: "bottom",
});
return;
}
handleReminderMinutesChange(val);
};

const handleClearCache = async () => {
setClearVisible(false);
Expand Down Expand Up @@ -164,6 +231,35 @@ export default function SettingsScreen() {
/>
</MenuGroup>

<MenuGroup title="通知">
<MenuItem
icon="notifications-active"
iconBg="#FF9500"
label="课前提醒"
showArrow={false}
right={
<Switch
value={courseReminder}
onValueChange={handleCourseReminderChange}
/>
}
/>
{courseReminder && (
<MenuItem
icon="schedule"
iconBg="#5856D6"
label="提醒时间"
showArrow
right={
<Text className="text-sm text-neutral-500">
提前 {reminderMinutes} 分钟
</Text>
}
onPress={() => setReminderSheetVisible(true)}
/>
)}
</MenuGroup>

<MenuGroup title="存储">
<MenuItem
icon="delete-outline"
Expand Down Expand Up @@ -193,6 +289,66 @@ export default function SettingsScreen() {
destructive
onConfirm={handleClearCache}
/>

<BottomSheet
visible={reminderSheetVisible}
onClose={() => setReminderSheetVisible(false)}
title="提醒时间"
>
{REMINDER_PRESETS.map((mins) => (
<MenuItem
key={mins}
icon={reminderMinutes === mins ? "check" : "radio-button-unchecked"}
iconBg={reminderMinutes === mins ? "#34C759" : "#C7C7CC"}
label={`提前 ${mins} 分钟`}
showArrow={false}
onPress={() => handleReminderMinutesChange(mins)}
/>
))}
<View className="flex-row items-center px-4 py-3">
<Text className="text-base text-neutral-900 dark:text-neutral-100">
自定义
</Text>
<TextInput
ref={customInputRef}
style={{
marginHorizontal: 12,
height: 34,
flex: 1,
borderRadius: 8,
borderWidth: 1,
borderColor: "#D4D4D4",
paddingHorizontal: 12,
paddingVertical: 0,
textAlign: "center",
fontSize: 14,
}}
keyboardType="number-pad"
maxLength={3}
placeholder="1-120"
placeholderTextColor="#9CA3AF"
value={customMinutes}
onChangeText={setCustomMinutes}
onSubmitEditing={handleCustomMinutesSubmit}
returnKeyType="done"
/>
<Text className="text-base text-neutral-500">分钟</Text>
<Pressable
style={{
marginLeft: 8,
height: 34,
width: 34,
borderRadius: 8,
backgroundColor: "#3b82f6",
alignItems: "center",
justifyContent: "center",
}}
onPress={handleCustomMinutesSubmit}
>
<MaterialIcons name="check" size={20} color="white" />
</Pressable>
</View>
</BottomSheet>
</>
);
}
26 changes: 25 additions & 1 deletion app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { StatusBar } from "expo-status-bar";
import { useCallback, useEffect, useRef } from "react";
import { Appearance, View } from "react-native";
import { AppState, Appearance, Platform, View } from "react-native";
import "react-native-reanimated";
import {
SafeAreaProvider,
Expand All @@ -36,6 +36,12 @@ import Toast from "react-native-toast-message";

import { Themes } from "@/constants/theme";
import { useColorScheme } from "@/hooks/use-color-scheme";
import {
initNotificationChannel,
registerBackgroundRefresh,
scheduleWeeklyReminders,
showUpcomingLiveActivity,
} from "@/services/course-notification";
import { syncWidgetData } from "@/services/widget-sync";
import { useCourseStore } from "@/store/course";
import { useThemeStore } from "@/store/theme";
Expand Down Expand Up @@ -76,6 +82,23 @@ function RootLayout() {
useUpdateStore.getState().check();
}, []);

useEffect(() => {
initNotificationChannel().catch(() => {});
scheduleWeeklyReminders().catch(() => {});
registerBackgroundRefresh().catch(() => {});
showUpcomingLiveActivity().catch(() => {});
}, []);

useEffect(() => {
if (Platform.OS !== "ios") return;
const sub = AppState.addEventListener("change", (state) => {
if (state === "active") {
showUpcomingLiveActivity().catch(() => {});
}
});
return () => sub.remove();
}, []);

useEffect(() => {
syncWidgetData().catch(() => {});
const unsub = useCourseStore.subscribe((state, prev) => {
Expand All @@ -84,6 +107,7 @@ function RootLayout() {
state.termStart !== prev.termStart
) {
syncWidgetData().catch(() => {});
scheduleWeeklyReminders().catch(() => {});
}
});
return unsub;
Expand Down
18 changes: 18 additions & 0 deletions modules/notification/android/build.gradle
Original file line number Diff line number Diff line change
@@ -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.notification"
}

dependencies {
implementation 'androidx.core:core:1.18.0'
}
12 changes: 12 additions & 0 deletions modules/notification/android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />

<application>
<receiver
android:name=".CountdownReceiver"
android:exported="false" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package dev.tokenteam.iwut.notification

import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat

class CountdownReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
ACTION_SHOW_COUNTDOWN -> handleShowCountdown(context, intent)
ACTION_DISMISS -> handleDismiss(context, intent)
}
}

private fun handleShowCountdown(context: Context, intent: Intent) {
val id = intent.getIntExtra("id", 0)
val channelId = intent.getStringExtra("channelId") ?: return
val title = intent.getStringExtra("title") ?: return
val body = intent.getStringExtra("body") ?: ""
val targetTimeMs = intent.getLongExtra("targetTimeMs", 0L)
val ongoing = intent.getBooleanExtra("ongoing", true)
val autoDismiss = intent.getBooleanExtra("autoDismiss", true)

showNotification(context, id, channelId, title, body, targetTimeMs, ongoing, autoDismiss)

NotificationModule.removeTrackedId(context, id)
}

private fun showNotification(
context: Context, id: Int, channelId: String,
title: String, body: String, targetTimeMs: Long,
ongoing: Boolean, autoDismiss: Boolean,
) {
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
val contentIntent = PendingIntent.getActivity(
context, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

val timeoutMs = targetTimeMs - System.currentTimeMillis()

val builder = NotificationCompat.Builder(context, channelId)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.ic_notification)
.setWhen(targetTimeMs)
.setUsesChronometer(true)
.setChronometerCountDown(true)
.setOngoing(ongoing)
.setCategory(NotificationCompat.CATEGORY_EVENT)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(contentIntent)
.setAutoCancel(!ongoing)
.apply {
if (autoDismiss && timeoutMs > 0) setTimeoutAfter(timeoutMs)
}

if (Build.VERSION.SDK_INT >= 36) {
builder.setRequestPromotedOngoing(true)
builder.setShortCriticalText(body)
}

val notification = builder.build()

val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(id, notification)
}

private fun handleDismiss(context: Context, intent: Intent) {
val id = intent.getIntExtra("id", 0)
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.cancel(id)
}

companion object {
const val ACTION_SHOW_COUNTDOWN = "dev.tokenteam.iwut.notification.SHOW_COUNTDOWN"
const val ACTION_DISMISS = "dev.tokenteam.iwut.notification.DISMISS"
}
}
Loading
Loading