Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cnb.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ main:
yarn install --frozen-lockfile
yarn run lint

"**":
web_trigger_job:
- runner:
cpus: 16
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ app-example
# generated native folders
/ios
/android
**/android/build/

# OTA
!assets/certificate.pem
4 changes: 4 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -84,6 +87,7 @@ const config: ExpoConfig = {
},
],
"@sentry/react-native",
"@bacons/apple-targets",
"./plugins/with-gradle-props.js",
],
experiments: {
Expand Down
15 changes: 15 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
}
18 changes: 18 additions & 0 deletions modules/widget/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}

group = 'dev.tokenteam.iwut'

expoModule {
canBePublished = false
}

android {
namespace "dev.tokenteam.iwut.widget"
}

dependencies {
implementation "com.google.code.gson:gson:2.11.0"
}
16 changes: 16 additions & 0 deletions modules/widget/android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<application>
<receiver
android:name="dev.tokenteam.iwut.widget.ScheduleWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="dev.tokenteam.iwut.widget.AUTO_REFRESH" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_schedule_info" />
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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<WidgetCourse> = 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)
}
}
Original file line number Diff line number Diff line change
@@ -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
)
}
}
}
Loading
Loading