From 48941f20d425adfbd579ef815bac61da6a530f28 Mon Sep 17 00:00:00 2001 From: zhxycn Date: Sat, 2 May 2026 13:30:03 +0800 Subject: [PATCH 1/5] =?UTF-8?q?:sparkles:=20feat:=20=E5=AE=89=E5=8D=93?= =?UTF-8?q?=E5=B0=8F=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + app.config.ts | 3 + app/_layout.tsx | 15 + modules/widget/android/build.gradle | 18 ++ .../android/src/main/AndroidManifest.xml | 14 + .../dev/tokenteam/iwut/widget/ScheduleData.kt | 41 +++ .../tokenteam/iwut/widget/ScheduleWidget.kt | 92 ++++++ .../dev/tokenteam/iwut/widget/WidgetModule.kt | 38 +++ .../drawable/ic_widget_schedule_all_done.xml | 86 +++++ .../res/drawable/ic_widget_schedule_bg.xml | 41 +++ .../drawable/widget_schedule_background.xml | 6 + .../drawable/widget_schedule_date_hint_bg.xml | 8 + .../widget_schedule_day_of_week_bg.xml | 6 + .../src/main/res/layout/widget_schedule.xml | 299 ++++++++++++++++++ .../main/res/values-night/widget_colors.xml | 9 + .../src/main/res/values/widget_colors.xml | 9 + .../src/main/res/values/widget_dimens.xml | 14 + .../src/main/res/values/widget_strings.xml | 5 + .../src/main/res/xml/widget_schedule_info.xml | 8 + modules/widget/expo-module.config.json | 9 + modules/widget/index.ts | 19 ++ modules/widget/ios/WidgetModule.swift | 19 ++ services/widget-sync.ts | 96 ++++++ 23 files changed, 856 insertions(+) create mode 100644 modules/widget/android/build.gradle create mode 100644 modules/widget/android/src/main/AndroidManifest.xml create mode 100644 modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt create mode 100644 modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt create mode 100644 modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt create mode 100644 modules/widget/android/src/main/res/drawable/ic_widget_schedule_all_done.xml create mode 100644 modules/widget/android/src/main/res/drawable/ic_widget_schedule_bg.xml create mode 100644 modules/widget/android/src/main/res/drawable/widget_schedule_background.xml create mode 100644 modules/widget/android/src/main/res/drawable/widget_schedule_date_hint_bg.xml create mode 100644 modules/widget/android/src/main/res/drawable/widget_schedule_day_of_week_bg.xml create mode 100644 modules/widget/android/src/main/res/layout/widget_schedule.xml create mode 100644 modules/widget/android/src/main/res/values-night/widget_colors.xml create mode 100644 modules/widget/android/src/main/res/values/widget_colors.xml create mode 100644 modules/widget/android/src/main/res/values/widget_dimens.xml create mode 100644 modules/widget/android/src/main/res/values/widget_strings.xml create mode 100644 modules/widget/android/src/main/res/xml/widget_schedule_info.xml create mode 100644 modules/widget/expo-module.config.json create mode 100644 modules/widget/index.ts create mode 100644 modules/widget/ios/WidgetModule.swift create mode 100644 services/widget-sync.ts diff --git a/.gitignore b/.gitignore index ce3579c..fe2a649 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ app-example # generated native folders /ios /android +**/android/build/ # OTA !assets/certificate.pem diff --git a/app.config.ts b/app.config.ts index f7184c7..ff4e982 100644 --- a/app.config.ts +++ b/app.config.ts @@ -45,6 +45,9 @@ const config: ExpoConfig = { NSAllowsArbitraryLoadsInWebContent: true, }, }, + entitlements: { + "com.apple.security.application-groups": ["group.dev.tokenteam.iwut"], + }, }, android: { package: IS_DEV ? "dev.tokenteam.iwut.dev" : "dev.tokenteam.iwut", diff --git a/app/_layout.tsx b/app/_layout.tsx index 53293ad..10c1b6a 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -36,6 +36,8 @@ import Toast from "react-native-toast-message"; import { Themes } from "@/constants/theme"; import { useColorScheme } from "@/hooks/use-color-scheme"; +import { syncWidgetData } from "@/services/widget-sync"; +import { useCourseStore } from "@/store/course"; import { useThemeStore } from "@/store/theme"; import { useUpdateStore } from "@/store/update"; @@ -74,6 +76,19 @@ function RootLayout() { useUpdateStore.getState().check(); }, []); + useEffect(() => { + syncWidgetData().catch(() => {}); + const unsub = useCourseStore.subscribe((state, prev) => { + if ( + state.courses !== prev.courses || + state.termStart !== prev.termStart + ) { + syncWidgetData().catch(() => {}); + } + }); + return unsub; + }, []); + const onLayoutRootView = useCallback(() => { if (fontsLoaded || fontError) { void SplashScreen.hideAsync(); diff --git a/modules/widget/android/build.gradle b/modules/widget/android/build.gradle new file mode 100644 index 0000000..e3e8062 --- /dev/null +++ b/modules/widget/android/build.gradle @@ -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.widget" +} + +dependencies { + implementation "com.google.code.gson:gson:2.11.0" +} diff --git a/modules/widget/android/src/main/AndroidManifest.xml b/modules/widget/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1fbb579 --- /dev/null +++ b/modules/widget/android/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt new file mode 100644 index 0000000..64aa036 --- /dev/null +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt @@ -0,0 +1,41 @@ +package dev.tokenteam.iwut.widget + +import android.content.Context +import com.google.gson.Gson + +data class WidgetCourse( + val name: String = "", + val room: String = "", + val teacher: String = "", + val sectionStart: Int = 0, + val sectionEnd: Int = 0, + val startTime: String = "", + val endTime: String = "", + val isToday: Boolean = true, +) + +data class ScheduleWidgetData( + val todayCourses: List = emptyList(), + val tomorrowCourses: List = emptyList(), + val dayOfWeek: Int = 1, + val week: Int = 1, + val weekStr: String = "", + val dateStr: String = "", + val dayOfWeekStr: String = "", + val updatedAt: String = "", +) + +object ScheduleData { + private val gson = Gson() + + fun load(context: Context): ScheduleWidgetData? { + val prefs = context.getSharedPreferences("widget_data", Context.MODE_PRIVATE) + val json = prefs.getString("schedule", null) ?: return null + + return try { + gson.fromJson(json, ScheduleWidgetData::class.java) + } catch (e: Exception) { + null + } + } +} diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt new file mode 100644 index 0000000..3472be9 --- /dev/null +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt @@ -0,0 +1,92 @@ +package dev.tokenteam.iwut.widget + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.view.View +import android.widget.RemoteViews +import java.util.Calendar + +class ScheduleWidget : AppWidgetProvider() { + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + ) { + for (id in appWidgetIds) { + updateWidget(context, appWidgetManager, id) + } + } + + companion object { + fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + ) { + val views = RemoteViews(context.packageName, R.layout.widget_schedule) + val data = ScheduleData.load(context) + + val weekStr = data?.weekStr ?: "" + val dateStr = data?.dateStr ?: "" + val dayOfWeekStr = data?.dayOfWeekStr ?: "" + + views.setTextViewText(R.id.tv_week, weekStr) + views.setTextViewText(R.id.tv_date, dateStr) + views.setTextViewText(R.id.tv_day_of_week, dayOfWeekStr) + + val todayCourses = data?.todayCourses ?: emptyList() + val tomorrowCourses = data?.tomorrowCourses ?: emptyList() + + val now = Calendar.getInstance() + val nowMin = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + + val upcomingToday = todayCourses.filter { parseTimeToMinutes(it.endTime) > nowMin } + val combined = (upcomingToday + tomorrowCourses).take(2) + + if (combined.isEmpty()) { + views.setViewVisibility(R.id.course_group, View.GONE) + views.setViewVisibility(R.id.all_done_group, View.VISIBLE) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + views.setViewVisibility(R.id.course_group, View.VISIBLE) + views.setViewVisibility(R.id.all_done_group, View.GONE) + + val c1 = combined[0] + views.setViewVisibility(R.id.course_row_1, View.VISIBLE) + views.setTextViewText(R.id.course_1_name, c1.name) + views.setTextViewText(R.id.course_1_tag, if (c1.isToday) "今天" else "明天") + views.setTextViewText(R.id.course_1_room, c1.room) + views.setTextViewText(R.id.course_1_time, "${c1.startTime}-${c1.endTime}") + + if (combined.size > 1) { + val c2 = combined[1] + views.setViewVisibility(R.id.course_row_2, View.VISIBLE) + views.setViewVisibility(R.id.tv_no_more, View.GONE) + views.setTextViewText(R.id.course_2_name, c2.name) + views.setTextViewText(R.id.course_2_tag, if (c2.isToday) "今天" else "明天") + views.setTextViewText(R.id.course_2_room, c2.room) + views.setTextViewText(R.id.course_2_time, "${c2.startTime}-${c2.endTime}") + } else { + views.setViewVisibility(R.id.course_row_2, View.GONE) + views.setViewVisibility(R.id.tv_no_more, View.VISIBLE) + } + + val todayHint = if (upcomingToday.isEmpty()) "今天没有课了," else "今天还有${upcomingToday.size}节课," + val tomorrowHint = if (tomorrowCourses.isEmpty()) "明天没有课了~" else "明天还有${tomorrowCourses.size}节课" + views.setViewVisibility(R.id.tv_course_hint, View.VISIBLE) + views.setTextViewText(R.id.tv_course_hint, todayHint + tomorrowHint) + + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + private fun parseTimeToMinutes(time: String): Int { + val parts = time.split(":") + if (parts.size != 2) return 0 + return (parts[0].toIntOrNull() ?: 0) * 60 + (parts[1].toIntOrNull() ?: 0) + } + } +} diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt new file mode 100644 index 0000000..0298148 --- /dev/null +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt @@ -0,0 +1,38 @@ +package dev.tokenteam.iwut.widget + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +class WidgetModule : Module() { + override fun definition() = ModuleDefinition { + Name("Widget") + + AsyncFunction("setWidgetData") { key: String, json: String -> + val context = appContext.reactContext ?: return@AsyncFunction null + context + .getSharedPreferences("widget_data", Context.MODE_PRIVATE) + .edit() + .putString(key, json) + .apply() + null + } + + AsyncFunction("reloadWidgets") { + val context = appContext.reactContext ?: return@AsyncFunction null + val manager = AppWidgetManager.getInstance(context) + val widget = ComponentName(context, ScheduleWidget::class.java) + val ids = manager.getAppWidgetIds(widget) + if (ids.isNotEmpty()) { + val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) + intent.component = widget + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + context.sendBroadcast(intent) + } + null + } + } +} diff --git a/modules/widget/android/src/main/res/drawable/ic_widget_schedule_all_done.xml b/modules/widget/android/src/main/res/drawable/ic_widget_schedule_all_done.xml new file mode 100644 index 0000000..36bb90a --- /dev/null +++ b/modules/widget/android/src/main/res/drawable/ic_widget_schedule_all_done.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/widget/android/src/main/res/drawable/ic_widget_schedule_bg.xml b/modules/widget/android/src/main/res/drawable/ic_widget_schedule_bg.xml new file mode 100644 index 0000000..50a4c56 --- /dev/null +++ b/modules/widget/android/src/main/res/drawable/ic_widget_schedule_bg.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/widget/android/src/main/res/drawable/widget_schedule_background.xml b/modules/widget/android/src/main/res/drawable/widget_schedule_background.xml new file mode 100644 index 0000000..f4152d3 --- /dev/null +++ b/modules/widget/android/src/main/res/drawable/widget_schedule_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/modules/widget/android/src/main/res/drawable/widget_schedule_date_hint_bg.xml b/modules/widget/android/src/main/res/drawable/widget_schedule_date_hint_bg.xml new file mode 100644 index 0000000..1ce8f9b --- /dev/null +++ b/modules/widget/android/src/main/res/drawable/widget_schedule_date_hint_bg.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/modules/widget/android/src/main/res/drawable/widget_schedule_day_of_week_bg.xml b/modules/widget/android/src/main/res/drawable/widget_schedule_day_of_week_bg.xml new file mode 100644 index 0000000..aadfc2e --- /dev/null +++ b/modules/widget/android/src/main/res/drawable/widget_schedule_day_of_week_bg.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/modules/widget/android/src/main/res/layout/widget_schedule.xml b/modules/widget/android/src/main/res/layout/widget_schedule.xml new file mode 100644 index 0000000..7647fef --- /dev/null +++ b/modules/widget/android/src/main/res/layout/widget_schedule.xml @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/widget/android/src/main/res/values-night/widget_colors.xml b/modules/widget/android/src/main/res/values-night/widget_colors.xml new file mode 100644 index 0000000..0bd8a75 --- /dev/null +++ b/modules/widget/android/src/main/res/values-night/widget_colors.xml @@ -0,0 +1,9 @@ + + + #1D1D20 + #007AFF + #FFFFFF + #AEAEB2 + #FFFFFF + #F3F3F3 + diff --git a/modules/widget/android/src/main/res/values/widget_colors.xml b/modules/widget/android/src/main/res/values/widget_colors.xml new file mode 100644 index 0000000..07d2f99 --- /dev/null +++ b/modules/widget/android/src/main/res/values/widget_colors.xml @@ -0,0 +1,9 @@ + + + #F0F1FF + #007AFF + #48484A + #AEAEB2 + #FFFFFF + #1C1C1E + diff --git a/modules/widget/android/src/main/res/values/widget_dimens.xml b/modules/widget/android/src/main/res/values/widget_dimens.xml new file mode 100644 index 0000000..ead5099 --- /dev/null +++ b/modules/widget/android/src/main/res/values/widget_dimens.xml @@ -0,0 +1,14 @@ + + + 22dp + 17dp + 22dp + 17dp + 77dp + 34dp + 14dp + 12dp + 12dp + 10dp + 17dp + diff --git a/modules/widget/android/src/main/res/values/widget_strings.xml b/modules/widget/android/src/main/res/values/widget_strings.xml new file mode 100644 index 0000000..297dfa5 --- /dev/null +++ b/modules/widget/android/src/main/res/values/widget_strings.xml @@ -0,0 +1,5 @@ + + + 没有更多课啦,放松一下吧~ + 没有更多课啦~ + diff --git a/modules/widget/android/src/main/res/xml/widget_schedule_info.xml b/modules/widget/android/src/main/res/xml/widget_schedule_info.xml new file mode 100644 index 0000000..ae87834 --- /dev/null +++ b/modules/widget/android/src/main/res/xml/widget_schedule_info.xml @@ -0,0 +1,8 @@ + + diff --git a/modules/widget/expo-module.config.json b/modules/widget/expo-module.config.json new file mode 100644 index 0000000..b52a51e --- /dev/null +++ b/modules/widget/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["android", "ios"], + "android": { + "modules": ["dev.tokenteam.iwut.widget.WidgetModule"] + }, + "ios": { + "modules": ["WidgetModule"] + } +} diff --git a/modules/widget/index.ts b/modules/widget/index.ts new file mode 100644 index 0000000..bdb931a --- /dev/null +++ b/modules/widget/index.ts @@ -0,0 +1,19 @@ +import { requireNativeModule } from "expo-modules-core"; + +interface WidgetNativeModule { + setWidgetData(key: string, json: string): Promise; + reloadWidgets(): Promise; +} + +const WidgetModule = requireNativeModule("Widget"); + +export async function setWidgetData( + key: string, + data: Record, +): Promise { + await WidgetModule.setWidgetData(key, JSON.stringify(data)); +} + +export async function reloadWidgets(): Promise { + await WidgetModule.reloadWidgets(); +} diff --git a/modules/widget/ios/WidgetModule.swift b/modules/widget/ios/WidgetModule.swift new file mode 100644 index 0000000..f661f12 --- /dev/null +++ b/modules/widget/ios/WidgetModule.swift @@ -0,0 +1,19 @@ +import ExpoModulesCore +import WidgetKit + +public class WidgetModule: Module { + public func definition() -> ModuleDefinition { + Name("Widget") + + AsyncFunction("setWidgetData") { (key: String, json: String) in + let defaults = UserDefaults(suiteName: "group.dev.tokenteam.iwut") + defaults?.set(json, forKey: key) + } + + AsyncFunction("reloadWidgets") { + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadAllTimelines() + } + } + } +} diff --git a/services/widget-sync.ts b/services/widget-sync.ts new file mode 100644 index 0000000..38f9d78 --- /dev/null +++ b/services/widget-sync.ts @@ -0,0 +1,96 @@ +import { + getCurrentDayOfWeek, + getCurrentWeek, + getTomorrowDayOfWeek, + getTomorrowWeek, +} from "@/lib/date"; +import { reloadWidgets, setWidgetData } from "@/modules/widget"; +import { SECTION_TIMES } from "@/services/course-time"; +import { useCourseStore } from "@/store/course"; + +const DAY_NAMES = ["", "周一", "周二", "周三", "周四", "周五", "周六", "周日"]; + +interface WidgetCourse { + name: string; + room: string; + teacher: string; + sectionStart: number; + sectionEnd: number; + startTime: string; + endTime: string; + isToday: boolean; +} + +interface ScheduleWidgetData { + todayCourses: WidgetCourse[]; + tomorrowCourses: WidgetCourse[]; + dayOfWeek: number; + week: number; + weekStr: string; + dateStr: string; + dayOfWeekStr: string; + updatedAt: string; +} + +function toWidgetCourse( + c: { + name: string; + room: string; + teacher: string; + sectionStart: number; + sectionEnd: number; + }, + isToday: boolean, +): WidgetCourse { + return { + name: c.name, + room: c.room, + teacher: c.teacher, + sectionStart: c.sectionStart, + sectionEnd: c.sectionEnd, + startTime: SECTION_TIMES[c.sectionStart]?.[0] ?? "", + endTime: SECTION_TIMES[c.sectionEnd]?.[1] ?? "", + isToday, + }; +} + +export async function syncWidgetData(): Promise { + const { courses, termStart } = useCourseStore.getState(); + if (!termStart || courses.length === 0) return; + + const week = getCurrentWeek(termStart); + const today = getCurrentDayOfWeek(); + const tomorrowDay = getTomorrowDayOfWeek(); + const tomorrowWeek = getTomorrowWeek(termStart); + + const now = new Date(); + + const todayCourses = courses + .filter((c) => c.day === today && c.weekStart <= week && c.weekEnd >= week) + .sort((a, b) => a.sectionStart - b.sectionStart) + .map((c) => toWidgetCourse(c, true)); + + const tomorrowCourses = courses + .filter( + (c) => + c.day === tomorrowDay && + c.weekStart <= tomorrowWeek && + c.weekEnd >= tomorrowWeek, + ) + .sort((a, b) => a.sectionStart - b.sectionStart) + .map((c) => toWidgetCourse(c, false)); + + const data: ScheduleWidgetData = { + todayCourses, + tomorrowCourses, + dayOfWeek: today, + week, + weekStr: `第${week}周`, + dateStr: `${now.getMonth() + 1}月${now.getDate()}日`, + dayOfWeekStr: DAY_NAMES[today] ?? "", + updatedAt: now.toISOString(), + }; + + await setWidgetData("schedule", data as unknown as Record); + await reloadWidgets(); +} From f42d17f62c2ab61ddee3521a6e0079fd83db2e46 Mon Sep 17 00:00:00 2001 From: zhxycn Date: Sat, 2 May 2026 16:06:14 +0800 Subject: [PATCH 2/5] =?UTF-8?q?:sparkles:=20feat:=20iOS=20=E5=B0=8F?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.config.ts | 1 + .../networkreporter/NetworkReporterModule.kt | 30 +-- .../dev/tokenteam/iwut/widget/ScheduleData.kt | 50 ++--- .../tokenteam/iwut/widget/ScheduleWidget.kt | 158 +++++++------- .../dev/tokenteam/iwut/widget/WidgetModule.kt | 48 ++--- .../src/main/res/values/widget_strings.xml | 4 +- modules/widget/ios/Widget.podspec | 19 ++ modules/widget/ios/WidgetModule.swift | 22 +- package.json | 1 + .../AccentBlue.colorset/Contents.json | 20 ++ .../BackgroundLogo.svg | 19 ++ .../BackgroundLogo.imageset/Contents.json | 15 ++ targets/widget/Assets.xcassets/Contents.json | 6 + .../EmptyCourseImage.imageset/Contents.json | 25 +++ .../EmptyCourseImage.svg | 29 +++ .../EmptyCourseImage_dark.svg | 29 +++ .../TextPrimary.colorset/Contents.json | 38 ++++ .../TextSecondary.colorset/Contents.json | 38 ++++ .../WidgetBackground.colorset/Contents.json | 38 ++++ targets/widget/Info.plist | 11 + targets/widget/Models/WidgetData.swift | 32 +++ targets/widget/ScheduleTimelineProvider.swift | 35 +++ targets/widget/ScheduleWidget.swift | 22 ++ targets/widget/Views/BackgroundView.swift | 34 +++ targets/widget/Views/CourseInfoView.swift | 53 +++++ targets/widget/Views/DateInfoView.swift | 30 +++ targets/widget/Views/EmptyCourseView.swift | 13 ++ .../Views/ScheduleWidgetEntryView.swift | 87 ++++++++ targets/widget/WidgetBundle.swift | 9 + targets/widget/expo-target.config.js | 9 + targets/widget/generated.entitlements | 10 + yarn.lock | 202 +++++++++++++++++- 32 files changed, 981 insertions(+), 156 deletions(-) create mode 100644 modules/widget/ios/Widget.podspec create mode 100644 targets/widget/Assets.xcassets/AccentBlue.colorset/Contents.json create mode 100644 targets/widget/Assets.xcassets/BackgroundLogo.imageset/BackgroundLogo.svg create mode 100644 targets/widget/Assets.xcassets/BackgroundLogo.imageset/Contents.json create mode 100644 targets/widget/Assets.xcassets/Contents.json create mode 100644 targets/widget/Assets.xcassets/EmptyCourseImage.imageset/Contents.json create mode 100644 targets/widget/Assets.xcassets/EmptyCourseImage.imageset/EmptyCourseImage.svg create mode 100644 targets/widget/Assets.xcassets/EmptyCourseImage.imageset/EmptyCourseImage_dark.svg create mode 100644 targets/widget/Assets.xcassets/TextPrimary.colorset/Contents.json create mode 100644 targets/widget/Assets.xcassets/TextSecondary.colorset/Contents.json create mode 100644 targets/widget/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 targets/widget/Info.plist create mode 100644 targets/widget/Models/WidgetData.swift create mode 100644 targets/widget/ScheduleTimelineProvider.swift create mode 100644 targets/widget/ScheduleWidget.swift create mode 100644 targets/widget/Views/BackgroundView.swift create mode 100644 targets/widget/Views/CourseInfoView.swift create mode 100644 targets/widget/Views/DateInfoView.swift create mode 100644 targets/widget/Views/EmptyCourseView.swift create mode 100644 targets/widget/Views/ScheduleWidgetEntryView.swift create mode 100644 targets/widget/WidgetBundle.swift create mode 100644 targets/widget/expo-target.config.js create mode 100644 targets/widget/generated.entitlements diff --git a/app.config.ts b/app.config.ts index ff4e982..ea786e4 100644 --- a/app.config.ts +++ b/app.config.ts @@ -87,6 +87,7 @@ const config: ExpoConfig = { }, ], "@sentry/react-native", + "@bacons/apple-targets", "./plugins/with-gradle-props.js", ], experiments: { diff --git a/modules/network-reporter/android/src/main/java/dev/tokenteam/iwut/networkreporter/NetworkReporterModule.kt b/modules/network-reporter/android/src/main/java/dev/tokenteam/iwut/networkreporter/NetworkReporterModule.kt index a32e098..a66750f 100644 --- a/modules/network-reporter/android/src/main/java/dev/tokenteam/iwut/networkreporter/NetworkReporterModule.kt +++ b/modules/network-reporter/android/src/main/java/dev/tokenteam/iwut/networkreporter/NetworkReporterModule.kt @@ -8,24 +8,24 @@ import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class NetworkReporterModule : Module() { - override fun definition() = ModuleDefinition { - Name("NetworkReporter") + override fun definition() = ModuleDefinition { + Name("NetworkReporter") - AsyncFunction("reportWifiConnectivity") { hasConnectivity: Boolean -> - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - return@AsyncFunction false - } + AsyncFunction("reportWifiConnectivity") { hasConnectivity: Boolean -> + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return@AsyncFunction false + } - val context = appContext.reactContext ?: return@AsyncFunction false - val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) - as? ConnectivityManager ?: return@AsyncFunction false + val context = appContext.reactContext ?: return@AsyncFunction false + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) + as? ConnectivityManager ?: return@AsyncFunction false - @Suppress("DEPRECATION") - val wifi = cm.allNetworks.firstOrNull { - cm.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true - } ?: return@AsyncFunction false + @Suppress("DEPRECATION") + val wifi = cm.allNetworks.firstOrNull { + cm.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true + } ?: return@AsyncFunction false - runCatching { cm.reportNetworkConnectivity(wifi, hasConnectivity) }.isSuccess + runCatching { cm.reportNetworkConnectivity(wifi, hasConnectivity) }.isSuccess + } } - } } diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt index 64aa036..9550b32 100644 --- a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt @@ -4,38 +4,38 @@ import android.content.Context import com.google.gson.Gson data class WidgetCourse( - val name: String = "", - val room: String = "", - val teacher: String = "", - val sectionStart: Int = 0, - val sectionEnd: Int = 0, - val startTime: String = "", - val endTime: String = "", - val isToday: Boolean = true, + val name: String = "", + val room: String = "", + val teacher: String = "", + val sectionStart: Int = 0, + val sectionEnd: Int = 0, + val startTime: String = "", + val endTime: String = "", + val isToday: Boolean = true, ) data class ScheduleWidgetData( - val todayCourses: List = emptyList(), - val tomorrowCourses: List = emptyList(), - val dayOfWeek: Int = 1, - val week: Int = 1, - val weekStr: String = "", - val dateStr: String = "", - val dayOfWeekStr: String = "", - val updatedAt: String = "", + val todayCourses: List = emptyList(), + val tomorrowCourses: List = emptyList(), + val dayOfWeek: Int = 1, + val week: Int = 1, + val weekStr: String = "", + val dateStr: String = "", + val dayOfWeekStr: String = "", + val updatedAt: String = "", ) object ScheduleData { - private val gson = Gson() + private val gson = Gson() - fun load(context: Context): ScheduleWidgetData? { - val prefs = context.getSharedPreferences("widget_data", Context.MODE_PRIVATE) - val json = prefs.getString("schedule", null) ?: return null + fun load(context: Context): ScheduleWidgetData? { + val prefs = context.getSharedPreferences("widget_data", Context.MODE_PRIVATE) + val json = prefs.getString("schedule", null) ?: return null - return try { - gson.fromJson(json, ScheduleWidgetData::class.java) - } catch (e: Exception) { - null + return try { + gson.fromJson(json, ScheduleWidgetData::class.java) + } catch (e: Exception) { + null + } } - } } diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt index 3472be9..d559006 100644 --- a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt @@ -9,84 +9,90 @@ import java.util.Calendar class ScheduleWidget : AppWidgetProvider() { - override fun onUpdate( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetIds: IntArray, - ) { - for (id in appWidgetIds) { - updateWidget(context, appWidgetManager, id) - } - } - - companion object { - fun updateWidget( - context: Context, - appWidgetManager: AppWidgetManager, - appWidgetId: Int, + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, ) { - val views = RemoteViews(context.packageName, R.layout.widget_schedule) - val data = ScheduleData.load(context) - - val weekStr = data?.weekStr ?: "" - val dateStr = data?.dateStr ?: "" - val dayOfWeekStr = data?.dayOfWeekStr ?: "" - - views.setTextViewText(R.id.tv_week, weekStr) - views.setTextViewText(R.id.tv_date, dateStr) - views.setTextViewText(R.id.tv_day_of_week, dayOfWeekStr) - - val todayCourses = data?.todayCourses ?: emptyList() - val tomorrowCourses = data?.tomorrowCourses ?: emptyList() - - val now = Calendar.getInstance() - val nowMin = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) - - val upcomingToday = todayCourses.filter { parseTimeToMinutes(it.endTime) > nowMin } - val combined = (upcomingToday + tomorrowCourses).take(2) - - if (combined.isEmpty()) { - views.setViewVisibility(R.id.course_group, View.GONE) - views.setViewVisibility(R.id.all_done_group, View.VISIBLE) - appWidgetManager.updateAppWidget(appWidgetId, views) - return - } - - views.setViewVisibility(R.id.course_group, View.VISIBLE) - views.setViewVisibility(R.id.all_done_group, View.GONE) - - val c1 = combined[0] - views.setViewVisibility(R.id.course_row_1, View.VISIBLE) - views.setTextViewText(R.id.course_1_name, c1.name) - views.setTextViewText(R.id.course_1_tag, if (c1.isToday) "今天" else "明天") - views.setTextViewText(R.id.course_1_room, c1.room) - views.setTextViewText(R.id.course_1_time, "${c1.startTime}-${c1.endTime}") - - if (combined.size > 1) { - val c2 = combined[1] - views.setViewVisibility(R.id.course_row_2, View.VISIBLE) - views.setViewVisibility(R.id.tv_no_more, View.GONE) - views.setTextViewText(R.id.course_2_name, c2.name) - views.setTextViewText(R.id.course_2_tag, if (c2.isToday) "今天" else "明天") - views.setTextViewText(R.id.course_2_room, c2.room) - views.setTextViewText(R.id.course_2_time, "${c2.startTime}-${c2.endTime}") - } else { - views.setViewVisibility(R.id.course_row_2, View.GONE) - views.setViewVisibility(R.id.tv_no_more, View.VISIBLE) - } - - val todayHint = if (upcomingToday.isEmpty()) "今天没有课了," else "今天还有${upcomingToday.size}节课," - val tomorrowHint = if (tomorrowCourses.isEmpty()) "明天没有课了~" else "明天还有${tomorrowCourses.size}节课" - views.setViewVisibility(R.id.tv_course_hint, View.VISIBLE) - views.setTextViewText(R.id.tv_course_hint, todayHint + tomorrowHint) - - appWidgetManager.updateAppWidget(appWidgetId, views) + for (id in appWidgetIds) { + updateWidget(context, appWidgetManager, id) + } } - private fun parseTimeToMinutes(time: String): Int { - val parts = time.split(":") - if (parts.size != 2) return 0 - return (parts[0].toIntOrNull() ?: 0) * 60 + (parts[1].toIntOrNull() ?: 0) + companion object { + fun updateWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int, + ) { + val views = RemoteViews(context.packageName, R.layout.widget_schedule) + val data = ScheduleData.load(context) + + val weekStr = data?.weekStr ?: "" + val dateStr = data?.dateStr ?: "" + val dayOfWeekStr = data?.dayOfWeekStr ?: "" + + views.setTextViewText(R.id.tv_week, weekStr) + views.setTextViewText(R.id.tv_date, dateStr) + views.setTextViewText(R.id.tv_day_of_week, dayOfWeekStr) + + val todayCourses = data?.todayCourses ?: emptyList() + val tomorrowCourses = data?.tomorrowCourses ?: emptyList() + + val now = Calendar.getInstance() + val nowMin = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + + val upcomingToday = todayCourses.filter { parseTimeToMinutes(it.endTime) > nowMin } + val combined = (upcomingToday + tomorrowCourses).take(2) + + if (combined.isEmpty()) { + views.setViewVisibility(R.id.course_group, View.GONE) + views.setViewVisibility(R.id.all_done_group, View.VISIBLE) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } + + views.setViewVisibility(R.id.course_group, View.VISIBLE) + views.setViewVisibility(R.id.all_done_group, View.GONE) + + val c1 = combined[0] + views.setViewVisibility(R.id.course_row_1, View.VISIBLE) + views.setTextViewText(R.id.course_1_name, c1.name) + views.setTextViewText(R.id.course_1_tag, if (c1.isToday) "今天" else "明天") + views.setTextViewText(R.id.course_1_room, c1.room) + views.setTextViewText(R.id.course_1_time, "${c1.startTime}-${c1.endTime}") + + if (combined.size > 1) { + val c2 = combined[1] + views.setViewVisibility(R.id.course_row_2, View.VISIBLE) + views.setViewVisibility(R.id.tv_no_more, View.GONE) + views.setTextViewText(R.id.course_2_name, c2.name) + views.setTextViewText(R.id.course_2_tag, if (c2.isToday) "今天" else "明天") + views.setTextViewText(R.id.course_2_room, c2.room) + views.setTextViewText(R.id.course_2_time, "${c2.startTime}-${c2.endTime}") + } else { + views.setViewVisibility(R.id.course_row_2, View.GONE) + views.setViewVisibility(R.id.tv_no_more, View.VISIBLE) + } + + val hintText: String + if (upcomingToday.isEmpty() && tomorrowCourses.isEmpty()) { + hintText = "今天和明天都没有课啦~" + } else { + val todayHint = if (upcomingToday.isEmpty()) "今天没有课啦," else "今天还有${upcomingToday.size}节课," + val tomorrowHint = if (tomorrowCourses.isEmpty()) "明天没有课啦~" else "明天还有${tomorrowCourses.size}节课" + hintText = todayHint + tomorrowHint + } + views.setViewVisibility(R.id.tv_course_hint, View.VISIBLE) + views.setTextViewText(R.id.tv_course_hint, hintText) + + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + private fun parseTimeToMinutes(time: String): Int { + val parts = time.split(":") + if (parts.size != 2) return 0 + return (parts[0].toIntOrNull() ?: 0) * 60 + (parts[1].toIntOrNull() ?: 0) + } } - } } diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt index 0298148..dd327fe 100644 --- a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt @@ -8,31 +8,31 @@ import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition class WidgetModule : Module() { - override fun definition() = ModuleDefinition { - Name("Widget") + override fun definition() = ModuleDefinition { + Name("Widget") - AsyncFunction("setWidgetData") { key: String, json: String -> - val context = appContext.reactContext ?: return@AsyncFunction null - context - .getSharedPreferences("widget_data", Context.MODE_PRIVATE) - .edit() - .putString(key, json) - .apply() - null - } + AsyncFunction("setWidgetData") { key: String, json: String -> + val context = appContext.reactContext ?: return@AsyncFunction null + context + .getSharedPreferences("widget_data", Context.MODE_PRIVATE) + .edit() + .putString(key, json) + .apply() + null + } - AsyncFunction("reloadWidgets") { - val context = appContext.reactContext ?: return@AsyncFunction null - val manager = AppWidgetManager.getInstance(context) - val widget = ComponentName(context, ScheduleWidget::class.java) - val ids = manager.getAppWidgetIds(widget) - if (ids.isNotEmpty()) { - val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) - intent.component = widget - intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) - context.sendBroadcast(intent) - } - null + AsyncFunction("reloadWidgets") { + val context = appContext.reactContext ?: return@AsyncFunction null + val manager = AppWidgetManager.getInstance(context) + val widget = ComponentName(context, ScheduleWidget::class.java) + val ids = manager.getAppWidgetIds(widget) + if (ids.isNotEmpty()) { + val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) + intent.component = widget + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) + context.sendBroadcast(intent) + } + null + } } - } } diff --git a/modules/widget/android/src/main/res/values/widget_strings.xml b/modules/widget/android/src/main/res/values/widget_strings.xml index 297dfa5..f01c643 100644 --- a/modules/widget/android/src/main/res/values/widget_strings.xml +++ b/modules/widget/android/src/main/res/values/widget_strings.xml @@ -1,5 +1,5 @@ - 没有更多课啦,放松一下吧~ - 没有更多课啦~ + 没有更多课啦,放松一下吧~ + 没有更多课啦~ diff --git a/modules/widget/ios/Widget.podspec b/modules/widget/ios/Widget.podspec new file mode 100644 index 0000000..a46a8f7 --- /dev/null +++ b/modules/widget/ios/Widget.podspec @@ -0,0 +1,19 @@ +Pod::Spec.new do |s| + s.name = 'Widget' + 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/widget/ios/WidgetModule.swift b/modules/widget/ios/WidgetModule.swift index f661f12..36bc542 100644 --- a/modules/widget/ios/WidgetModule.swift +++ b/modules/widget/ios/WidgetModule.swift @@ -2,18 +2,18 @@ import ExpoModulesCore import WidgetKit public class WidgetModule: Module { - public func definition() -> ModuleDefinition { - Name("Widget") + public func definition() -> ModuleDefinition { + Name("Widget") - AsyncFunction("setWidgetData") { (key: String, json: String) in - let defaults = UserDefaults(suiteName: "group.dev.tokenteam.iwut") - defaults?.set(json, forKey: key) - } + AsyncFunction("setWidgetData") { (key: String, json: String) in + let defaults = UserDefaults(suiteName: "group.dev.tokenteam.iwut") + defaults?.set(json, forKey: key) + } - AsyncFunction("reloadWidgets") { - if #available(iOS 14.0, *) { - WidgetCenter.shared.reloadAllTimelines() - } + AsyncFunction("reloadWidgets") { + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadAllTimelines() + } + } } - } } diff --git a/package.json b/package.json index 3b39093..629a51f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "format": "prettier --write ." }, "dependencies": { + "@bacons/apple-targets": "^4.0.6", "@expo/vector-icons": "^15.0.3", "@preeternal/react-native-cookie-manager": "^6.3.2", "@react-native-assets/slider": "^11.0.12", diff --git a/targets/widget/Assets.xcassets/AccentBlue.colorset/Contents.json b/targets/widget/Assets.xcassets/AccentBlue.colorset/Contents.json new file mode 100644 index 0000000..df4ba6d --- /dev/null +++ b/targets/widget/Assets.xcassets/AccentBlue.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "1.000", + "green": "0.478", + "red": "0.000" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/targets/widget/Assets.xcassets/BackgroundLogo.imageset/BackgroundLogo.svg b/targets/widget/Assets.xcassets/BackgroundLogo.imageset/BackgroundLogo.svg new file mode 100644 index 0000000..67bdb66 --- /dev/null +++ b/targets/widget/Assets.xcassets/BackgroundLogo.imageset/BackgroundLogo.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/targets/widget/Assets.xcassets/BackgroundLogo.imageset/Contents.json b/targets/widget/Assets.xcassets/BackgroundLogo.imageset/Contents.json new file mode 100644 index 0000000..d25393c --- /dev/null +++ b/targets/widget/Assets.xcassets/BackgroundLogo.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images": [ + { + "filename": "BackgroundLogo.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/targets/widget/Assets.xcassets/Contents.json b/targets/widget/Assets.xcassets/Contents.json new file mode 100644 index 0000000..74d6a72 --- /dev/null +++ b/targets/widget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/targets/widget/Assets.xcassets/EmptyCourseImage.imageset/Contents.json b/targets/widget/Assets.xcassets/EmptyCourseImage.imageset/Contents.json new file mode 100644 index 0000000..163257d --- /dev/null +++ b/targets/widget/Assets.xcassets/EmptyCourseImage.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "filename": "EmptyCourseImage.svg", + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "EmptyCourseImage_dark.svg", + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} diff --git a/targets/widget/Assets.xcassets/EmptyCourseImage.imageset/EmptyCourseImage.svg b/targets/widget/Assets.xcassets/EmptyCourseImage.imageset/EmptyCourseImage.svg new file mode 100644 index 0000000..951963f --- /dev/null +++ b/targets/widget/Assets.xcassets/EmptyCourseImage.imageset/EmptyCourseImage.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/targets/widget/Assets.xcassets/EmptyCourseImage.imageset/EmptyCourseImage_dark.svg b/targets/widget/Assets.xcassets/EmptyCourseImage.imageset/EmptyCourseImage_dark.svg new file mode 100644 index 0000000..ef2a0b6 --- /dev/null +++ b/targets/widget/Assets.xcassets/EmptyCourseImage.imageset/EmptyCourseImage_dark.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/targets/widget/Assets.xcassets/TextPrimary.colorset/Contents.json b/targets/widget/Assets.xcassets/TextPrimary.colorset/Contents.json new file mode 100644 index 0000000..db3ea36 --- /dev/null +++ b/targets/widget/Assets.xcassets/TextPrimary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0.290", + "green": "0.282", + "red": "0.282" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "1.000", + "green": "1.000", + "red": "1.000" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/targets/widget/Assets.xcassets/TextSecondary.colorset/Contents.json b/targets/widget/Assets.xcassets/TextSecondary.colorset/Contents.json new file mode 100644 index 0000000..b71d4ca --- /dev/null +++ b/targets/widget/Assets.xcassets/TextSecondary.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0.698", + "green": "0.682", + "red": "0.682" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0.698", + "green": "0.682", + "red": "0.682" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/targets/widget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/targets/widget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..248a8e1 --- /dev/null +++ b/targets/widget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "1.000", + "green": "0.945", + "red": "0.941" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0.125", + "green": "0.114", + "red": "0.114" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/targets/widget/Info.plist b/targets/widget/Info.plist new file mode 100644 index 0000000..5510804 --- /dev/null +++ b/targets/widget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + \ No newline at end of file diff --git a/targets/widget/Models/WidgetData.swift b/targets/widget/Models/WidgetData.swift new file mode 100644 index 0000000..0b94bf9 --- /dev/null +++ b/targets/widget/Models/WidgetData.swift @@ -0,0 +1,32 @@ +import Foundation + +struct WidgetCourse: Codable { + let name: String + let room: String + let teacher: String + let sectionStart: Int + let sectionEnd: Int + let startTime: String + let endTime: String + let isToday: Bool +} + +struct ScheduleWidgetData: Codable { + let todayCourses: [WidgetCourse] + let tomorrowCourses: [WidgetCourse] + let dayOfWeek: Int + let week: Int + let weekStr: String + let dateStr: String + let dayOfWeekStr: String + let updatedAt: String + + static func load() -> ScheduleWidgetData? { + guard let defaults = UserDefaults(suiteName: "group.dev.tokenteam.iwut"), + let json = defaults.string(forKey: "schedule"), + let data = json.data(using: .utf8) + else { return nil } + + return try? JSONDecoder().decode(ScheduleWidgetData.self, from: data) + } +} diff --git a/targets/widget/ScheduleTimelineProvider.swift b/targets/widget/ScheduleTimelineProvider.swift new file mode 100644 index 0000000..01febd9 --- /dev/null +++ b/targets/widget/ScheduleTimelineProvider.swift @@ -0,0 +1,35 @@ +import WidgetKit + +struct ScheduleEntry: TimelineEntry { + let date: Date + let data: ScheduleWidgetData? + + var courses: [WidgetCourse] { + guard let data = data else { return [] } + let today = data.todayCourses + if !today.isEmpty { return today } + return data.tomorrowCourses + } + + var isShowingTomorrow: Bool { + guard let data = data else { return false } + return data.todayCourses.isEmpty && !data.tomorrowCourses.isEmpty + } +} + +struct ScheduleTimelineProvider: TimelineProvider { + func placeholder(in context: Context) -> ScheduleEntry { + ScheduleEntry(date: .now, data: nil) + } + + func getSnapshot(in context: Context, completion: @escaping (ScheduleEntry) -> Void) { + let entry = ScheduleEntry(date: .now, data: ScheduleWidgetData.load()) + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entry = ScheduleEntry(date: .now, data: ScheduleWidgetData.load()) + let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now + completion(Timeline(entries: [entry], policy: .after(nextUpdate))) + } +} diff --git a/targets/widget/ScheduleWidget.swift b/targets/widget/ScheduleWidget.swift new file mode 100644 index 0000000..9082d29 --- /dev/null +++ b/targets/widget/ScheduleWidget.swift @@ -0,0 +1,22 @@ +import SwiftUI +import WidgetKit + +struct ScheduleWidget: Widget { + let kind: String = "ScheduleWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: ScheduleTimelineProvider()) { entry in + if #available(iOS 17.0, *) { + ScheduleWidgetEntryView(entry: entry) + .containerBackground(Color("WidgetBackground"), for: .widget) + } else { + ScheduleWidgetEntryView(entry: entry) + .background(Color("WidgetBackground")) + } + } + .contentMarginsDisabled() + .configurationDisplayName("课程表") + .description("今天有什么课?看这里就够啦~") + .supportedFamilies([.systemMedium]) + } +} diff --git a/targets/widget/Views/BackgroundView.swift b/targets/widget/Views/BackgroundView.swift new file mode 100644 index 0000000..8e1cf5e --- /dev/null +++ b/targets/widget/Views/BackgroundView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct WidgetBackgroundView: View { + let isEmpty: Bool + + var body: some View { + VStack { + Spacer() + HStack { + Image("BackgroundLogo") + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(height: 120) + .offset(x: -5, y: 10) + Spacer() + } + } + + if isEmpty { + VStack { + Spacer() + HStack { + Spacer() + Image("EmptyCourseImage") + .renderingMode(.original) + .resizable() + .frame(width: 220, height: 110) + .offset(y: 1) + } + } + } + } +} diff --git a/targets/widget/Views/CourseInfoView.swift b/targets/widget/Views/CourseInfoView.swift new file mode 100644 index 0000000..059a269 --- /dev/null +++ b/targets/widget/Views/CourseInfoView.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct CourseInfoView: View { + let course: WidgetCourse + + private var tagText: String { + course.isToday ? "今天" : "明天" + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(course.name) + .font(.system(size: 14)) + .lineLimit(1) + .truncationMode(.tail) + .foregroundColor(Color("TextPrimary")) + + Text(tagText) + .font(.system(size: 10)) + .foregroundColor(Color("AccentBlue")) + .fontWeight(.bold) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4) + .stroke(Color("AccentBlue"), lineWidth: 1) + ) + } + + HStack(alignment: .bottom, spacing: 0) { + Text(course.room.isEmpty ? "暂无教室信息" : course.room) + .font(.system(size: 12)) + .lineLimit(2) + .foregroundColor(Color("TextSecondary")) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 4) + + Text("|") + .font(.system(size: 12)) + .foregroundColor(Color("TextSecondary")) + .padding(.trailing, 4) + + Text("\(course.startTime)-\(course.endTime)") + .foregroundColor(Color("TextSecondary")) + .lineLimit(1) + .font(.system(size: 12).monospacedDigit()) + .fixedSize(horizontal: true, vertical: false) + } + } + } +} diff --git a/targets/widget/Views/DateInfoView.swift b/targets/widget/Views/DateInfoView.swift new file mode 100644 index 0000000..b8e4224 --- /dev/null +++ b/targets/widget/Views/DateInfoView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct DateInfoView: View { + let weekStr: String + let dateStr: String + let dayOfWeekStr: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(weekStr) + .foregroundColor(Color("AccentBlue")) + .font(.system(size: 14)) + .padding(.bottom, 4) + + Text(dateStr) + .foregroundColor(Color("TextPrimary")) + .font(.system(size: 18)) + + Spacer() + + ZStack { + RoundedRectangle(cornerRadius: 12) + .foregroundColor(Color("AccentBlue")) + Text(dayOfWeekStr) + .foregroundColor(.white) + .font(.system(size: 14)) + }.frame(width: 64, height: 28) + } + } +} diff --git a/targets/widget/Views/EmptyCourseView.swift b/targets/widget/Views/EmptyCourseView.swift new file mode 100644 index 0000000..058e233 --- /dev/null +++ b/targets/widget/Views/EmptyCourseView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct EmptyCourseView: View { + var body: some View { + HStack(spacing: 0) { + Text("没有更多课啦,放松一下吧~") + .foregroundColor(Color("TextPrimary")) + .font(.system(size: 14)) + .padding(.leading, 8) + Spacer() + } + } +} diff --git a/targets/widget/Views/ScheduleWidgetEntryView.swift b/targets/widget/Views/ScheduleWidgetEntryView.swift new file mode 100644 index 0000000..b0fb926 --- /dev/null +++ b/targets/widget/Views/ScheduleWidgetEntryView.swift @@ -0,0 +1,87 @@ +import SwiftUI +import WidgetKit + +struct ScheduleWidgetEntryView: View { + var entry: ScheduleTimelineProvider.Entry + + private var courses: [WidgetCourse] { + entry.courses + } + + private var bottomText: String { + guard let data = entry.data else { return "" } + if courses.isEmpty { return "" } + + if data.todayCourses.isEmpty && data.tomorrowCourses.isEmpty { + return "今天和明天都没有课啦~" + } + + let todayPart: String + if data.todayCourses.isEmpty { + todayPart = "今天没有课啦," + } else { + todayPart = "今天还有\(data.todayCourses.count)节课," + } + + let tomorrowPart: String + if data.tomorrowCourses.isEmpty { + tomorrowPart = "明天没有课啦~" + } else { + tomorrowPart = "明天还有\(data.tomorrowCourses.count)节课" + } + + return todayPart + tomorrowPart + } + + var body: some View { + ZStack { + WidgetBackgroundView(isEmpty: courses.isEmpty) + + HStack(spacing: 12) { + if let data = entry.data { + DateInfoView( + weekStr: data.weekStr, + dateStr: data.dateStr, + dayOfWeekStr: data.dayOfWeekStr + ) + } else { + DateInfoView( + weekStr: "第-周", + dateStr: "--月--日", + dayOfWeekStr: "--" + ) + } + + VStack(alignment: .leading, spacing: 0) { + if courses.isEmpty { + EmptyCourseView() + } + + ForEach(courses.prefix(2).indices, id: \.self) { index in + if index > 0 { + Divider() + .padding(.vertical, 8) + } + CourseInfoView(course: courses[index]) + } + + if courses.count == 1 { + Text("没有更多课啦~") + .foregroundColor(Color("TextPrimary")) + .font(.system(size: 12)) + .padding(.top, 8) + } + + Spacer() + + if !bottomText.isEmpty { + Text(bottomText) + .foregroundColor(Color("TextSecondary")) + .font(.system(size: 12)) + } + } + } + .padding(16) + } + } +} diff --git a/targets/widget/WidgetBundle.swift b/targets/widget/WidgetBundle.swift new file mode 100644 index 0000000..d6f7e7a --- /dev/null +++ b/targets/widget/WidgetBundle.swift @@ -0,0 +1,9 @@ +import SwiftUI +import WidgetKit + +@main +struct IwutWidgetBundle: WidgetBundle { + var body: some Widget { + ScheduleWidget() + } +} diff --git a/targets/widget/expo-target.config.js b/targets/widget/expo-target.config.js new file mode 100644 index 0000000..42e5800 --- /dev/null +++ b/targets/widget/expo-target.config.js @@ -0,0 +1,9 @@ +/** @type {import('@bacons/apple-targets').Config} */ +module.exports = { + type: "widget", + name: "ScheduleWidget", + deploymentTarget: "17.0", + entitlements: { + "com.apple.security.application-groups": ["group.dev.tokenteam.iwut"], + }, +}; diff --git a/targets/widget/generated.entitlements b/targets/widget/generated.entitlements new file mode 100644 index 0000000..01b4822 --- /dev/null +++ b/targets/widget/generated.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.dev.tokenteam.iwut + + + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4dd4121..e171f0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -794,6 +794,25 @@ "@babel/helper-string-parser" "^7.27.1" "@babel/helper-validator-identifier" "^7.28.5" +"@bacons/apple-targets@^4.0.6": + version "4.0.6" + resolved "https://registry.npmmirror.com/@bacons/apple-targets/-/apple-targets-4.0.6.tgz#1cca86affa754cd4be0565bbc1d1345a1e97a693" + integrity sha512-aDSXI6HPgsroPMOAlNvVyvM2TvU25hgGu/UUWD6XWfWIX4nhGchCSaoEGx7cB3xO+ClQYdzlIAIlZScX6lHBwA== + dependencies: + "@bacons/xcode" "1.0.0-alpha.32" + "@react-native/normalize-colors" "^0.79.2" + debug "^4.3.4" + glob "^10.4.2" + +"@bacons/xcode@1.0.0-alpha.32": + version "1.0.0-alpha.32" + resolved "https://registry.npmmirror.com/@bacons/xcode/-/xcode-1.0.0-alpha.32.tgz#3b49a711472f433d4ece3f157a523e0db39d8987" + integrity sha512-OGpH7+yMbWC2cgYZon5B+VVadH9HsB2V/abtEiplA65XnSuV4GAYAVixOCDc5k182WkfoakfdM0zW6U9cbcsbw== + dependencies: + "@expo/plist" "^0.0.18" + debug "^4.3.4" + uuid "^8.3.2" + "@egjs/hammerjs@^2.0.17": version "2.0.17" resolved "https://registry.npmmirror.com/@egjs/hammerjs/-/hammerjs-2.0.17.tgz#5dc02af75a6a06e4c2db0202cae38c9263895124" @@ -1165,6 +1184,15 @@ ora "^3.4.0" resolve-workspace-root "^2.0.0" +"@expo/plist@^0.0.18": + version "0.0.18" + resolved "https://registry.npmmirror.com/@expo/plist/-/plist-0.0.18.tgz#9abcde78df703a88f6d9fa1a557ee2f045d178b0" + integrity sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w== + dependencies: + "@xmldom/xmldom" "~0.7.0" + base64-js "^1.2.3" + xmlbuilder "^14.0.0" + "@expo/plist@^0.5.2": version "0.5.2" resolved "https://registry.npmmirror.com/@expo/plist/-/plist-0.5.2.tgz#5bfc81cf09c1c0513a31d7e5cabf85b2ac4d1d71" @@ -1278,6 +1306,18 @@ resolved "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + "@isaacs/ttlcache@^1.4.1": version "1.4.1" resolved "https://registry.npmmirror.com/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz#21fb23db34e9b6220c6ba023a0118a2dd3461ea2" @@ -1431,6 +1471,11 @@ resolved "https://registry.npmmirror.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e" integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA== +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + "@preeternal/react-native-cookie-manager@^6.3.2": version "6.3.2" resolved "https://registry.npmmirror.com/@preeternal/react-native-cookie-manager/-/react-native-cookie-manager-6.3.2.tgz#7178469b483c7f43e7f12891ba11860499d4600b" @@ -1757,6 +1802,11 @@ resolved "https://registry.npmmirror.com/@react-native/normalize-colors/-/normalize-colors-0.83.6.tgz#9fef0e98733d58267aecafede08ebcc830a29c82" integrity sha512-bTM24b5v4qN3h52oflnv+OujFORn/kVi06WaWhnQQw14/ycilPqIsqsa+DpIBqdBrXxvLa9fXtCRrQtGATZCEw== +"@react-native/normalize-colors@^0.79.2": + version "0.79.7" + resolved "https://registry.npmmirror.com/@react-native/normalize-colors/-/normalize-colors-0.79.7.tgz#f7a3680dc81528b19761169cb6177ce64638b9ce" + integrity sha512-RrvewhdanEWhlyrHNWGXGZCc6MY0JGpNgRzA8y6OomDz0JmlnlIsbBHbNpPnIrt9Jh2KaV10KTscD1Ry8xU9gQ== + "@react-native/virtualized-lists@0.83.6": version "0.83.6" resolved "https://registry.npmmirror.com/@react-native/virtualized-lists/-/virtualized-lists-0.83.6.tgz#db5c2a7280519c6bea6c77a84cd70cd106a79f87" @@ -2433,6 +2483,11 @@ resolved "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.9.10.tgz#a0ad5a26fe8aa996310870726e1704977f769dee" integrity sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw== +"@xmldom/xmldom@~0.7.0": + version "0.7.13" + resolved "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.7.13.tgz#ff34942667a4e19a9f4a0996a76814daac364cf3" + integrity sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -2510,6 +2565,11 @@ ansi-regex@^5.0.0, ansi-regex@^5.0.1: resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== +ansi-regex@^6.2.2: + version "6.2.2" + resolved "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -2529,6 +2589,11 @@ ansi-styles@^5.0.0: resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== +ansi-styles@^6.1.0: + version "6.2.3" + resolved "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== + anymatch@^3.0.3: version "3.1.3" resolved "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" @@ -2845,7 +2910,7 @@ balanced-match@^4.0.2: resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== -base64-js@^1.3.1, base64-js@^1.5.1: +base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -2896,6 +2961,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.2: + version "2.1.0" + resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz#4f41a41190216ee36067ec381526fe9539c4f0ae" + integrity sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w== + dependencies: + balanced-match "^1.0.0" + brace-expansion@^5.0.5: version "5.0.5" resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" @@ -3340,6 +3412,11 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + ee-first@1.1.1: version "1.1.1" resolved "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" @@ -3355,6 +3432,11 @@ emoji-regex@^8.0.0: resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -4183,6 +4265,14 @@ for-each@^0.3.3, for-each@^0.3.5: dependencies: is-callable "^1.2.7" +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + fresh@~0.5.2: version "0.5.2" resolved "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -4297,6 +4387,18 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@^10.4.2: + version "10.5.0" + resolved "https://registry.npmmirror.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" + integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + glob@^13.0.0: version "13.0.6" resolved "https://registry.npmmirror.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d" @@ -4808,6 +4910,15 @@ iterator.prototype@^1.1.5: has-symbols "^1.1.0" set-function-name "^2.0.2" +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + jest-environment-node@^29.7.0: version "29.7.0" resolved "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" @@ -5150,7 +5261,7 @@ loose-envify@^1.0.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -lru-cache@^10.0.1: +lru-cache@^10.0.1, lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== @@ -5449,12 +5560,19 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2, minimatch@^3.1.5: dependencies: brace-expansion "^1.1.7" +minimatch@^9.0.4: + version "9.0.9" + resolved "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== + dependencies: + brace-expansion "^2.0.2" + minimist@^1.2.0, minimist@^1.2.6: version "1.2.8" resolved "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== -minipass@^7.1.2, minipass@^7.1.3: +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2, minipass@^7.1.3: version "7.1.3" resolved "https://registry.npmmirror.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b" integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A== @@ -5757,6 +5875,11 @@ p-try@^2.0.0: resolved "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + pako@~1.0.2: version "1.0.11" resolved "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" @@ -5801,6 +5924,14 @@ path-parse@^1.0.7: resolved "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-scurry@^2.0.2: version "2.0.2" resolved "https://registry.npmmirror.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85" @@ -6547,6 +6678,11 @@ signal-exit@^3.0.2, signal-exit@^3.0.7: resolved "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + simple-plist@^1.1.0: version "1.3.1" resolved "https://registry.npmmirror.com/simple-plist/-/simple-plist-1.3.1.tgz#16e1d8f62c6c9b691b8383127663d834112fb017" @@ -6663,6 +6799,15 @@ strict-uri-encode@^2.0.0: resolved "https://registry.npmmirror.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -6672,6 +6817,15 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + string.prototype.matchall@^4.0.12: version "4.0.12" resolved "https://registry.npmmirror.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz#6c88740e49ad4956b1332a911e949583a275d4c0" @@ -6738,6 +6892,13 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^5.2.0: version "5.2.0" resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" @@ -6752,6 +6913,13 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" +strip-ansi@^7.0.1: + version "7.2.0" + resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== + dependencies: + ansi-regex "^6.2.2" + strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.npmmirror.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" @@ -7116,6 +7284,11 @@ uuid@^7.0.3: resolved "https://registry.npmmirror.com/uuid/-/uuid-7.0.3.tgz#c5c9f2c8cf25dc0a372c4df1441c41f5bd0c680b" integrity sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg== +uuid@^8.3.2: + version "8.3.2" + resolved "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" + integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== + validate-npm-package-name@^5.0.0: version "5.0.1" resolved "https://registry.npmmirror.com/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz#a316573e9b49f3ccd90dbb6eb52b3f06c6d604e8" @@ -7245,6 +7418,15 @@ word-wrap@^1.2.5: resolved "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" @@ -7254,6 +7436,15 @@ wrap-ansi@^7.0.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + wrappy@1: version "1.0.2" resolved "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -7293,6 +7484,11 @@ xml2js@0.6.0: sax ">=0.6.0" xmlbuilder "~11.0.0" +xmlbuilder@^14.0.0: + version "14.0.0" + resolved "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-14.0.0.tgz#876b5aec4f05ffd5feb97b0a871c855d16fbeb8c" + integrity sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg== + xmlbuilder@^15.1.1: version "15.1.1" resolved "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" From 14dbed4508580e5ad704a9c7924acbb730ca7785 Mon Sep 17 00:00:00 2001 From: zhxycn Date: Sat, 2 May 2026 16:20:53 +0800 Subject: [PATCH 3/5] =?UTF-8?q?:wrench:=20ci:=20web=5Ftrigger=5Fjob=20?= =?UTF-8?q?=E5=85=B3=E8=81=94=E5=85=A8=E9=83=A8=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cnb.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.cnb.yml b/.cnb.yml index 6d5f8a2..033ee61 100644 --- a/.cnb.yml +++ b/.cnb.yml @@ -23,6 +23,7 @@ main: yarn install --frozen-lockfile yarn run lint +"**": web_trigger_job: - runner: cpus: 16 From f19ecc8083547bc4d46ab46f6c12b1e7db1a0305 Mon Sep 17 00:00:00 2001 From: zhxycn Date: Sat, 2 May 2026 17:36:10 +0800 Subject: [PATCH 4/5] =?UTF-8?q?:bug:=20fix:=20=E4=BF=AE=E5=A4=8D=E5=B0=8F?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/src/main/AndroidManifest.xml | 2 + .../dev/tokenteam/iwut/widget/ScheduleData.kt | 66 ++++++++++-- .../tokenteam/iwut/widget/ScheduleWidget.kt | 101 ++++++++++++++---- .../dev/tokenteam/iwut/widget/WidgetModule.kt | 1 + services/get-course.tsx | 3 + services/widget-sync.ts | 83 +++----------- targets/widget/Models/WidgetData.swift | 95 ++++++++++++++-- targets/widget/ScheduleTimelineProvider.swift | 61 +++++++++-- targets/widget/Views/CourseInfoView.swift | 3 +- .../Views/ScheduleWidgetEntryView.swift | 50 ++++----- 10 files changed, 324 insertions(+), 141 deletions(-) diff --git a/modules/widget/android/src/main/AndroidManifest.xml b/modules/widget/android/src/main/AndroidManifest.xml index 1fbb579..6da85b5 100644 --- a/modules/widget/android/src/main/AndroidManifest.xml +++ b/modules/widget/android/src/main/AndroidManifest.xml @@ -1,10 +1,12 @@ + + = emptyList(), - val tomorrowCourses: List = emptyList(), - val dayOfWeek: Int = 1, - val week: Int = 1, - val weekStr: String = "", - val dateStr: String = "", - val dayOfWeekStr: String = "", + val courses: List = emptyList(), + val termStart: String = "", val updatedAt: String = "", ) object ScheduleData { private val gson = Gson() + private val DAY_NAMES = arrayOf("", "周一", "周二", "周三", "周四", "周五", "周六", "周日") fun load(context: Context): ScheduleWidgetData? { val prefs = context.getSharedPreferences("widget_data", Context.MODE_PRIVATE) val json = prefs.getString("schedule", null) ?: return null - return try { gson.fromJson(json, ScheduleWidgetData::class.java) } catch (e: Exception) { null } } + + fun getCurrentWeek(termStart: String): Int { + val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + val startDate = try { + sdf.parse(termStart) ?: return 1 + } catch (e: Exception) { + return 1 + } + val now = Calendar.getInstance().time + val diffMs = now.time - startDate.time + if (diffMs < 0) return 0 + val diffDays = TimeUnit.MILLISECONDS.toDays(diffMs) + return (diffDays / 7 + 1).toInt() + } + + fun getDayOfWeek(): Int { + val cal = Calendar.getInstance() + val dow = cal.get(Calendar.DAY_OF_WEEK) + return if (dow == Calendar.SUNDAY) 7 else dow - 1 + } + + fun getTomorrowDayOfWeek(): Int { + val today = getDayOfWeek() + return if (today == 7) 1 else today + 1 + } + + fun getTomorrowWeek(termStart: String): Int { + val today = getDayOfWeek() + val week = getCurrentWeek(termStart) + return if (today == 7) week + 1 else week + } + + fun getWeekStr(week: Int): String = "第${week}周" + + fun getDateStr(): String { + val cal = Calendar.getInstance() + return "${cal.get(Calendar.MONTH) + 1}月${cal.get(Calendar.DAY_OF_MONTH)}日" + } + + fun getDayOfWeekStr(day: Int): String = DAY_NAMES.getOrElse(day) { "" } + + fun parseTimeToMinutes(time: String): Int { + val parts = time.split(":") + if (parts.size != 2) return 0 + return (parts[0].toIntOrNull() ?: 0) * 60 + (parts[1].toIntOrNull() ?: 0) + } } diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt index d559006..eb94c4f 100644 --- a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt @@ -1,8 +1,11 @@ package dev.tokenteam.iwut.widget +import android.app.AlarmManager +import android.app.PendingIntent import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context +import android.content.Intent import android.view.View import android.widget.RemoteViews import java.util.Calendar @@ -17,9 +20,23 @@ class ScheduleWidget : AppWidgetProvider() { for (id in appWidgetIds) { updateWidget(context, appWidgetManager, id) } + scheduleNextAlarm(context) + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action == ACTION_AUTO_REFRESH) { + val manager = AppWidgetManager.getInstance(context) + val ids = manager.getAppWidgetIds( + android.content.ComponentName(context, ScheduleWidget::class.java) + ) + onUpdate(context, manager, ids) + } } companion object { + const val ACTION_AUTO_REFRESH = "dev.tokenteam.iwut.widget.AUTO_REFRESH" + fun updateWidget( context: Context, appWidgetManager: AppWidgetManager, @@ -28,22 +45,38 @@ class ScheduleWidget : AppWidgetProvider() { val views = RemoteViews(context.packageName, R.layout.widget_schedule) val data = ScheduleData.load(context) - val weekStr = data?.weekStr ?: "" - val dateStr = data?.dateStr ?: "" - val dayOfWeekStr = data?.dayOfWeekStr ?: "" + if (data == null || data.termStart.isEmpty()) { + views.setViewVisibility(R.id.course_group, View.GONE) + views.setViewVisibility(R.id.all_done_group, View.VISIBLE) + appWidgetManager.updateAppWidget(appWidgetId, views) + return + } - views.setTextViewText(R.id.tv_week, weekStr) - views.setTextViewText(R.id.tv_date, dateStr) - views.setTextViewText(R.id.tv_day_of_week, dayOfWeekStr) + val week = ScheduleData.getCurrentWeek(data.termStart) + val today = ScheduleData.getDayOfWeek() + val tomorrowDay = ScheduleData.getTomorrowDayOfWeek() + val tomorrowWeek = ScheduleData.getTomorrowWeek(data.termStart) - val todayCourses = data?.todayCourses ?: emptyList() - val tomorrowCourses = data?.tomorrowCourses ?: emptyList() + views.setTextViewText(R.id.tv_week, ScheduleData.getWeekStr(week)) + views.setTextViewText(R.id.tv_date, ScheduleData.getDateStr()) + views.setTextViewText(R.id.tv_day_of_week, ScheduleData.getDayOfWeekStr(today)) val now = Calendar.getInstance() val nowMin = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) - val upcomingToday = todayCourses.filter { parseTimeToMinutes(it.endTime) > nowMin } - val combined = (upcomingToday + tomorrowCourses).take(2) + val todayCourses = data.courses + .filter { it.day == today && it.weekStart <= week && it.weekEnd >= week } + .sortedBy { it.sectionStart } + + val tomorrowCourses = data.courses + .filter { it.day == tomorrowDay && it.weekStart <= tomorrowWeek && it.weekEnd >= tomorrowWeek } + .sortedBy { it.sectionStart } + + val upcomingToday = todayCourses.filter { + ScheduleData.parseTimeToMinutes(it.endTime) > nowMin + } + + val combined = (upcomingToday.map { it to true } + tomorrowCourses.map { it to false }).take(2) if (combined.isEmpty()) { views.setViewVisibility(R.id.course_group, View.GONE) @@ -55,19 +88,19 @@ class ScheduleWidget : AppWidgetProvider() { views.setViewVisibility(R.id.course_group, View.VISIBLE) views.setViewVisibility(R.id.all_done_group, View.GONE) - val c1 = combined[0] + val (c1, c1IsToday) = combined[0] views.setViewVisibility(R.id.course_row_1, View.VISIBLE) views.setTextViewText(R.id.course_1_name, c1.name) - views.setTextViewText(R.id.course_1_tag, if (c1.isToday) "今天" else "明天") + views.setTextViewText(R.id.course_1_tag, if (c1IsToday) "今天" else "明天") views.setTextViewText(R.id.course_1_room, c1.room) views.setTextViewText(R.id.course_1_time, "${c1.startTime}-${c1.endTime}") if (combined.size > 1) { - val c2 = combined[1] + val (c2, c2IsToday) = combined[1] views.setViewVisibility(R.id.course_row_2, View.VISIBLE) views.setViewVisibility(R.id.tv_no_more, View.GONE) views.setTextViewText(R.id.course_2_name, c2.name) - views.setTextViewText(R.id.course_2_tag, if (c2.isToday) "今天" else "明天") + views.setTextViewText(R.id.course_2_tag, if (c2IsToday) "今天" else "明天") views.setTextViewText(R.id.course_2_room, c2.room) views.setTextViewText(R.id.course_2_time, "${c2.startTime}-${c2.endTime}") } else { @@ -89,10 +122,42 @@ class ScheduleWidget : AppWidgetProvider() { appWidgetManager.updateAppWidget(appWidgetId, views) } - private fun parseTimeToMinutes(time: String): Int { - val parts = time.split(":") - if (parts.size != 2) return 0 - return (parts[0].toIntOrNull() ?: 0) * 60 + (parts[1].toIntOrNull() ?: 0) + fun scheduleNextAlarm(context: Context) { + val data = ScheduleData.load(context) ?: return + if (data.termStart.isEmpty()) return + + val week = ScheduleData.getCurrentWeek(data.termStart) + val today = ScheduleData.getDayOfWeek() + val now = Calendar.getInstance() + val nowMin = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE) + + val nextEndMin = data.courses + .filter { it.day == today && it.weekStart <= week && it.weekEnd >= week } + .map { ScheduleData.parseTimeToMinutes(it.endTime) } + .filter { it > nowMin } + .minOrNull() ?: return + + val alarmTime = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, nextEndMin / 60) + set(Calendar.MINUTE, nextEndMin % 60) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + val intent = Intent(context, ScheduleWidget::class.java).apply { + action = ACTION_AUTO_REFRESH + } + val pendingIntent = PendingIntent.getBroadcast( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + alarmTime.timeInMillis, + pendingIntent + ) } } } diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt index dd327fe..dbac449 100644 --- a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt @@ -32,6 +32,7 @@ class WidgetModule : Module() { intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids) context.sendBroadcast(intent) } + ScheduleWidget.scheduleNextAlarm(context) null } } diff --git a/services/get-course.tsx b/services/get-course.tsx index 3637de8..772292d 100644 --- a/services/get-course.tsx +++ b/services/get-course.tsx @@ -22,6 +22,7 @@ import { WebView } from "react-native-webview"; import { IS_DEV } from "@/constants/is-dev"; import { useZhlgdAutoLogin } from "@/hooks/use-zhlgd-autologin"; import { reportError } from "@/lib/report"; +import { syncWidgetData } from "@/services/widget-sync"; import { type Course, type ImportType, useCourseStore } from "@/store/course"; // 本科生 @@ -352,6 +353,7 @@ export const GetCourse = forwardRef( store.setImportedCourses(courses); store.setLastImportType(importType); if (msg.termStart) store.setTermStart(msg.termStart); + syncWidgetData().catch(() => {}); finish(true); return; } @@ -377,6 +379,7 @@ export const GetCourse = forwardRef( const store = useCourseStore.getState(); store.setImportedCourses(courses); store.setLastImportType(importType); + syncWidgetData().catch(() => {}); finish(true); } }, diff --git a/services/widget-sync.ts b/services/widget-sync.ts index 38f9d78..58084ef 100644 --- a/services/widget-sync.ts +++ b/services/widget-sync.ts @@ -1,94 +1,45 @@ -import { - getCurrentDayOfWeek, - getCurrentWeek, - getTomorrowDayOfWeek, - getTomorrowWeek, -} from "@/lib/date"; import { reloadWidgets, setWidgetData } from "@/modules/widget"; import { SECTION_TIMES } from "@/services/course-time"; import { useCourseStore } from "@/store/course"; -const DAY_NAMES = ["", "周一", "周二", "周三", "周四", "周五", "周六", "周日"]; - interface WidgetCourse { name: string; room: string; - teacher: string; + day: number; + weekStart: number; + weekEnd: number; sectionStart: number; sectionEnd: number; startTime: string; endTime: string; - isToday: boolean; } interface ScheduleWidgetData { - todayCourses: WidgetCourse[]; - tomorrowCourses: WidgetCourse[]; - dayOfWeek: number; - week: number; - weekStr: string; - dateStr: string; - dayOfWeekStr: string; + courses: WidgetCourse[]; + termStart: string; updatedAt: string; } -function toWidgetCourse( - c: { - name: string; - room: string; - teacher: string; - sectionStart: number; - sectionEnd: number; - }, - isToday: boolean, -): WidgetCourse { - return { +export async function syncWidgetData(): Promise { + const { courses, termStart } = useCourseStore.getState(); + if (!termStart || courses.length === 0) return; + + const widgetCourses: WidgetCourse[] = courses.map((c) => ({ name: c.name, room: c.room, - teacher: c.teacher, + day: c.day, + weekStart: c.weekStart, + weekEnd: c.weekEnd, sectionStart: c.sectionStart, sectionEnd: c.sectionEnd, startTime: SECTION_TIMES[c.sectionStart]?.[0] ?? "", endTime: SECTION_TIMES[c.sectionEnd]?.[1] ?? "", - isToday, - }; -} - -export async function syncWidgetData(): Promise { - const { courses, termStart } = useCourseStore.getState(); - if (!termStart || courses.length === 0) return; - - const week = getCurrentWeek(termStart); - const today = getCurrentDayOfWeek(); - const tomorrowDay = getTomorrowDayOfWeek(); - const tomorrowWeek = getTomorrowWeek(termStart); - - const now = new Date(); - - const todayCourses = courses - .filter((c) => c.day === today && c.weekStart <= week && c.weekEnd >= week) - .sort((a, b) => a.sectionStart - b.sectionStart) - .map((c) => toWidgetCourse(c, true)); - - const tomorrowCourses = courses - .filter( - (c) => - c.day === tomorrowDay && - c.weekStart <= tomorrowWeek && - c.weekEnd >= tomorrowWeek, - ) - .sort((a, b) => a.sectionStart - b.sectionStart) - .map((c) => toWidgetCourse(c, false)); + })); const data: ScheduleWidgetData = { - todayCourses, - tomorrowCourses, - dayOfWeek: today, - week, - weekStr: `第${week}周`, - dateStr: `${now.getMonth() + 1}月${now.getDate()}日`, - dayOfWeekStr: DAY_NAMES[today] ?? "", - updatedAt: now.toISOString(), + courses: widgetCourses, + termStart, + updatedAt: new Date().toISOString(), }; await setWidgetData("schedule", data as unknown as Record); diff --git a/targets/widget/Models/WidgetData.swift b/targets/widget/Models/WidgetData.swift index 0b94bf9..5dad51c 100644 --- a/targets/widget/Models/WidgetData.swift +++ b/targets/widget/Models/WidgetData.swift @@ -3,22 +3,18 @@ import Foundation struct WidgetCourse: Codable { let name: String let room: String - let teacher: String + let day: Int + let weekStart: Int + let weekEnd: Int let sectionStart: Int let sectionEnd: Int let startTime: String let endTime: String - let isToday: Bool } struct ScheduleWidgetData: Codable { - let todayCourses: [WidgetCourse] - let tomorrowCourses: [WidgetCourse] - let dayOfWeek: Int - let week: Int - let weekStr: String - let dateStr: String - let dayOfWeekStr: String + let courses: [WidgetCourse] + let termStart: String let updatedAt: String static func load() -> ScheduleWidgetData? { @@ -30,3 +26,84 @@ struct ScheduleWidgetData: Codable { return try? JSONDecoder().decode(ScheduleWidgetData.self, from: data) } } + +struct ScheduleHelper { + private static let dayNames = ["", "周一", "周二", "周三", "周四", "周五", "周六", "周日"] + + static func currentWeek(termStart: String) -> Int { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + guard let startDate = formatter.date(from: termStart) else { return 1 } + + let now = Date() + let diffSeconds = now.timeIntervalSince(startDate) + if diffSeconds < 0 { return 0 } + let diffDays = Int(diffSeconds / 86400) + return diffDays / 7 + 1 + } + + static func dayOfWeek(for date: Date = .now) -> Int { + let weekday = Calendar.current.component(.weekday, from: date) + return weekday == 1 ? 7 : weekday - 1 + } + + static func tomorrowDayOfWeek() -> Int { + let today = dayOfWeek() + return today == 7 ? 1 : today + 1 + } + + static func tomorrowWeek(termStart: String) -> Int { + let today = dayOfWeek() + let week = currentWeek(termStart: termStart) + return today == 7 ? week + 1 : week + } + + static func weekStr(week: Int) -> String { + "第\(week)周" + } + + static func dateStr(for date: Date = .now) -> String { + let cal = Calendar.current + let month = cal.component(.month, from: date) + let day = cal.component(.day, from: date) + return "\(month)月\(day)日" + } + + static func dayOfWeekStr(day: Int) -> String { + guard day >= 1 && day <= 7 else { return "" } + return dayNames[day] + } + + static func parseTimeToMinutes(_ time: String) -> Int { + let parts = time.split(separator: ":") + guard parts.count == 2, + let hour = Int(parts[0]), + let minute = Int(parts[1]) else { return 0 } + return hour * 60 + minute + } + + static func todayCourses(from data: ScheduleWidgetData) -> [WidgetCourse] { + let week = currentWeek(termStart: data.termStart) + let today = dayOfWeek() + return data.courses + .filter { $0.day == today && $0.weekStart <= week && $0.weekEnd >= week } + .sorted { $0.sectionStart < $1.sectionStart } + } + + static func tomorrowCourses(from data: ScheduleWidgetData) -> [WidgetCourse] { + let tWeek = tomorrowWeek(termStart: data.termStart) + let tDay = tomorrowDayOfWeek() + return data.courses + .filter { $0.day == tDay && $0.weekStart <= tWeek && $0.weekEnd >= tWeek } + .sorted { $0.sectionStart < $1.sectionStart } + } + + static func upcomingTodayCourses(from data: ScheduleWidgetData) -> [WidgetCourse] { + let cal = Calendar.current + let nowMin = cal.component(.hour, from: .now) * 60 + cal.component(.minute, from: .now) + return todayCourses(from: data).filter { + parseTimeToMinutes($0.endTime) > nowMin + } + } +} diff --git a/targets/widget/ScheduleTimelineProvider.swift b/targets/widget/ScheduleTimelineProvider.swift index 01febd9..92de3b3 100644 --- a/targets/widget/ScheduleTimelineProvider.swift +++ b/targets/widget/ScheduleTimelineProvider.swift @@ -4,16 +4,33 @@ struct ScheduleEntry: TimelineEntry { let date: Date let data: ScheduleWidgetData? - var courses: [WidgetCourse] { + var upcomingToday: [WidgetCourse] { guard let data = data else { return [] } - let today = data.todayCourses - if !today.isEmpty { return today } - return data.tomorrowCourses + return ScheduleHelper.upcomingTodayCourses(from: data) } - var isShowingTomorrow: Bool { - guard let data = data else { return false } - return data.todayCourses.isEmpty && !data.tomorrowCourses.isEmpty + var tomorrowCourses: [WidgetCourse] { + guard let data = data else { return [] } + return ScheduleHelper.tomorrowCourses(from: data) + } + + var displayCourses: [(course: WidgetCourse, isToday: Bool)] { + let today = upcomingToday.map { ($0, true) } + let tomorrow = tomorrowCourses.map { ($0, false) } + return Array((today + tomorrow).prefix(2)) + } + + var weekStr: String { + guard let data = data else { return "第-周" } + return ScheduleHelper.weekStr(week: ScheduleHelper.currentWeek(termStart: data.termStart)) + } + + var dateStr: String { + ScheduleHelper.dateStr(for: date) + } + + var dayOfWeekStr: String { + ScheduleHelper.dayOfWeekStr(day: ScheduleHelper.dayOfWeek(for: date)) } } @@ -28,8 +45,32 @@ struct ScheduleTimelineProvider: TimelineProvider { } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let entry = ScheduleEntry(date: .now, data: ScheduleWidgetData.load()) - let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now - completion(Timeline(entries: [entry], policy: .after(nextUpdate))) + let data = ScheduleWidgetData.load() + var entries: [ScheduleEntry] = [] + + entries.append(ScheduleEntry(date: .now, data: data)) + + if let data = data { + let todayCourses = ScheduleHelper.todayCourses(from: data) + let calendar = Calendar.current + + for course in todayCourses { + let parts = course.endTime.split(separator: ":") + guard parts.count == 2, + let hour = Int(parts[0]), + let minute = Int(parts[1]), + let endDate = calendar.date(bySettingHour: hour, minute: minute, second: 0, of: .now), + endDate > .now + else { continue } + entries.append(ScheduleEntry(date: endDate, data: data)) + } + } + + let nextMidnight = Calendar.current.startOfDay( + for: Calendar.current.date(byAdding: .day, value: 1, to: .now) ?? .now + ) + entries.append(ScheduleEntry(date: nextMidnight, data: data)) + + completion(Timeline(entries: entries, policy: .after(nextMidnight))) } } diff --git a/targets/widget/Views/CourseInfoView.swift b/targets/widget/Views/CourseInfoView.swift index 059a269..45d855b 100644 --- a/targets/widget/Views/CourseInfoView.swift +++ b/targets/widget/Views/CourseInfoView.swift @@ -2,9 +2,10 @@ import SwiftUI struct CourseInfoView: View { let course: WidgetCourse + let isToday: Bool private var tagText: String { - course.isToday ? "今天" : "明天" + isToday ? "今天" : "明天" } var body: some View { diff --git a/targets/widget/Views/ScheduleWidgetEntryView.swift b/targets/widget/Views/ScheduleWidgetEntryView.swift index b0fb926..84b34b9 100644 --- a/targets/widget/Views/ScheduleWidgetEntryView.swift +++ b/targets/widget/Views/ScheduleWidgetEntryView.swift @@ -4,30 +4,33 @@ import WidgetKit struct ScheduleWidgetEntryView: View { var entry: ScheduleTimelineProvider.Entry - private var courses: [WidgetCourse] { - entry.courses + private var displayCourses: [(course: WidgetCourse, isToday: Bool)] { + entry.displayCourses } private var bottomText: String { - guard let data = entry.data else { return "" } - if courses.isEmpty { return "" } + guard entry.data != nil else { return "" } + if displayCourses.isEmpty { return "" } - if data.todayCourses.isEmpty && data.tomorrowCourses.isEmpty { + let upcomingCount = entry.upcomingToday.count + let tomorrowCount = entry.tomorrowCourses.count + + if upcomingCount == 0 && tomorrowCount == 0 { return "今天和明天都没有课啦~" } let todayPart: String - if data.todayCourses.isEmpty { + if upcomingCount == 0 { todayPart = "今天没有课啦," } else { - todayPart = "今天还有\(data.todayCourses.count)节课," + todayPart = "今天还有\(upcomingCount)节课," } let tomorrowPart: String - if data.tomorrowCourses.isEmpty { + if tomorrowCount == 0 { tomorrowPart = "明天没有课啦~" } else { - tomorrowPart = "明天还有\(data.tomorrowCourses.count)节课" + tomorrowPart = "明天还有\(tomorrowCount)节课" } return todayPart + tomorrowPart @@ -35,37 +38,30 @@ struct ScheduleWidgetEntryView: View { var body: some View { ZStack { - WidgetBackgroundView(isEmpty: courses.isEmpty) + WidgetBackgroundView(isEmpty: displayCourses.isEmpty) HStack(spacing: 12) { - if let data = entry.data { - DateInfoView( - weekStr: data.weekStr, - dateStr: data.dateStr, - dayOfWeekStr: data.dayOfWeekStr - ) - } else { - DateInfoView( - weekStr: "第-周", - dateStr: "--月--日", - dayOfWeekStr: "--" - ) - } + DateInfoView( + weekStr: entry.weekStr, + dateStr: entry.dateStr, + dayOfWeekStr: entry.dayOfWeekStr + ) VStack(alignment: .leading, spacing: 0) { - if courses.isEmpty { + if displayCourses.isEmpty { EmptyCourseView() } - ForEach(courses.prefix(2).indices, id: \.self) { index in + ForEach(displayCourses.indices, id: \.self) { index in if index > 0 { Divider() .padding(.vertical, 8) } - CourseInfoView(course: courses[index]) + let item = displayCourses[index] + CourseInfoView(course: item.course, isToday: item.isToday) } - if courses.count == 1 { + if displayCourses.count == 1 { Text("没有更多课啦~") .foregroundColor(Color("TextPrimary")) .font(.system(size: 12)) From 9146e6f153aaa8eaa54aa7e0c9970f3cc9844733 Mon Sep 17 00:00:00 2001 From: zhxycn Date: Sat, 2 May 2026 18:22:11 +0800 Subject: [PATCH 5/5] =?UTF-8?q?:bug:=20fix:=20=E4=BF=AE=E5=A4=8D=20R8=20?= =?UTF-8?q?=E6=B7=B7=E6=B7=86=E5=AF=BC=E8=87=B4=E5=B0=8F=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=A7=A3=E6=9E=90=E5=A4=B1=E8=B4=A5=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 --- .../dev/tokenteam/iwut/widget/ScheduleData.kt | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt index b6742db..a5d55b8 100644 --- a/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt +++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt @@ -2,27 +2,28 @@ package dev.tokenteam.iwut.widget import android.content.Context import com.google.gson.Gson +import com.google.gson.annotations.SerializedName import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale import java.util.concurrent.TimeUnit data class WidgetCourse( - val name: String = "", - val room: String = "", - val day: Int = 1, - val weekStart: Int = 1, - val weekEnd: Int = 20, - val sectionStart: Int = 0, - val sectionEnd: Int = 0, - val startTime: String = "", - val endTime: String = "", + @SerializedName("name") val name: String = "", + @SerializedName("room") val room: String = "", + @SerializedName("day") val day: Int = 1, + @SerializedName("weekStart") val weekStart: Int = 1, + @SerializedName("weekEnd") val weekEnd: Int = 20, + @SerializedName("sectionStart") val sectionStart: Int = 0, + @SerializedName("sectionEnd") val sectionEnd: Int = 0, + @SerializedName("startTime") val startTime: String = "", + @SerializedName("endTime") val endTime: String = "", ) data class ScheduleWidgetData( - val courses: List = emptyList(), - val termStart: String = "", - val updatedAt: String = "", + @SerializedName("courses") val courses: List = emptyList(), + @SerializedName("termStart") val termStart: String = "", + @SerializedName("updatedAt") val updatedAt: String = "", ) object ScheduleData {