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
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..ea786e4 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",
@@ -84,6 +87,7 @@ const config: ExpoConfig = {
},
],
"@sentry/react-native",
+ "@bacons/apple-targets",
"./plugins/with-gradle-props.js",
],
experiments: {
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/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/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..6da85b5
--- /dev/null
+++ b/modules/widget/android/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
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..a5d55b8
--- /dev/null
+++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleData.kt
@@ -0,0 +1,88 @@
+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(
+ @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(
+ @SerializedName("courses") val courses: List = emptyList(),
+ @SerializedName("termStart") val termStart: String = "",
+ @SerializedName("updatedAt") 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
new file mode 100644
index 0000000..eb94c4f
--- /dev/null
+++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/ScheduleWidget.kt
@@ -0,0 +1,163 @@
+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
+
+class ScheduleWidget : AppWidgetProvider() {
+
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ 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,
+ appWidgetId: Int,
+ ) {
+ val views = RemoteViews(context.packageName, R.layout.widget_schedule)
+ val data = ScheduleData.load(context)
+
+ 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
+ }
+
+ val week = ScheduleData.getCurrentWeek(data.termStart)
+ val today = ScheduleData.getDayOfWeek()
+ val tomorrowDay = ScheduleData.getTomorrowDayOfWeek()
+ val tomorrowWeek = ScheduleData.getTomorrowWeek(data.termStart)
+
+ 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 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)
+ 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, 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 (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, 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 (c2IsToday) "今天" 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)
+ }
+
+ 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
new file mode 100644
index 0000000..dbac449
--- /dev/null
+++ b/modules/widget/android/src/main/java/dev/tokenteam/iwut/widget/WidgetModule.kt
@@ -0,0 +1,39 @@
+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)
+ }
+ ScheduleWidget.scheduleNextAlarm(context)
+ 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..f01c643
--- /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/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
new file mode 100644
index 0000000..36bc542
--- /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/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/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
new file mode 100644
index 0000000..58084ef
--- /dev/null
+++ b/services/widget-sync.ts
@@ -0,0 +1,47 @@
+import { reloadWidgets, setWidgetData } from "@/modules/widget";
+import { SECTION_TIMES } from "@/services/course-time";
+import { useCourseStore } from "@/store/course";
+
+interface WidgetCourse {
+ name: string;
+ room: string;
+ day: number;
+ weekStart: number;
+ weekEnd: number;
+ sectionStart: number;
+ sectionEnd: number;
+ startTime: string;
+ endTime: string;
+}
+
+interface ScheduleWidgetData {
+ courses: WidgetCourse[];
+ termStart: string;
+ updatedAt: string;
+}
+
+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,
+ 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] ?? "",
+ }));
+
+ const data: ScheduleWidgetData = {
+ courses: widgetCourses,
+ termStart,
+ updatedAt: new Date().toISOString(),
+ };
+
+ await setWidgetData("schedule", data as unknown as Record);
+ await reloadWidgets();
+}
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..5dad51c
--- /dev/null
+++ b/targets/widget/Models/WidgetData.swift
@@ -0,0 +1,109 @@
+import Foundation
+
+struct WidgetCourse: Codable {
+ let name: String
+ let room: String
+ let day: Int
+ let weekStart: Int
+ let weekEnd: Int
+ let sectionStart: Int
+ let sectionEnd: Int
+ let startTime: String
+ let endTime: String
+}
+
+struct ScheduleWidgetData: Codable {
+ let courses: [WidgetCourse]
+ let termStart: 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)
+ }
+}
+
+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
new file mode 100644
index 0000000..92de3b3
--- /dev/null
+++ b/targets/widget/ScheduleTimelineProvider.swift
@@ -0,0 +1,76 @@
+import WidgetKit
+
+struct ScheduleEntry: TimelineEntry {
+ let date: Date
+ let data: ScheduleWidgetData?
+
+ var upcomingToday: [WidgetCourse] {
+ guard let data = data else { return [] }
+ return ScheduleHelper.upcomingTodayCourses(from: data)
+ }
+
+ 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))
+ }
+}
+
+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 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/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..45d855b
--- /dev/null
+++ b/targets/widget/Views/CourseInfoView.swift
@@ -0,0 +1,54 @@
+import SwiftUI
+
+struct CourseInfoView: View {
+ let course: WidgetCourse
+ let isToday: Bool
+
+ private var tagText: String {
+ 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..84b34b9
--- /dev/null
+++ b/targets/widget/Views/ScheduleWidgetEntryView.swift
@@ -0,0 +1,83 @@
+import SwiftUI
+import WidgetKit
+
+struct ScheduleWidgetEntryView: View {
+ var entry: ScheduleTimelineProvider.Entry
+
+ private var displayCourses: [(course: WidgetCourse, isToday: Bool)] {
+ entry.displayCourses
+ }
+
+ private var bottomText: String {
+ guard entry.data != nil else { return "" }
+ if displayCourses.isEmpty { return "" }
+
+ let upcomingCount = entry.upcomingToday.count
+ let tomorrowCount = entry.tomorrowCourses.count
+
+ if upcomingCount == 0 && tomorrowCount == 0 {
+ return "今天和明天都没有课啦~"
+ }
+
+ let todayPart: String
+ if upcomingCount == 0 {
+ todayPart = "今天没有课啦,"
+ } else {
+ todayPart = "今天还有\(upcomingCount)节课,"
+ }
+
+ let tomorrowPart: String
+ if tomorrowCount == 0 {
+ tomorrowPart = "明天没有课啦~"
+ } else {
+ tomorrowPart = "明天还有\(tomorrowCount)节课"
+ }
+
+ return todayPart + tomorrowPart
+ }
+
+ var body: some View {
+ ZStack {
+ WidgetBackgroundView(isEmpty: displayCourses.isEmpty)
+
+ HStack(spacing: 12) {
+ DateInfoView(
+ weekStr: entry.weekStr,
+ dateStr: entry.dateStr,
+ dayOfWeekStr: entry.dayOfWeekStr
+ )
+
+ VStack(alignment: .leading, spacing: 0) {
+ if displayCourses.isEmpty {
+ EmptyCourseView()
+ }
+
+ ForEach(displayCourses.indices, id: \.self) { index in
+ if index > 0 {
+ Divider()
+ .padding(.vertical, 8)
+ }
+ let item = displayCourses[index]
+ CourseInfoView(course: item.course, isToday: item.isToday)
+ }
+
+ if displayCourses.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"