From fc5b35b26452edbaa42a0913f6ed759c427175d7 Mon Sep 17 00:00:00 2001 From: zhxycn Date: Mon, 4 May 2026 21:36:27 +0800 Subject: [PATCH 1/7] =?UTF-8?q?:sparkles:=20feat:=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/notification/android/build.gradle | 14 ++ .../android/src/main/AndroidManifest.xml | 11 ++ .../iwut/notification/CountdownReceiver.kt | 77 ++++++++ .../iwut/notification/NotificationModule.kt | 183 ++++++++++++++++++ modules/notification/expo-module.config.json | 9 + modules/notification/index.ts | 39 ++++ modules/notification/ios/Notification.podspec | 19 ++ .../notification/ios/NotificationModule.swift | 27 +++ 8 files changed, 379 insertions(+) create mode 100644 modules/notification/android/build.gradle create mode 100644 modules/notification/android/src/main/AndroidManifest.xml create mode 100644 modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt create mode 100644 modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt create mode 100644 modules/notification/expo-module.config.json create mode 100644 modules/notification/index.ts create mode 100644 modules/notification/ios/Notification.podspec create mode 100644 modules/notification/ios/NotificationModule.swift diff --git a/modules/notification/android/build.gradle b/modules/notification/android/build.gradle new file mode 100644 index 0000000..4b402b7 --- /dev/null +++ b/modules/notification/android/build.gradle @@ -0,0 +1,14 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'dev.tokenteam.iwut' + +expoModule { + canBePublished = false +} + +android { + namespace "dev.tokenteam.iwut.notification" +} diff --git a/modules/notification/android/src/main/AndroidManifest.xml b/modules/notification/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..10dc681 --- /dev/null +++ b/modules/notification/android/src/main/AndroidManifest.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt new file mode 100644 index 0000000..a51d603 --- /dev/null +++ b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt @@ -0,0 +1,77 @@ +package dev.tokenteam.iwut.notification + +import android.app.AlarmManager +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +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) + + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + val contentIntent = PendingIntent.getActivity( + context, 0, launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val notification = NotificationCompat.Builder(context, channelId) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setWhen(targetTimeMs) + .setUsesChronometer(true) + .setChronometerCountDown(true) + .setOngoing(ongoing) + .setContentIntent(contentIntent) + .setAutoCancel(!ongoing) + .build() + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(id, notification) + + if (autoDismiss && targetTimeMs > System.currentTimeMillis()) { + val dismissIntent = Intent(context, CountdownReceiver::class.java).apply { + action = ACTION_DISMISS + putExtra("id", id) + } + val dismissPending = PendingIntent.getBroadcast( + context, id + 100000, dismissIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, targetTimeMs, dismissPending + ) + } + + NotificationModule.removeTrackedId(context, id) + } + + 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" + } +} diff --git a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt new file mode 100644 index 0000000..2871b3f --- /dev/null +++ b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt @@ -0,0 +1,183 @@ +package dev.tokenteam.iwut.notification + +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class NotificationModule : Module() { + private val notificationManager: NotificationManager? + get() = appContext.reactContext?.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + + private val alarmManager: AlarmManager? + get() = appContext.reactContext?.getSystemService(Context.ALARM_SERVICE) as? AlarmManager + + override fun definition() = ModuleDefinition { + Name("Notification") + + AsyncFunction("createChannel") { id: String, name: String, description: String -> + val manager = notificationManager ?: return@AsyncFunction + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH).apply { + this.description = description + } + manager.createNotificationChannel(channel) + } + } + + AsyncFunction("showCountdown") { id: Int, channelId: String, title: String, body: String, targetTimeMs: Double, ongoing: Boolean, autoDismiss: Boolean -> + val context = appContext.reactContext ?: return@AsyncFunction + val target = targetTimeMs.toLong() + + val notification = buildCountdownNotification(context, channelId, title, body, target, ongoing) + notificationManager?.notify(id, notification) + + if (autoDismiss) { + scheduleDismiss(context, id, target) + } + } + + AsyncFunction("scheduleCountdown") { id: Int, channelId: String, title: String, body: String, triggerAtMs: Double, targetTimeMs: Double, ongoing: Boolean, autoDismiss: Boolean -> + val context = appContext.reactContext ?: return@AsyncFunction + val trigger = triggerAtMs.toLong() + + val intent = Intent(context, CountdownReceiver::class.java).apply { + action = "dev.tokenteam.iwut.notification.SHOW_COUNTDOWN" + putExtra("id", id) + putExtra("channelId", channelId) + putExtra("title", title) + putExtra("body", body) + putExtra("targetTimeMs", targetTimeMs.toLong()) + putExtra("ongoing", ongoing) + putExtra("autoDismiss", autoDismiss) + } + + val pendingIntent = PendingIntent.getBroadcast( + context, id, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + alarmManager?.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, trigger, pendingIntent + ) + + trackScheduledId(context, id) + } + + AsyncFunction("cancel") { id: Int -> + val context = appContext.reactContext ?: return@AsyncFunction + notificationManager?.cancel(id) + cancelScheduledAlarm(context, id) + removeTrackedId(context, id) + } + + AsyncFunction("cancelAll") { + val context = appContext.reactContext ?: return@AsyncFunction + notificationManager?.cancelAll() + cancelAllScheduledAlarms(context) + } + } + + private fun buildCountdownNotification( + context: Context, + channelId: String, + title: String, + body: String, + targetTimeMs: Long, + ongoing: Boolean, + ): android.app.Notification { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + val contentIntent = PendingIntent.getActivity( + context, 0, launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + return NotificationCompat.Builder(context, channelId) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setWhen(targetTimeMs) + .setUsesChronometer(true) + .setChronometerCountDown(true) + .setOngoing(ongoing) + .setContentIntent(contentIntent) + .setAutoCancel(!ongoing) + .build() + } + + private fun scheduleDismiss(context: Context, id: Int, targetTimeMs: Long) { + val intent = Intent(context, CountdownReceiver::class.java).apply { + action = "dev.tokenteam.iwut.notification.DISMISS" + putExtra("id", id) + } + val pendingIntent = PendingIntent.getBroadcast( + context, id + 100000, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + alarmManager?.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, targetTimeMs, pendingIntent + ) + } + + private fun cancelScheduledAlarm(context: Context, id: Int) { + val intent = Intent(context, CountdownReceiver::class.java) + val pendingIntent = PendingIntent.getBroadcast( + context, id, intent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE + ) + pendingIntent?.let { alarmManager?.cancel(it) } + } + + private fun cancelAllScheduledAlarms(context: Context) { + val ids = getTrackedIds(context) + for (id in ids) { + cancelScheduledAlarm(context, id) + val dismissIntent = Intent(context, CountdownReceiver::class.java) + val dismissPending = PendingIntent.getBroadcast( + context, id + 100000, dismissIntent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE + ) + dismissPending?.let { alarmManager?.cancel(it) } + } + clearTrackedIds(context) + } + + companion object { + private const val PREFS_NAME = "notification_ids" + private const val KEY_IDS = "scheduled_ids" + + fun trackScheduledId(context: Context, id: Int) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val ids = prefs.getStringSet(KEY_IDS, mutableSetOf()) ?: mutableSetOf() + val updated = ids.toMutableSet() + updated.add(id.toString()) + prefs.edit().putStringSet(KEY_IDS, updated).apply() + } + + fun getTrackedIds(context: Context): Set { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val ids = prefs.getStringSet(KEY_IDS, emptySet()) ?: emptySet() + return ids.mapNotNull { it.toIntOrNull() }.toSet() + } + + fun removeTrackedId(context: Context, id: Int) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val ids = prefs.getStringSet(KEY_IDS, mutableSetOf()) ?: mutableSetOf() + val updated = ids.toMutableSet() + updated.remove(id.toString()) + prefs.edit().putStringSet(KEY_IDS, updated).apply() + } + + fun clearTrackedIds(context: Context) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().remove(KEY_IDS).apply() + } + } +} diff --git a/modules/notification/expo-module.config.json b/modules/notification/expo-module.config.json new file mode 100644 index 0000000..57155ce --- /dev/null +++ b/modules/notification/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["android", "ios"], + "android": { + "modules": ["dev.tokenteam.iwut.notification.NotificationModule"] + }, + "ios": { + "modules": ["NotificationModule"] + } +} diff --git a/modules/notification/index.ts b/modules/notification/index.ts new file mode 100644 index 0000000..22f4de4 --- /dev/null +++ b/modules/notification/index.ts @@ -0,0 +1,39 @@ +import { requireNativeModule } from "expo-modules-core"; + +interface NotificationNativeModule { + createChannel(id: string, name: string, description: string): Promise; + + showCountdown( + id: number, + channelId: string, + title: string, + body: string, + targetTimeMs: number, + ongoing: boolean, + autoDismiss: boolean, + ): Promise; + + scheduleCountdown( + id: number, + channelId: string, + title: string, + body: string, + triggerAtMs: number, + targetTimeMs: number, + ongoing: boolean, + autoDismiss: boolean, + ): Promise; + + cancel(id: number): Promise; + + cancelAll(): Promise; +} + +const NativeModule = + requireNativeModule("Notification"); + +export const createChannel = NativeModule.createChannel; +export const showCountdown = NativeModule.showCountdown; +export const scheduleCountdown = NativeModule.scheduleCountdown; +export const cancel = NativeModule.cancel; +export const cancelAll = NativeModule.cancelAll; diff --git a/modules/notification/ios/Notification.podspec b/modules/notification/ios/Notification.podspec new file mode 100644 index 0000000..0e6e3da --- /dev/null +++ b/modules/notification/ios/Notification.podspec @@ -0,0 +1,19 @@ +Pod::Spec.new do |s| + s.name = 'Notification' + s.version = '1.0.0' + s.summary = '.' + s.homepage = 'https://github.com/tokenteam/iwut' + s.author = 'tokenteam' + s.platforms = { :ios => '15.1' } + 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/notification/ios/NotificationModule.swift b/modules/notification/ios/NotificationModule.swift new file mode 100644 index 0000000..87ea9a3 --- /dev/null +++ b/modules/notification/ios/NotificationModule.swift @@ -0,0 +1,27 @@ +import ExpoModulesCore + +public class NotificationModule: Module { + public func definition() -> ModuleDefinition { + Name("Notification") + + AsyncFunction("createChannel") { (_: String, _: String, _: String) in + // Android only, no-op on iOS + } + + AsyncFunction("showCountdown") { (_: Int, _: String, _: String, _: String, _: Double, _: Bool, _: Bool) in + // TODO: Implement iOS Live Activity + } + + AsyncFunction("scheduleCountdown") { (_: Int, _: String, _: String, _: String, _: Double, _: Double, _: Bool, _: Bool) in + // TODO: Implement iOS Live Activity scheduling + } + + AsyncFunction("cancel") { (_: Int) in + // TODO: Implement iOS Live Activity cancellation + } + + AsyncFunction("cancelAll") { () in + // TODO: Implement iOS Live Activity cleanup + } + } +} From 1421d50c28a98106575a506e9fb9d95c9762e97f Mon Sep 17 00:00:00 2001 From: zhxycn Date: Mon, 4 May 2026 21:38:47 +0800 Subject: [PATCH 2/7] =?UTF-8?q?:sparkles:=20feat:=20=E5=AE=89=E5=8D=93?= =?UTF-8?q?=E7=AB=AF=E8=AF=BE=E5=89=8D=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(pages)/settings/index.tsx | 117 +++++++++++++++++++++++++++++++- app/_layout.tsx | 10 +++ services/course-notification.ts | 69 +++++++++++++++++++ store/settings.ts | 8 +++ 4 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 services/course-notification.ts diff --git a/app/(pages)/settings/index.tsx b/app/(pages)/settings/index.tsx index 21a1bd7..1933d4c 100644 --- a/app/(pages)/settings/index.tsx +++ b/app/(pages)/settings/index.tsx @@ -5,17 +5,29 @@ 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, + Platform, + 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 { scheduleWeeklyReminders } 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); @@ -23,10 +35,45 @@ export default function SettingsScreen() { 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(null); + + const handleCourseReminderChange = async (value: boolean) => { + setCourseReminder(value); + await scheduleWeeklyReminders(); + }; + + 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); @@ -164,6 +211,37 @@ export default function SettingsScreen() { /> + {Platform.OS === "android" && ( + + + } + /> + {courseReminder && ( + + 提前 {reminderMinutes} 分钟 + + } + onPress={() => setReminderSheetVisible(true)} + /> + )} + + )} + + + setReminderSheetVisible(false)} + title="提醒时间" + > + {REMINDER_PRESETS.map((mins) => ( + handleReminderMinutesChange(mins)} + /> + ))} + + + 自定义 + + + 分钟 + + ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index 10c1b6a..2dbedb5 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -36,6 +36,10 @@ import Toast from "react-native-toast-message"; import { Themes } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { + initNotificationChannel, + scheduleWeeklyReminders, +} from "@/services/course-notification"; import { syncWidgetData } from "@/services/widget-sync"; import { useCourseStore } from "@/store/course"; import { useThemeStore } from "@/store/theme"; @@ -76,6 +80,11 @@ function RootLayout() { useUpdateStore.getState().check(); }, []); + useEffect(() => { + initNotificationChannel().catch(() => {}); + scheduleWeeklyReminders().catch(() => {}); + }, []); + useEffect(() => { syncWidgetData().catch(() => {}); const unsub = useCourseStore.subscribe((state, prev) => { @@ -84,6 +93,7 @@ function RootLayout() { state.termStart !== prev.termStart ) { syncWidgetData().catch(() => {}); + scheduleWeeklyReminders().catch(() => {}); } }); return unsub; diff --git a/services/course-notification.ts b/services/course-notification.ts new file mode 100644 index 0000000..9756e30 --- /dev/null +++ b/services/course-notification.ts @@ -0,0 +1,69 @@ +import { Platform } from "react-native"; + +import { + cancelAll, + createChannel, + scheduleCountdown, +} from "@/modules/notification"; +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"; + +export async function initNotificationChannel(): Promise { + if (Platform.OS === "android") { + await createChannel(CHANNEL_ID, "课程提醒", "在课程开始前显示倒计时通知"); + } +} + +export async function scheduleWeeklyReminders(): Promise { + const { courseReminder, reminderMinutes } = useSettingsStore.getState(); + + await cancelAll(); + + if (!courseReminder) return; + + const { courses, termStart } = useCourseStore.getState(); + if (!termStart || courses.length === 0) return; + + const currentWeek = getCurrentWeek(termStart); + const monday = getTermWeekMonday(termStart, currentWeek); + if (!monday) return; + + const now = Date.now(); + let idCounter = 0; + + for (const course of courses) { + if (currentWeek < course.weekStart || currentWeek > course.weekEnd) { + continue; + } + + const sectionTime = SECTION_TIMES[course.sectionStart]; + if (!sectionTime) continue; + + const [startTimeStr] = sectionTime; + const [startH, startM] = startTimeStr.split(":").map(Number); + + const courseDate = new Date(monday); + courseDate.setDate(courseDate.getDate() + course.day - 1); + courseDate.setHours(startH, startM, 0, 0); + + const classStartMs = courseDate.getTime(); + const triggerAtMs = classStartMs - reminderMinutes * 60 * 1000; + + if (triggerAtMs <= now) continue; + + await scheduleCountdown( + idCounter++, + CHANNEL_ID, + course.name, + `${startTimeStr} · ${course.room}`, + triggerAtMs, + classStartMs, + true, + true, + ); + } +} diff --git a/store/settings.ts b/store/settings.ts index 914785f..9b772b4 100644 --- a/store/settings.ts +++ b/store/settings.ts @@ -6,8 +6,12 @@ import { zustandStorage } from "@/lib/storage"; interface SettingsStore { hapticFeedback: boolean; openCourseOnLaunch: boolean; + courseReminder: boolean; + reminderMinutes: number; setHapticFeedback: (value: boolean) => void; setOpenCourseOnLaunch: (value: boolean) => void; + setCourseReminder: (value: boolean) => void; + setReminderMinutes: (value: number) => void; } export const useSettingsStore = create()( @@ -15,9 +19,13 @@ export const useSettingsStore = create()( (set) => ({ hapticFeedback: true, openCourseOnLaunch: false, + courseReminder: false, + reminderMinutes: 30, setHapticFeedback: (value: boolean) => set({ hapticFeedback: value }), setOpenCourseOnLaunch: (value: boolean) => set({ openCourseOnLaunch: value }), + setCourseReminder: (value: boolean) => set({ courseReminder: value }), + setReminderMinutes: (value: number) => set({ reminderMinutes: value }), }), { name: "settings", From 09b14adb425cbf1d90a02169252492090feab039 Mon Sep 17 00:00:00 2001 From: zhxycn Date: Mon, 4 May 2026 22:17:41 +0800 Subject: [PATCH 3/7] =?UTF-8?q?:sparkles:=20feat:=20=E4=BD=BF=E7=94=A8=20e?= =?UTF-8?q?xpo-background-task=20=E5=AE=9E=E7=8E=B0=E8=AF=BE=E7=A8=8B?= =?UTF-8?q?=E6=8F=90=E9=86=92=E5=90=8E=E5=8F=B0=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.config.ts | 1 + app/(pages)/settings/index.tsx | 11 ++- app/_layout.tsx | 2 + .../iwut/notification/NotificationModule.kt | 16 ++-- package.json | 2 + services/course-notification.ts | 87 +++++++++++++------ yarn.lock | 19 ++++ 7 files changed, 104 insertions(+), 34 deletions(-) diff --git a/app.config.ts b/app.config.ts index 8146233..cea3b7e 100644 --- a/app.config.ts +++ b/app.config.ts @@ -87,6 +87,7 @@ const config: ExpoConfig = { }, ], "@sentry/react-native", + "expo-background-task", "@bacons/apple-targets", "./plugins/with-gradle-props.js", ], diff --git a/app/(pages)/settings/index.tsx b/app/(pages)/settings/index.tsx index 1933d4c..fdf2745 100644 --- a/app/(pages)/settings/index.tsx +++ b/app/(pages)/settings/index.tsx @@ -22,7 +22,11 @@ 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 { scheduleWeeklyReminders } from "@/services/course-notification"; +import { + registerBackgroundRefresh, + scheduleWeeklyReminders, + unregisterBackgroundRefresh, +} from "@/services/course-notification"; import { useScheduleStore } from "@/store/schedule"; import { useSettingsStore } from "@/store/settings"; @@ -50,6 +54,11 @@ export default function SettingsScreen() { const handleCourseReminderChange = async (value: boolean) => { setCourseReminder(value); await scheduleWeeklyReminders(); + if (value) { + await registerBackgroundRefresh(); + } else { + await unregisterBackgroundRefresh(); + } }; const handleReminderMinutesChange = async (value: number) => { diff --git a/app/_layout.tsx b/app/_layout.tsx index 2dbedb5..8fce375 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -38,6 +38,7 @@ import { Themes } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; import { initNotificationChannel, + registerBackgroundRefresh, scheduleWeeklyReminders, } from "@/services/course-notification"; import { syncWidgetData } from "@/services/widget-sync"; @@ -83,6 +84,7 @@ function RootLayout() { useEffect(() => { initNotificationChannel().catch(() => {}); scheduleWeeklyReminders().catch(() => {}); + registerBackgroundRefresh().catch(() => {}); }, []); useEffect(() => { diff --git a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt index 2871b3f..22a973a 100644 --- a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt +++ b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt @@ -8,7 +8,6 @@ import android.content.Context import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition @@ -23,17 +22,18 @@ class NotificationModule : Module() { Name("Notification") AsyncFunction("createChannel") { id: String, name: String, description: String -> - val manager = notificationManager ?: return@AsyncFunction + val manager = notificationManager ?: return@AsyncFunction null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_HIGH).apply { this.description = description } manager.createNotificationChannel(channel) } + null } AsyncFunction("showCountdown") { id: Int, channelId: String, title: String, body: String, targetTimeMs: Double, ongoing: Boolean, autoDismiss: Boolean -> - val context = appContext.reactContext ?: return@AsyncFunction + val context = appContext.reactContext ?: return@AsyncFunction null val target = targetTimeMs.toLong() val notification = buildCountdownNotification(context, channelId, title, body, target, ongoing) @@ -42,10 +42,11 @@ class NotificationModule : Module() { if (autoDismiss) { scheduleDismiss(context, id, target) } + null } AsyncFunction("scheduleCountdown") { id: Int, channelId: String, title: String, body: String, triggerAtMs: Double, targetTimeMs: Double, ongoing: Boolean, autoDismiss: Boolean -> - val context = appContext.reactContext ?: return@AsyncFunction + val context = appContext.reactContext ?: return@AsyncFunction null val trigger = triggerAtMs.toLong() val intent = Intent(context, CountdownReceiver::class.java).apply { @@ -69,19 +70,22 @@ class NotificationModule : Module() { ) trackScheduledId(context, id) + null } AsyncFunction("cancel") { id: Int -> - val context = appContext.reactContext ?: return@AsyncFunction + val context = appContext.reactContext ?: return@AsyncFunction null notificationManager?.cancel(id) cancelScheduledAlarm(context, id) removeTrackedId(context, id) + null } AsyncFunction("cancelAll") { - val context = appContext.reactContext ?: return@AsyncFunction + val context = appContext.reactContext ?: return@AsyncFunction null notificationManager?.cancelAll() cancelAllScheduledAlarms(context) + null } } diff --git a/package.json b/package.json index 4c7ce52..3498da3 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@sentry/react-native": "~7.11.0", "@tailwindcss/postcss": "^4.2.4", "expo": "~55.0.19", + "expo-background-task": "~55.0.17", "expo-build-properties": "~55.0.13", "expo-clipboard": "~55.0.13", "expo-constants": "~55.0.15", @@ -38,6 +39,7 @@ "expo-splash-screen": "~55.0.19", "expo-status-bar": "~55.0.5", "expo-system-ui": "~55.0.16", + "expo-task-manager": "~55.0.15", "expo-updates": "~55.0.21", "expo-web-browser": "~55.0.14", "jszip": "^3.10.1", diff --git a/services/course-notification.ts b/services/course-notification.ts index 9756e30..7e447a8 100644 --- a/services/course-notification.ts +++ b/services/course-notification.ts @@ -1,3 +1,5 @@ +import * as BackgroundTask from "expo-background-task"; +import * as TaskManager from "expo-task-manager"; import { Platform } from "react-native"; import { @@ -11,6 +13,36 @@ import { useSettingsStore } from "@/store/settings"; import { getCurrentWeek, getTermWeekMonday } from "@/lib/date"; const CHANNEL_ID = "course_reminder"; +const BACKGROUND_TASK_NAME = "course-reminder-refresh"; +const SCHEDULE_WEEKS = 2; + +TaskManager.defineTask(BACKGROUND_TASK_NAME, async () => { + try { + await initNotificationChannel(); + await scheduleWeeklyReminders(); + return BackgroundTask.BackgroundTaskResult.Success; + } catch { + return BackgroundTask.BackgroundTaskResult.Failed; + } +}); + +export async function registerBackgroundRefresh(): Promise { + const isRegistered = + await TaskManager.isTaskRegisteredAsync(BACKGROUND_TASK_NAME); + if (isRegistered) return; + + await BackgroundTask.registerTaskAsync(BACKGROUND_TASK_NAME, { + minimumInterval: 60 * 6, + }); +} + +export async function unregisterBackgroundRefresh(): Promise { + const isRegistered = + await TaskManager.isTaskRegisteredAsync(BACKGROUND_TASK_NAME); + if (!isRegistered) return; + + await BackgroundTask.unregisterTaskAsync(BACKGROUND_TASK_NAME); +} export async function initNotificationChannel(): Promise { if (Platform.OS === "android") { @@ -29,41 +61,42 @@ export async function scheduleWeeklyReminders(): Promise { if (!termStart || courses.length === 0) return; const currentWeek = getCurrentWeek(termStart); - const monday = getTermWeekMonday(termStart, currentWeek); - if (!monday) return; - const now = Date.now(); let idCounter = 0; - for (const course of courses) { - if (currentWeek < course.weekStart || currentWeek > course.weekEnd) { - continue; - } + for (let offset = 0; offset < SCHEDULE_WEEKS; offset++) { + const week = currentWeek + offset; + const monday = getTermWeekMonday(termStart, week); + if (!monday) continue; - const sectionTime = SECTION_TIMES[course.sectionStart]; - if (!sectionTime) continue; + for (const course of courses) { + if (week < course.weekStart || week > course.weekEnd) continue; - const [startTimeStr] = sectionTime; - const [startH, startM] = startTimeStr.split(":").map(Number); + const sectionTime = SECTION_TIMES[course.sectionStart]; + if (!sectionTime) continue; - const courseDate = new Date(monday); - courseDate.setDate(courseDate.getDate() + course.day - 1); - courseDate.setHours(startH, startM, 0, 0); + const [startTimeStr] = sectionTime; + const [startH, startM] = startTimeStr.split(":").map(Number); - const classStartMs = courseDate.getTime(); - const triggerAtMs = classStartMs - reminderMinutes * 60 * 1000; + const courseDate = new Date(monday); + courseDate.setDate(courseDate.getDate() + course.day - 1); + courseDate.setHours(startH, startM, 0, 0); - if (triggerAtMs <= now) continue; + const classStartMs = courseDate.getTime(); + const triggerAtMs = classStartMs - reminderMinutes * 60 * 1000; - await scheduleCountdown( - idCounter++, - CHANNEL_ID, - course.name, - `${startTimeStr} · ${course.room}`, - triggerAtMs, - classStartMs, - true, - true, - ); + if (triggerAtMs <= now) continue; + + await scheduleCountdown( + idCounter++, + CHANNEL_ID, + course.name, + `${startTimeStr} · ${course.room}`, + triggerAtMs, + classStartMs, + true, + true, + ); + } } } diff --git a/yarn.lock b/yarn.lock index 1c2bdf0..bcd750b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3847,6 +3847,13 @@ expo-asset@~55.0.16: "@expo/image-utils" "^0.8.13" expo-constants "~55.0.15" +expo-background-task@~55.0.17: + version "55.0.17" + resolved "https://mirrors.cloud.tencent.com/npm/expo-background-task/-/expo-background-task-55.0.17.tgz#f488939cb95c94185e6fa35ca7f8184e934c7b15" + integrity sha512-XJD971fbELfvz7OveP3mBEoPovXOt67EJWcpDWZOamKeCy/vJ4uPkBpStrq2h1oKh2+YexuA76gbjo6q6NdmWw== + dependencies: + expo-task-manager "~55.0.15" + expo-build-properties@~55.0.13: version "55.0.13" resolved "https://mirrors.cloud.tencent.com/npm/expo-build-properties/-/expo-build-properties-55.0.13.tgz#e44247a14424fa7a1597f29f99cd84a69c25385b" @@ -4093,6 +4100,13 @@ expo-system-ui@~55.0.16: "@react-native/normalize-colors" "0.83.6" debug "^4.3.2" +expo-task-manager@~55.0.15: + version "55.0.15" + resolved "https://mirrors.cloud.tencent.com/npm/expo-task-manager/-/expo-task-manager-55.0.15.tgz#b1cbb617fb1bfc6c602ef3f8a627c7ddc1d505ac" + integrity sha512-wLqYkKBp9cxIonEIp3LYy9iFjlOxxw4ca8nZLdSriKVxzPvdUwX6cZ4g55Fi+uSi4oPVFo9JYFKVUEofc+do+A== + dependencies: + unimodules-app-loader "~55.0.5" + expo-updates-interface@~55.1.6: version "55.1.6" resolved "https://mirrors.cloud.tencent.com/npm/expo-updates-interface/-/expo-updates-interface-55.1.6.tgz#18a806d65a28229ed4a7f920fceb9dda678c5c09" @@ -7193,6 +7207,11 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://mirrors.cloud.tencent.com/npm/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz#301d4f8a43d2b75c97adfad87c9dd5350c9475d1" integrity sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ== +unimodules-app-loader@~55.0.5: + version "55.0.5" + resolved "https://mirrors.cloud.tencent.com/npm/unimodules-app-loader/-/unimodules-app-loader-55.0.5.tgz#3542667367932ef1cb3fa6f52c6dd4120dab3ed8" + integrity sha512-2eLjtaAVQTK3EeiUAgRbfEnX78f6cMtw5Js8Ri4OcEdkrozsmvG3Wu8YVfr6kfhea17FHZkKZmO1m4dL/Ky2Bg== + unpipe@~1.0.0: version "1.0.0" resolved "https://mirrors.cloud.tencent.com/npm/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" From fabfa84afb89fc5b7a59ea4b737dc96a95277a5e Mon Sep 17 00:00:00 2001 From: zhxycn Date: Tue, 5 May 2026 00:22:03 +0800 Subject: [PATCH 4/7] =?UTF-8?q?:bug:=20fix:=20=E4=BF=AE=E5=A4=8D=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=80=92=E8=AE=A1=E6=97=B6=E5=BC=82=E5=B8=B8=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(pages)/settings/index.tsx | 29 ++++++++++++++++++- .../iwut/notification/CountdownReceiver.kt | 21 ++++---------- .../iwut/notification/NotificationModule.kt | 5 ++++ 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/app/(pages)/settings/index.tsx b/app/(pages)/settings/index.tsx index fdf2745..b502149 100644 --- a/app/(pages)/settings/index.tsx +++ b/app/(pages)/settings/index.tsx @@ -1,3 +1,4 @@ +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"; @@ -9,6 +10,7 @@ import { useRef, useState } from "react"; import { ActivityIndicator, Platform, + Pressable, ScrollView, Switch, Text, @@ -302,7 +304,18 @@ export default function SettingsScreen() { 分钟 + + + diff --git a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt index a51d603..3e4a59f 100644 --- a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt +++ b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt @@ -1,6 +1,5 @@ package dev.tokenteam.iwut.notification -import android.app.AlarmManager import android.app.NotificationManager import android.app.PendingIntent import android.content.BroadcastReceiver @@ -31,6 +30,8 @@ class CountdownReceiver : BroadcastReceiver() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val timeoutMs = targetTimeMs - System.currentTimeMillis() + val notification = NotificationCompat.Builder(context, channelId) .setContentTitle(title) .setContentText(body) @@ -41,26 +42,14 @@ class CountdownReceiver : BroadcastReceiver() { .setOngoing(ongoing) .setContentIntent(contentIntent) .setAutoCancel(!ongoing) + .apply { + if (autoDismiss && timeoutMs > 0) setTimeoutAfter(timeoutMs) + } .build() val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.notify(id, notification) - if (autoDismiss && targetTimeMs > System.currentTimeMillis()) { - val dismissIntent = Intent(context, CountdownReceiver::class.java).apply { - action = ACTION_DISMISS - putExtra("id", id) - } - val dismissPending = PendingIntent.getBroadcast( - context, id + 100000, dismissIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - alarmManager.setExactAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, targetTimeMs, dismissPending - ) - } - NotificationModule.removeTrackedId(context, id) } diff --git a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt index 22a973a..c9b952a 100644 --- a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt +++ b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt @@ -103,6 +103,8 @@ class NotificationModule : Module() { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + val timeoutMs = targetTimeMs - System.currentTimeMillis() + return NotificationCompat.Builder(context, channelId) .setContentTitle(title) .setContentText(body) @@ -113,6 +115,9 @@ class NotificationModule : Module() { .setOngoing(ongoing) .setContentIntent(contentIntent) .setAutoCancel(!ongoing) + .apply { + if (timeoutMs > 0) setTimeoutAfter(timeoutMs) + } .build() } From d4dea54e4d12761b3e783c7dbd770e5ebad5041a Mon Sep 17 00:00:00 2001 From: zhxycn Date: Tue, 5 May 2026 00:48:56 +0800 Subject: [PATCH 5/7] =?UTF-8?q?:sparkles:=20feat:=20=E9=80=82=E9=85=8D=20A?= =?UTF-8?q?ndroid=2016=20PromotedOngoing=20=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- modules/notification/android/build.gradle | 4 +++ .../android/src/main/AndroidManifest.xml | 1 + .../iwut/notification/CountdownReceiver.kt | 27 ++++++++++++--- .../iwut/notification/NotificationModule.kt | 34 +++++-------------- .../src/main/res/drawable/ic_notification.xml | 10 ++++++ services/course-notification.ts | 2 +- 6 files changed, 47 insertions(+), 31 deletions(-) create mode 100644 modules/notification/android/src/main/res/drawable/ic_notification.xml diff --git a/modules/notification/android/build.gradle b/modules/notification/android/build.gradle index 4b402b7..ac94cbc 100644 --- a/modules/notification/android/build.gradle +++ b/modules/notification/android/build.gradle @@ -12,3 +12,7 @@ expoModule { android { namespace "dev.tokenteam.iwut.notification" } + +dependencies { + implementation 'androidx.core:core:1.18.0' +} diff --git a/modules/notification/android/src/main/AndroidManifest.xml b/modules/notification/android/src/main/AndroidManifest.xml index 10dc681..24081c5 100644 --- a/modules/notification/android/src/main/AndroidManifest.xml +++ b/modules/notification/android/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + diff --git a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt index 3e4a59f..49d7266 100644 --- a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt +++ b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/CountdownReceiver.kt @@ -5,6 +5,7 @@ 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() { @@ -24,6 +25,16 @@ class CountdownReceiver : BroadcastReceiver() { 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, @@ -32,25 +43,31 @@ class CountdownReceiver : BroadcastReceiver() { val timeoutMs = targetTimeMs - System.currentTimeMillis() - val notification = NotificationCompat.Builder(context, channelId) + val builder = NotificationCompat.Builder(context, channelId) .setContentTitle(title) .setContentText(body) - .setSmallIcon(android.R.drawable.ic_dialog_info) + .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) } - .build() + + 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) - - NotificationModule.removeTrackedId(context, id) } private fun handleDismiss(context: Context, intent: Intent) { diff --git a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt index c9b952a..16a45de 100644 --- a/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt +++ b/modules/notification/android/src/main/java/dev/tokenteam/iwut/notification/NotificationModule.kt @@ -38,10 +38,6 @@ class NotificationModule : Module() { val notification = buildCountdownNotification(context, channelId, title, body, target, ongoing) notificationManager?.notify(id, notification) - - if (autoDismiss) { - scheduleDismiss(context, id, target) - } null } @@ -105,34 +101,28 @@ class NotificationModule : Module() { val timeoutMs = targetTimeMs - System.currentTimeMillis() - return NotificationCompat.Builder(context, channelId) + val builder = NotificationCompat.Builder(context, channelId) .setContentTitle(title) .setContentText(body) - .setSmallIcon(android.R.drawable.ic_dialog_info) + .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 (timeoutMs > 0) setTimeoutAfter(timeoutMs) } - .build() - } - private fun scheduleDismiss(context: Context, id: Int, targetTimeMs: Long) { - val intent = Intent(context, CountdownReceiver::class.java).apply { - action = "dev.tokenteam.iwut.notification.DISMISS" - putExtra("id", id) + if (Build.VERSION.SDK_INT >= 36) { + builder.setRequestPromotedOngoing(true) + builder.setShortCriticalText(body) } - val pendingIntent = PendingIntent.getBroadcast( - context, id + 100000, intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - alarmManager?.setExactAndAllowWhileIdle( - AlarmManager.RTC_WAKEUP, targetTimeMs, pendingIntent - ) + + return builder.build() } private fun cancelScheduledAlarm(context: Context, id: Int) { @@ -148,12 +138,6 @@ class NotificationModule : Module() { val ids = getTrackedIds(context) for (id in ids) { cancelScheduledAlarm(context, id) - val dismissIntent = Intent(context, CountdownReceiver::class.java) - val dismissPending = PendingIntent.getBroadcast( - context, id + 100000, dismissIntent, - PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE - ) - dismissPending?.let { alarmManager?.cancel(it) } } clearTrackedIds(context) } diff --git a/modules/notification/android/src/main/res/drawable/ic_notification.xml b/modules/notification/android/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..3028a75 --- /dev/null +++ b/modules/notification/android/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + diff --git a/services/course-notification.ts b/services/course-notification.ts index 7e447a8..761084b 100644 --- a/services/course-notification.ts +++ b/services/course-notification.ts @@ -91,7 +91,7 @@ export async function scheduleWeeklyReminders(): Promise { idCounter++, CHANNEL_ID, course.name, - `${startTimeStr} · ${course.room}`, + `${course.room} · ${startTimeStr}`, triggerAtMs, classStartMs, true, From 14221c9bd9cf50fcbbfdffa4fb5b39c7bfdc9db9 Mon Sep 17 00:00:00 2001 From: zhxycn Date: Tue, 5 May 2026 02:26:42 +0800 Subject: [PATCH 6/7] =?UTF-8?q?:robot:=20fix:=20=E5=BC=80=E5=90=AF?= =?UTF-8?q?=E8=AF=BE=E5=89=8D=E6=8F=90=E9=86=92=E6=97=B6=E4=B8=BB=E5=8A=A8?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E9=80=9A=E7=9F=A5=E6=9D=83=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(pages)/settings/index.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/(pages)/settings/index.tsx b/app/(pages)/settings/index.tsx index b502149..3062f68 100644 --- a/app/(pages)/settings/index.tsx +++ b/app/(pages)/settings/index.tsx @@ -9,6 +9,7 @@ import JSZip from "jszip"; import { useRef, useState } from "react"; import { ActivityIndicator, + PermissionsAndroid, Platform, Pressable, ScrollView, @@ -54,6 +55,14 @@ export default function SettingsScreen() { const customInputRef = useRef(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) { From 15bd073a0809ca0b437070eee127bf60ea2f082d Mon Sep 17 00:00:00 2001 From: zhxycn Date: Tue, 5 May 2026 18:55:42 +0800 Subject: [PATCH 7/7] =?UTF-8?q?:sparkles:=20feat:=20=E5=AE=9E=E7=8E=B0=20i?= =?UTF-8?q?OS=20=E7=AB=AF=E8=AF=BE=E5=89=8D=E6=8F=90=E9=86=92=E5=92=8C=20L?= =?UTF-8?q?ive=20Activity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.config.ts | 1 + app/(pages)/settings/index.tsx | 50 +++-- app/_layout.tsx | 14 +- .../ios/CountdownActivityAttributes.swift | 12 ++ modules/notification/ios/Notification.podspec | 2 + .../notification/ios/NotificationModule.swift | 171 +++++++++++++++++- services/course-notification.ts | 62 +++++++ targets/widget/CountdownActivityWidget.swift | 65 +++++++ .../Models/CountdownActivityAttributes.swift | 11 ++ targets/widget/ScheduleWidget.swift | 12 +- targets/widget/WidgetBundle.swift | 1 + targets/widget/expo-target.config.js | 3 +- 12 files changed, 367 insertions(+), 37 deletions(-) create mode 100644 modules/notification/ios/CountdownActivityAttributes.swift create mode 100644 targets/widget/CountdownActivityWidget.swift create mode 100644 targets/widget/Models/CountdownActivityAttributes.swift diff --git a/app.config.ts b/app.config.ts index cea3b7e..37154fa 100644 --- a/app.config.ts +++ b/app.config.ts @@ -38,6 +38,7 @@ const config: ExpoConfig = { supportsTablet: true, infoPlist: { ITSAppUsesNonExemptEncryption: false, + NSSupportsLiveActivities: true, NSLocationWhenInUseUsageDescription: "用于在连接校园网时读取当前 Wi-Fi 相关信息并完成网络认证", NSAppTransportSecurity: { diff --git a/app/(pages)/settings/index.tsx b/app/(pages)/settings/index.tsx index 3062f68..41f7799 100644 --- a/app/(pages)/settings/index.tsx +++ b/app/(pages)/settings/index.tsx @@ -231,36 +231,34 @@ export default function SettingsScreen() { /> - {Platform.OS === "android" && ( - + + + } + /> + {courseReminder && ( + + 提前 {reminderMinutes} 分钟 + } + onPress={() => setReminderSheetVisible(true)} /> - {courseReminder && ( - - 提前 {reminderMinutes} 分钟 - - } - onPress={() => setReminderSheetVisible(true)} - /> - )} - - )} + )} + {}); 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(() => { diff --git a/modules/notification/ios/CountdownActivityAttributes.swift b/modules/notification/ios/CountdownActivityAttributes.swift new file mode 100644 index 0000000..563fd79 --- /dev/null +++ b/modules/notification/ios/CountdownActivityAttributes.swift @@ -0,0 +1,12 @@ +import ActivityKit +import Foundation + +@available(iOS 16.2, *) +public struct CountdownActivityAttributes: ActivityAttributes { + public struct ContentState: Codable & Hashable { + public let targetTime: Date + } + + public let title: String + public let subtitle: String +} diff --git a/modules/notification/ios/Notification.podspec b/modules/notification/ios/Notification.podspec index 0e6e3da..9a8ec0f 100644 --- a/modules/notification/ios/Notification.podspec +++ b/modules/notification/ios/Notification.podspec @@ -12,6 +12,8 @@ Pod::Spec.new do |s| s.dependency 'ExpoModulesCore' s.source_files = '**/*.{h,m,swift}' + s.weak_frameworks = 'ActivityKit' + s.frameworks = 'UserNotifications' s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'SWIFT_COMPILATION_MODE' => 'wholemodule' diff --git a/modules/notification/ios/NotificationModule.swift b/modules/notification/ios/NotificationModule.swift index 87ea9a3..206d2ba 100644 --- a/modules/notification/ios/NotificationModule.swift +++ b/modules/notification/ios/NotificationModule.swift @@ -1,27 +1,182 @@ +import ActivityKit import ExpoModulesCore +import UserNotifications public class NotificationModule: Module { + private let delegateProxy = NotificationDelegateProxy() + public func definition() -> ModuleDefinition { Name("Notification") + OnCreate { + let center = UNUserNotificationCenter.current() + center.delegate = self.delegateProxy + center.requestAuthorization(options: [.alert, .sound, .badge]) { _, _ in } + } + AsyncFunction("createChannel") { (_: String, _: String, _: String) in - // Android only, no-op on iOS } - AsyncFunction("showCountdown") { (_: Int, _: String, _: String, _: String, _: Double, _: Bool, _: Bool) in - // TODO: Implement iOS Live Activity + AsyncFunction("showCountdown") { (id: Int, _: String, title: String, body: String, targetTimeMs: Double, _: Bool, autoDismiss: Bool) in + let targetDate = Date(timeIntervalSince1970: targetTimeMs / 1000.0) + + if #available(iOS 16.2, *) { + try self.startLiveActivity(id: id, title: title, body: body, targetDate: targetDate) + } else { + self.showLocalNotification(id: id, title: title, body: body, triggerDate: nil, autoDismiss: autoDismiss) + } } - AsyncFunction("scheduleCountdown") { (_: Int, _: String, _: String, _: String, _: Double, _: Double, _: Bool, _: Bool) in - // TODO: Implement iOS Live Activity scheduling + AsyncFunction("scheduleCountdown") { (id: Int, _: String, title: String, body: String, triggerAtMs: Double, targetTimeMs: Double, _: Bool, autoDismiss: Bool) in + let triggerDate = Date(timeIntervalSince1970: triggerAtMs / 1000.0) + let targetDate = Date(timeIntervalSince1970: targetTimeMs / 1000.0) + + if #available(iOS 16.2, *) { + let now = Date() + if triggerDate <= now { + try self.startLiveActivity(id: id, title: title, body: body, targetDate: targetDate) + } else { + self.scheduleLiveActivityViaNotification(id: id, title: title, body: body, triggerDate: triggerDate, targetDate: targetDate) + } + } else { + self.showLocalNotification(id: id, title: title, body: body, triggerDate: triggerDate, autoDismiss: autoDismiss) + } } - AsyncFunction("cancel") { (_: Int) in - // TODO: Implement iOS Live Activity cancellation + AsyncFunction("cancel") { (id: Int) in + if #available(iOS 16.2, *) { + await self.endLiveActivity(id: id) + } + self.cancelLocalNotification(id: id) } AsyncFunction("cancelAll") { () in - // TODO: Implement iOS Live Activity cleanup + if #available(iOS 16.2, *) { + await self.endAllLiveActivities() + } + self.cancelAllLocalNotifications() + } + } + + @available(iOS 16.2, *) + private func startLiveActivity(id: Int, title: String, body: String, targetDate: Date) throws { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } + + let attributes = CountdownActivityAttributes(title: title, subtitle: body) + let state = CountdownActivityAttributes.ContentState(targetTime: targetDate) + + let content = ActivityContent(state: state, staleDate: targetDate) + let activity = try Activity.request( + attributes: attributes, + content: content, + pushType: nil + ) + + self.saveActivityMapping(id: id, activityId: activity.id) + } + + @available(iOS 16.2, *) + private func endLiveActivity(id: Int) async { + guard let activityId = self.loadActivityId(for: id) else { return } + + for activity in Activity.activities where activity.id == activityId { + await activity.end(nil, dismissalPolicy: .immediate) + } + self.removeActivityMapping(id: id) + } + + @available(iOS 16.2, *) + private func endAllLiveActivities() async { + for activity in Activity.activities { + await activity.end(nil, dismissalPolicy: .immediate) + } + self.clearAllActivityMappings() + } + + @available(iOS 16.2, *) + private func scheduleLiveActivityViaNotification(id: Int, title: String, body: String, triggerDate: Date, targetDate: Date) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.userInfo = [ + "liveActivityId": id, + "targetTimeMs": targetDate.timeIntervalSince1970 * 1000, + "isLiveActivityTrigger": true, + ] + + let interval = triggerDate.timeIntervalSinceNow + guard interval > 0 else { return } + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false) + let request = UNNotificationRequest(identifier: "notification_\(id)", content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) + } + + private func showLocalNotification(id: Int, title: String, body: String, triggerDate: Date?, autoDismiss: Bool) { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + + let trigger: UNNotificationTrigger? + if let triggerDate = triggerDate { + let interval = triggerDate.timeIntervalSinceNow + guard interval > 0 else { return } + trigger = UNTimeIntervalNotificationTrigger(timeInterval: interval, repeats: false) + } else { + trigger = nil } + + let request = UNNotificationRequest(identifier: "notification_\(id)", content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) + } + + private func cancelLocalNotification(id: Int) { + UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ["notification_\(id)"]) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: ["notification_\(id)"]) + } + + private func cancelAllLocalNotifications() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + } + + private static let suiteName = "group.dev.tokenteam.iwut" + private static let activityMapKey = "live_activity_map" + + private func saveActivityMapping(id: Int, activityId: String) { + guard let defaults = UserDefaults(suiteName: Self.suiteName) else { return } + var map = defaults.dictionary(forKey: Self.activityMapKey) as? [String: String] ?? [:] + map[String(id)] = activityId + defaults.set(map, forKey: Self.activityMapKey) + } + + private func loadActivityId(for id: Int) -> String? { + guard let defaults = UserDefaults(suiteName: Self.suiteName) else { return nil } + let map = defaults.dictionary(forKey: Self.activityMapKey) as? [String: String] ?? [:] + return map[String(id)] + } + + private func removeActivityMapping(id: Int) { + guard let defaults = UserDefaults(suiteName: Self.suiteName) else { return } + var map = defaults.dictionary(forKey: Self.activityMapKey) as? [String: String] ?? [:] + map.removeValue(forKey: String(id)) + defaults.set(map, forKey: Self.activityMapKey) + } + + private func clearAllActivityMappings() { + guard let defaults = UserDefaults(suiteName: Self.suiteName) else { return } + defaults.removeObject(forKey: Self.activityMapKey) + } +} + +private class NotificationDelegateProxy: NSObject, UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .sound]) } } diff --git a/services/course-notification.ts b/services/course-notification.ts index 761084b..2921c02 100644 --- a/services/course-notification.ts +++ b/services/course-notification.ts @@ -6,6 +6,7 @@ import { cancelAll, createChannel, scheduleCountdown, + showCountdown, } from "@/modules/notification"; import { SECTION_TIMES } from "@/services/course-time"; import { useCourseStore } from "@/store/course"; @@ -15,6 +16,7 @@ import { getCurrentWeek, getTermWeekMonday } from "@/lib/date"; const CHANNEL_ID = "course_reminder"; const BACKGROUND_TASK_NAME = "course-reminder-refresh"; const SCHEDULE_WEEKS = 2; +const LIVE_ACTIVITY_ID = 9999; TaskManager.defineTask(BACKGROUND_TASK_NAME, async () => { try { @@ -50,6 +52,66 @@ export async function initNotificationChannel(): Promise { } } +export async function showUpcomingLiveActivity(): Promise { + if (Platform.OS !== "ios") return; + + const { courseReminder, reminderMinutes } = useSettingsStore.getState(); + if (!courseReminder) return; + + const { courses, termStart } = useCourseStore.getState(); + if (!termStart || courses.length === 0) return; + + const currentWeek = getCurrentWeek(termStart); + const now = Date.now(); + const windowMs = reminderMinutes * 60 * 1000; + + let nearest: { name: string; info: string; classStartMs: number } | null = + null; + + for (const course of courses) { + if (currentWeek < course.weekStart || currentWeek > course.weekEnd) + continue; + + const sectionTime = SECTION_TIMES[course.sectionStart]; + if (!sectionTime) continue; + + const [startTimeStr] = sectionTime; + const [startH, startM] = startTimeStr.split(":").map(Number); + + const monday = getTermWeekMonday(termStart, currentWeek); + if (!monday) continue; + + const courseDate = new Date(monday); + courseDate.setDate(courseDate.getDate() + course.day - 1); + courseDate.setHours(startH, startM, 0, 0); + + const classStartMs = courseDate.getTime(); + const triggerAtMs = classStartMs - windowMs; + + if (now >= triggerAtMs && now < classStartMs) { + if (!nearest || classStartMs < nearest.classStartMs) { + nearest = { + name: course.name, + info: `${course.room} · ${startTimeStr}`, + classStartMs, + }; + } + } + } + + if (nearest) { + await showCountdown( + LIVE_ACTIVITY_ID, + CHANNEL_ID, + nearest.name, + nearest.info, + nearest.classStartMs, + true, + true, + ); + } +} + export async function scheduleWeeklyReminders(): Promise { const { courseReminder, reminderMinutes } = useSettingsStore.getState(); diff --git a/targets/widget/CountdownActivityWidget.swift b/targets/widget/CountdownActivityWidget.swift new file mode 100644 index 0000000..967e154 --- /dev/null +++ b/targets/widget/CountdownActivityWidget.swift @@ -0,0 +1,65 @@ +import ActivityKit +import SwiftUI +import WidgetKit + +struct CountdownActivityWidget: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: CountdownActivityAttributes.self) { context in + LockScreenView(context: context) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + Text(context.attributes.title) + .font(.headline) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.trailing) { + Text(timerInterval: Date.now...context.state.targetTime, countsDown: true) + .font(.title3.monospacedDigit()) + .frame(width: 72) + .multilineTextAlignment(.trailing) + } + DynamicIslandExpandedRegion(.bottom) { + Text(context.attributes.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } compactLeading: { + Text(context.attributes.title) + .font(.caption2) + .lineLimit(1) + } compactTrailing: { + Text(context.attributes.subtitle) + .font(.caption2) + .lineLimit(1) + } minimal: { + Image(systemName: "timer") + .foregroundStyle(.indigo) + } + } + } +} + +private struct LockScreenView: View { + let context: ActivityViewContext + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(context.attributes.title) + .font(.headline) + .lineLimit(1) + Text(context.attributes.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Text(timerInterval: Date.now...context.state.targetTime, countsDown: true) + .font(.title2.monospacedDigit()) + .foregroundStyle(.indigo) + .multilineTextAlignment(.trailing) + } + .padding() + } +} diff --git a/targets/widget/Models/CountdownActivityAttributes.swift b/targets/widget/Models/CountdownActivityAttributes.swift new file mode 100644 index 0000000..8e51596 --- /dev/null +++ b/targets/widget/Models/CountdownActivityAttributes.swift @@ -0,0 +1,11 @@ +import ActivityKit +import Foundation + +public struct CountdownActivityAttributes: ActivityAttributes { + public struct ContentState: Codable & Hashable { + public let targetTime: Date + } + + public let title: String + public let subtitle: String +} diff --git a/targets/widget/ScheduleWidget.swift b/targets/widget/ScheduleWidget.swift index 9082d29..22cee59 100644 --- a/targets/widget/ScheduleWidget.swift +++ b/targets/widget/ScheduleWidget.swift @@ -14,9 +14,19 @@ struct ScheduleWidget: Widget { .background(Color("WidgetBackground")) } } - .contentMarginsDisabled() + .safeContentMarginsDisabled() .configurationDisplayName("课程表") .description("今天有什么课?看这里就够啦~") .supportedFamilies([.systemMedium]) } } + +extension WidgetConfiguration { + func safeContentMarginsDisabled() -> some WidgetConfiguration { + if #available(iOSApplicationExtension 17.0, *) { + return self.contentMarginsDisabled() + } else { + return self + } + } +} diff --git a/targets/widget/WidgetBundle.swift b/targets/widget/WidgetBundle.swift index d6f7e7a..5e466a4 100644 --- a/targets/widget/WidgetBundle.swift +++ b/targets/widget/WidgetBundle.swift @@ -5,5 +5,6 @@ import WidgetKit struct IwutWidgetBundle: WidgetBundle { var body: some Widget { ScheduleWidget() + CountdownActivityWidget() } } diff --git a/targets/widget/expo-target.config.js b/targets/widget/expo-target.config.js index 42e5800..96df8bb 100644 --- a/targets/widget/expo-target.config.js +++ b/targets/widget/expo-target.config.js @@ -2,7 +2,8 @@ module.exports = { type: "widget", name: "ScheduleWidget", - deploymentTarget: "17.0", + deploymentTarget: "16.2", + frameworks: ["ActivityKit"], entitlements: { "com.apple.security.application-groups": ["group.dev.tokenteam.iwut"], },