From 6166cae090f5d6429d364337e01abef1ade8fbcb Mon Sep 17 00:00:00 2001 From: YYiChen <107186179+YYiChen@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:57:58 +0800 Subject: [PATCH] feat: optionally show next week's schedule after week ends --- .../ui/screen/mine/GeneralSettingsScreen.kt | 12 ++ .../ui/screen/schedule/ScheduleScreen.kt | 24 ++- .../ui/screen/schedule/ScheduleViewModel.kt | 84 +++++++++- .../java/com/lingion/sleepy/util/AppPrefs.kt | 10 ++ .../sleepy/util/WeekDisplayResolver.kt | 121 ++++++++++++++ .../sleepy/widget/TodayDateNavStore.kt | 4 + .../com/lingion/sleepy/widget/TodayWidget.kt | 47 ++++-- .../com/lingion/sleepy/widget/TwoDayWidget.kt | 38 ++--- .../sleepy/widget/WeekGridWidgetProvider.kt | 85 ++++++---- .../lingion/sleepy/widget/WeekListWidget.kt | 25 +-- .../lingion/sleepy/widget/WeekViewWidget.kt | 25 +-- .../sleepy/widget/WidgetBitmapRenderers.kt | 76 +++++++-- .../sleepy/widget/WidgetCompactWindow.kt | 22 ++- .../lingion/sleepy/widget/WidgetContent.kt | 13 +- .../sleepy/widget/WidgetRenderActivity.kt | 60 +------ .../sleepy/widget/WidgetWeekDataLoader.kt | 46 +++++ app/src/main/res/values-en/strings.xml | 9 +- app/src/main/res/values-es/strings.xml | 9 +- app/src/main/res/values-ja/strings.xml | 9 +- app/src/main/res/values-zh-rCN/strings.xml | 9 +- app/src/main/res/values-zh-rTW/strings.xml | 9 +- app/src/main/res/values/strings.xml | 9 +- .../sleepy/util/WeekDisplayResolverTest.kt | 158 ++++++++++++++++++ .../widget/WeekDisplayWidgetContractTest.kt | 106 ++++++++++++ 24 files changed, 822 insertions(+), 188 deletions(-) create mode 100644 app/src/main/java/com/lingion/sleepy/util/WeekDisplayResolver.kt create mode 100644 app/src/main/java/com/lingion/sleepy/widget/WidgetWeekDataLoader.kt create mode 100644 app/src/test/java/com/lingion/sleepy/util/WeekDisplayResolverTest.kt create mode 100644 app/src/test/java/com/lingion/sleepy/widget/WeekDisplayWidgetContractTest.kt diff --git a/app/src/main/java/com/lingion/sleepy/ui/screen/mine/GeneralSettingsScreen.kt b/app/src/main/java/com/lingion/sleepy/ui/screen/mine/GeneralSettingsScreen.kt index 288ee7cf..f5ca1935 100644 --- a/app/src/main/java/com/lingion/sleepy/ui/screen/mine/GeneralSettingsScreen.kt +++ b/app/src/main/java/com/lingion/sleepy/ui/screen/mine/GeneralSettingsScreen.kt @@ -109,6 +109,7 @@ fun GeneralSettingsScreen( var gridAdaptiveHeight by remember { mutableStateOf(AppPrefs.isGridAdaptiveHeight(context)) } // v1.0.56 T3: 双指捏放行高(实验室, 默认关) var gridPinchZoom by remember { mutableStateOf(AppPrefs.isGridPinchZoom(context)) } + var autoNextWeek by remember { mutableStateOf(AppPrefs.isAutoNextWeek(context)) } // v1.0.56 T4: 语言折叠卡展开态 — 默认收起; 选择语言即 recreate 重建, 会话态足够 var languageExpanded by remember { mutableStateOf(false) } var eveningStart by remember { mutableStateOf(AppPrefs.getGridEveningStart(context)) } @@ -806,6 +807,17 @@ fun GeneralSettingsScreen( ) } } + HorizontalDivider(color = colors.outlineVariant.copy(alpha = SleepyTheme.Alpha.hairline)) + SettingToggleRow( + label = stringResource(R.string.settings_auto_next_week), + subtitle = stringResource(R.string.settings_auto_next_week_sub), + checked = autoNextWeek, + onCheckedChange = { + autoNextWeek = it + AppPrefs.setAutoNextWeek(context, it) + refreshWidgets() + } + ) } } } diff --git a/app/src/main/java/com/lingion/sleepy/ui/screen/schedule/ScheduleScreen.kt b/app/src/main/java/com/lingion/sleepy/ui/screen/schedule/ScheduleScreen.kt index 9128c7cd..657c69f7 100644 --- a/app/src/main/java/com/lingion/sleepy/ui/screen/schedule/ScheduleScreen.kt +++ b/app/src/main/java/com/lingion/sleepy/ui/screen/schedule/ScheduleScreen.kt @@ -75,6 +75,9 @@ import com.lingion.sleepy.util.AppPrefs import com.lingion.sleepy.util.DateUtils import com.lingion.sleepy.util.HolidayManager import com.lingion.sleepy.util.TimeTableUtils +import com.lingion.sleepy.util.WeekDisplayContext +import com.lingion.sleepy.util.WeekDisplayResolver +import com.lingion.sleepy.util.WeekDisplayStatus // 非 private: MainActivity(AppRoot 会话层)需以本类型注入 viewMode — // 会话内切视图/编辑课程 overlay 往返/切 tab 往返都不丢(启动默认仍由 AppRoot 初始化时读 AppPrefs)。 @@ -156,6 +159,7 @@ fun ScheduleScreen( currentWeek = state.selectedWeek, maxWeek = state.currentTable?.maxWeek ?: 20, startDate = state.currentTable?.startDate ?: "", + displayContext = state.weekDisplayContext, onSwitchTable = { showTableSwitcher = true }, onUndo = { undoScope.launch { @@ -413,6 +417,7 @@ private fun TopBar( currentWeek: Int, maxWeek: Int, startDate: String, + displayContext: WeekDisplayContext?, onSwitchTable: () -> Unit, onUndo: () -> Unit, scaleUncommitted: Boolean, @@ -427,12 +432,16 @@ private fun TopBar( ) { val colors = SleepyTheme.colors // 实时计算当前实际周(不依赖 state.currentWeek — 用户可能切到了别的周) - val actualWeek = remember(startDate) { + val actualWeek = displayContext?.actualWeek ?: remember(startDate) { if (startDate.isBlank()) 1 else DateUtils.currentWeek(startDate) } var menuOpen by remember { mutableStateOf(false) } val isOnActual = currentWeek == actualWeek - val semesterStatus = DateUtils.semesterStatus(startDate, maxWeek) + val semesterStatus = displayContext?.semesterStatus + ?: DateUtils.semesterStatus(startDate, maxWeek) + val displayStatus = displayContext?.let { + WeekDisplayResolver.statusForSelectedWeek(it, currentWeek) + } ?: WeekDisplayStatus.NORMAL Row( modifier = Modifier @@ -493,9 +502,14 @@ private fun TopBar( else -> 0 } Text( - text = if (statusRes == 0) - stringResource(R.string.schedule_current_week, currentWeek) - else "${stringResource(statusRes)} · ${stringResource(R.string.schedule_week_prefix, currentWeek)}", + text = when (displayStatus) { + WeekDisplayStatus.NEXT_WEEK -> stringResource(R.string.schedule_next_week) + WeekDisplayStatus.WEEKEND_CURRENT -> stringResource(R.string.schedule_weekend) + WeekDisplayStatus.CURRENT_ENDED -> stringResource(R.string.schedule_week_ended) + WeekDisplayStatus.NORMAL -> if (statusRes == 0) + stringResource(R.string.schedule_current_week, currentWeek) + else "${stringResource(statusRes)} · ${stringResource(R.string.schedule_week_prefix, currentWeek)}" + }, style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), color = if (isOnActual) colors.onPrimaryContainer else colors.primary, modifier = Modifier diff --git a/app/src/main/java/com/lingion/sleepy/ui/screen/schedule/ScheduleViewModel.kt b/app/src/main/java/com/lingion/sleepy/ui/screen/schedule/ScheduleViewModel.kt index 1378ec26..43c8e434 100644 --- a/app/src/main/java/com/lingion/sleepy/ui/screen/schedule/ScheduleViewModel.kt +++ b/app/src/main/java/com/lingion/sleepy/ui/screen/schedule/ScheduleViewModel.kt @@ -7,17 +7,24 @@ import com.lingion.sleepy.SleepyApp import com.lingion.sleepy.data.entity.CourseEntity import com.lingion.sleepy.data.entity.TimeTableEntity import com.lingion.sleepy.data.repository.ScheduleRepository +import com.lingion.sleepy.util.AppPrefs import com.lingion.sleepy.util.DateUtils +import com.lingion.sleepy.util.WeekDisplayContext +import com.lingion.sleepy.util.WeekDisplayResolver import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.time.LocalDate +import java.time.LocalDateTime data class ScheduleState( val tables: List = emptyList(), @@ -25,6 +32,9 @@ data class ScheduleState( val courses: List = emptyList(), val currentWeek: Int = 1, val selectedWeek: Int = 1, + val weekDisplayContext: WeekDisplayContext? = null, + /** true = 用户手动选周;false = 跟随自动展示周。 */ + val weekSelectionManual: Boolean = false, /** false=首次加载(本周), true=用户/系统已选定周 — 课程变更时 selectedWeek 不再被重置 */ val initialWeekSettled: Boolean = false, val nodesPerDay: Int = 12, @@ -77,6 +87,17 @@ class ScheduleViewModel : ViewModel() { init { loadTables() + viewModelScope.launch { + AppPrefs.changeBus + .filter { it == AppPrefs.KEY_AUTO_NEXT_WEEK } + .collect { recalculateWeekDisplay() } + } + viewModelScope.launch { + while (isActive) { + delay(60_000) + recalculateWeekDisplay() + } + } } private fun loadTables() { @@ -89,7 +110,14 @@ class ScheduleViewModel : ViewModel() { if (tables.isEmpty()) { // 没有课表就老实空着,不强行造占位表。 // selectedTableId = null,UI 走空态。 - _state.update { it.copy(tables = emptyList(), selectedTableId = null) } + _state.update { + it.copy( + tables = emptyList(), + selectedTableId = null, + weekDisplayContext = null, + weekSelectionManual = false + ) + } return@collect } val selectedId = _state.value.selectedTableId @@ -119,14 +147,29 @@ class ScheduleViewModel : ViewModel() { val rawTable = st.tables.find { it.id == tableId } // 水合: 绑定存在时 nodesPerDay/timeJson/smartConfigJson 以时间节次表为准 val table = rawTable?.hydratedWith(periodTable) - val week = table?.let { DateUtils.currentWeek(it.startDate) } ?: 1 + val displayContext = table?.let { + WeekDisplayResolver.resolve( + startDate = it.startDate, + maxWeek = it.maxWeek, + now = LocalDateTime.now(), + courses = courses, + timeJson = it.timeJson, + enabled = AppPrefs.isAutoNextWeek(SleepyApp.get()) + ) + } + val week = displayContext?.actualWeek ?: 1 // v7.10.16s: 只更新真实周(currentWeek, 供"回到本周"), 不再重置 selectedWeek — // 用户在第 x 周编辑/删课, 保存回来仍停在 x 周(此前被拽回真实周=跳回第一周体验)。 // 首次加载(initial=true)仍落真实周, 保持原行为 st.copy( courses = courses, currentWeek = week, - selectedWeek = if (st.initialWeekSettled) st.selectedWeek else week, + selectedWeek = when { + !st.initialWeekSettled -> displayContext?.displayWeek ?: week + !st.weekSelectionManual -> displayContext?.displayWeek ?: week + else -> st.selectedWeek + }, + weekDisplayContext = displayContext, initialWeekSettled = true, nodesPerDay = table?.nodesPerDay ?: 12, effectivePeriodTable = periodTable @@ -145,7 +188,14 @@ class ScheduleViewModel : ViewModel() { fun selectTable(id: Long) { manualSelectDone = true // 切表 = 新学期语境, 周选择回到该表真实周(initialWeekSettled 复位, loadCourses 重新落周) - _state.update { it.copy(selectedTableId = id, initialWeekSettled = false) } + _state.update { + it.copy( + selectedTableId = id, + initialWeekSettled = false, + weekSelectionManual = false, + weekDisplayContext = null + ) + } loadCourses(id) // 切表后同步数据库 isDefault,使小组件严格跟随 App 当前选中表(widget 按默认表解析) viewModelScope.launch { @@ -366,7 +416,31 @@ class ScheduleViewModel : ViewModel() { // 防呆: 下限 1, 上限 maxWeek — 之前只有下限, 右箭头可以无限翻出学期范围外 val maxWeek = _state.value.currentTable?.maxWeek ?: 20 if (week < 1 || week > maxWeek) return - _state.update { it.copy(selectedWeek = week) } + _state.update { it.copy(selectedWeek = week, weekSelectionManual = true) } + } + + private fun recalculateWeekDisplay() { + val current = _state.value + val table = current.effectiveCurrentTable ?: return + val context = WeekDisplayResolver.resolve( + startDate = table.startDate, + maxWeek = table.maxWeek, + now = LocalDateTime.now(), + courses = current.courses, + timeJson = table.timeJson, + enabled = AppPrefs.isAutoNextWeek(SleepyApp.get()) + ) + _state.update { + val selected = if (it.weekSelectionManual) it.selectedWeek else context.displayWeek + if (it.currentWeek == context.actualWeek && + it.weekDisplayContext == context && + it.selectedWeek == selected + ) it else it.copy( + currentWeek = context.actualWeek, + weekDisplayContext = context, + selectedWeek = selected + ) + } } /** diff --git a/app/src/main/java/com/lingion/sleepy/util/AppPrefs.kt b/app/src/main/java/com/lingion/sleepy/util/AppPrefs.kt index 57558e72..7b53401c 100644 --- a/app/src/main/java/com/lingion/sleepy/util/AppPrefs.kt +++ b/app/src/main/java/com/lingion/sleepy/util/AppPrefs.kt @@ -76,6 +76,8 @@ object AppPrefs { const val KEY_GRID_ROW_SCALE = "grid_row_scale" // float default 1.0 — 双指行高缩放确认值(相对基座; 顶栏 tick 落盘, 撤回回退) const val KEY_GRID_PINCH_ZOOM = "grid_pinch_zoom" // bool default false — 实验室: 网格视图双指捏放行高(v1.0.56 默认关, 关=手势不挂; 存量缩放值不清) const val DEFAULT_GRID_PINCH_ZOOM = false + const val KEY_AUTO_NEXT_WEEK = "auto_next_week" // bool default false — 实验室: 当周无剩余课程时自动显示下周 + const val DEFAULT_AUTO_NEXT_WEEK = false const val KEY_WEEK_SCALE = "week_scale" // float 0.7~1.3 default 1.0 — 周视图整体缩放(与网格视图互相独立, issue#8) const val KEY_GRID_CORNER_RATIO = "grid_corner_ratio" // float 0.0~2.0 default 1.0 — 网格/周视图圆角比例系数(乘基准 12/16dp, issue#8) const val KEY_WEEK_TWO_COLUMN = "week_two_column" // bool default false — 周视图两栏开关, issue#8 @@ -534,6 +536,14 @@ object AppPrefs { _changeBus.tryEmit(KEY_GRID_PINCH_ZOOM) } + fun isAutoNextWeek(ctx: Context): Boolean = + sp(ctx).getBoolean(KEY_AUTO_NEXT_WEEK, DEFAULT_AUTO_NEXT_WEEK) + + fun setAutoNextWeek(ctx: Context, v: Boolean) { + sp(ctx).edit().putBoolean(KEY_AUTO_NEXT_WEEK, v).apply() + _changeBus.tryEmit(KEY_AUTO_NEXT_WEEK) + } + fun getGridEveningStart(ctx: Context): String = sp(ctx).getString(KEY_GRID_EVENING_START, "18:00") ?: "18:00" diff --git a/app/src/main/java/com/lingion/sleepy/util/WeekDisplayResolver.kt b/app/src/main/java/com/lingion/sleepy/util/WeekDisplayResolver.kt new file mode 100644 index 00000000..566034f0 --- /dev/null +++ b/app/src/main/java/com/lingion/sleepy/util/WeekDisplayResolver.kt @@ -0,0 +1,121 @@ +package com.lingion.sleepy.util + +import com.lingion.sleepy.data.entity.CourseEntity +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +/** + * 展示周次的单一事实来源。 + * + * 这不是课程数据的重排器:课程仍以课表里保存的 day/startWeek/endWeek 为准。 + * 它只决定在“当周没有剩余课程”时,默认展示当前周还是下一周。 + */ +enum class WeekDisplayStatus { + NORMAL, + NEXT_WEEK, + WEEKEND_CURRENT, + CURRENT_ENDED +} + +data class WeekDisplayContext( + val actualWeek: Int, + val displayWeek: Int, + val actualWeekEnded: Boolean, + val status: WeekDisplayStatus, + val semesterStatus: DateUtils.SemesterStatus, + val today: LocalDate, + val enabled: Boolean +) + +object WeekDisplayResolver { + fun resolve( + startDate: String, + maxWeek: Int, + now: LocalDateTime, + courses: List, + timeJson: String, + enabled: Boolean + ): WeekDisplayContext { + val safeMaxWeek = maxWeek.coerceAtLeast(1) + val today = now.toLocalDate() + val semesterStatus = DateUtils.semesterStatus(startDate, safeMaxWeek, today) + val actualWeek = DateUtils.currentWeek(startDate, today).coerceIn(1, safeMaxWeek) + val ended = semesterStatus == DateUtils.SemesterStatus.IN_RANGE && + !hasRemainingCourse(actualWeek, today, now.toLocalTime(), courses, timeJson) + val canAdvance = enabled && + semesterStatus == DateUtils.SemesterStatus.IN_RANGE && + ended && actualWeek < safeMaxWeek + val displayWeek = if (canAdvance) actualWeek + 1 else actualWeek + val status = when { + canAdvance -> WeekDisplayStatus.NEXT_WEEK + enabled && ended && today.dayOfWeek.value >= 6 -> WeekDisplayStatus.WEEKEND_CURRENT + enabled && ended -> WeekDisplayStatus.CURRENT_ENDED + else -> WeekDisplayStatus.NORMAL + } + return WeekDisplayContext( + actualWeek = actualWeek, + displayWeek = displayWeek, + actualWeekEnded = ended, + status = status, + semesterStatus = semesterStatus, + today = today, + enabled = enabled + ) + } + + /** + * 给主界面/可手动导航的小组件计算当前选中周的标题状态。 + * 只有自动切换产生的下一周和已结束的真实周显示特殊标题,其他手动周次保持普通周次标题。 + */ + fun statusForSelectedWeek( + context: WeekDisplayContext, + selectedWeek: Int + ): WeekDisplayStatus = when { + !context.enabled -> WeekDisplayStatus.NORMAL + context.status == WeekDisplayStatus.NEXT_WEEK && selectedWeek == context.displayWeek -> + WeekDisplayStatus.NEXT_WEEK + selectedWeek == context.actualWeek && context.actualWeekEnded && + context.today.dayOfWeek.value >= 6 -> WeekDisplayStatus.WEEKEND_CURRENT + selectedWeek == context.actualWeek && context.actualWeekEnded -> + WeekDisplayStatus.CURRENT_ENDED + else -> WeekDisplayStatus.NORMAL + } + + /** 下周模式下,今日/两日小组件从下一周周一开始,而不是把周末和下周拼在一起。 */ + fun dateOfDisplayWeek(startDate: String, displayWeek: Int, dayOfWeek: Int): LocalDate = + DateUtils.dateOfWeek(startDate, displayWeek, dayOfWeek) + + private fun hasRemainingCourse( + actualWeek: Int, + today: LocalDate, + now: LocalTime, + courses: List, + timeJson: String + ): Boolean { + val todayDow = today.dayOfWeek.value + return courses.asSequence() + .filter { it.inWeek(actualWeek) } + .any { course -> + when { + course.day > todayDow -> true + course.day < todayDow -> false + else -> courseEndMinutes(course, timeJson)?.let { it > now.hour * 60 + now.minute } + ?: true + } + } + } + + private fun courseEndMinutes(course: CourseEntity, timeJson: String): Int? { + val effective = TimeTableUtils.effectiveCourseTime( + course.isIrregularTime || course.ownTime, + course.startTime, + course.endTime, + course.startNode, + course.step, + timeJson + ) ?: return null + val end = runCatching { LocalTime.parse(effective.second) }.getOrNull() ?: return null + return end.hour * 60 + end.minute + } +} diff --git a/app/src/main/java/com/lingion/sleepy/widget/TodayDateNavStore.kt b/app/src/main/java/com/lingion/sleepy/widget/TodayDateNavStore.kt index d462accd..0b01c01f 100644 --- a/app/src/main/java/com/lingion/sleepy/widget/TodayDateNavStore.kt +++ b/app/src/main/java/com/lingion/sleepy/widget/TodayDateNavStore.kt @@ -30,6 +30,10 @@ object TodayDateNavStore { return LocalDate.ofEpochDay(epoch) } + /** true 表示用户曾经手动翻过日期;自动周末切换不得覆盖这类导航态。 */ + fun hasNavigation(context: Context, widgetId: Int): Boolean = + read(context, widgetId) != null + /** 把日期推进 ±deltaDays (锚点刷新为 today); 钳制在 Core.MAX_ABS_OFFSET_DAYS 内。 */ fun shift(context: Context, widgetId: Int, today: LocalDate, deltaDays: Long) { synchronized(writeLock) { diff --git a/app/src/main/java/com/lingion/sleepy/widget/TodayWidget.kt b/app/src/main/java/com/lingion/sleepy/widget/TodayWidget.kt index 341dc5c9..d1383ee4 100644 --- a/app/src/main/java/com/lingion/sleepy/widget/TodayWidget.kt +++ b/app/src/main/java/com/lingion/sleepy/widget/TodayWidget.kt @@ -13,6 +13,7 @@ import com.lingion.sleepy.SleepyApp import com.lingion.sleepy.util.ConflictLayoutEngine import com.lingion.sleepy.util.DateUtils import com.lingion.sleepy.util.TimeTableUtils +import com.lingion.sleepy.util.WeekDisplayResolver import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -437,9 +438,11 @@ open class TodayWidgetReceiver : AppWidgetProvider() { */ fun loadDataSync(context: Context, appWidgetId: Int): WidgetData { val now = LocalDate.now() + val manualNavigation = TodayDateNavStore.hasNavigation(context, appWidgetId) return loadDataForDate( context, appWidgetId, - TodayDateNavStore.target(context, appWidgetId, now), now + TodayDateNavStore.target(context, appWidgetId, now), now, + autoNextWeek = !manualNavigation ) } @@ -448,9 +451,12 @@ open class TodayWidgetReceiver : AppWidgetProvider() { * 翻页卡工厂传卡面日期); [today] 必须同源自调用方 (禁内部再取第二次 now)。 */ fun loadDataForDate( - context: Context, appWidgetId: Int, target: LocalDate, today: LocalDate + context: Context, + appWidgetId: Int, + target: LocalDate, + today: LocalDate, + autoNextWeek: Boolean = false ): WidgetData { - val dayOfWeek = DateUtils.todayDayOfWeek(target) val isSystemDark = (context.resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES val isDark = com.lingion.sleepy.util.AppPrefs.isDarkMode(context, isSystemDark) val themeKey = com.lingion.sleepy.util.AppPrefs.getThemeKey(context) @@ -458,20 +464,35 @@ open class TodayWidgetReceiver : AppWidgetProvider() { Log.d("TodayWidget", "DIAG: isDark=$isDark isSystemDark=$isSystemDark themeMode=$themeMode themeKey=$themeKey") return try { runBlocking { - val app = SleepyApp.get() - val repo = app.repository - val table = WidgetTableResolver.resolveBoundTable(appWidgetId) - ?: WidgetTableResolver.resolveCurrentTable() - if (table == null) { + val source = WidgetWeekDataLoader.resolve(appWidgetId) + if (source == null) { WidgetData(date = target, courses = emptyList(), timeJson = TimeTableUtils.DEFAULT_TIME_JSON, hasTable = false, isDark = isDark, themeKey = themeKey, isToday = target == today) } else { - val week = DateUtils.currentWeek(table.startDate, target) - val status = DateUtils.semesterStatus(table.startDate, table.maxWeek, target) - val all = repo.getCoursesByDayOnce(table.id, dayOfWeek) + val table = source.table + val effectiveTarget = if (autoNextWeek && + source.display.status == com.lingion.sleepy.util.WeekDisplayStatus.NEXT_WEEK + ) { + source.dateFor(1) + } else target + val effectiveDayOfWeek = DateUtils.todayDayOfWeek(effectiveTarget) + val week = DateUtils.currentWeek(table.startDate, effectiveTarget) + val status = DateUtils.semesterStatus(table.startDate, table.maxWeek, effectiveTarget) // 学期外(前/后)不展示课程 — App 今日页同语义, 避免学期前显示"第1周"的课 val visible = if (status != DateUtils.SemesterStatus.IN_RANGE) emptyList() else - all.filter { it.inWeek(week) }.sortedBy { it.startNode } - WidgetData(date = target, courses = visible, timeJson = table.timeJson, hasTable = true, isDark = isDark, themeKey = themeKey, semesterStatus = status, isToday = target == today) + source.coursesFor(effectiveDayOfWeek, week) + WidgetData( + date = effectiveTarget, + courses = visible, + timeJson = table.timeJson, + hasTable = true, + isDark = isDark, + themeKey = themeKey, + semesterStatus = status, + isToday = effectiveTarget == today, + weekDisplayStatus = WeekDisplayResolver.statusForSelectedWeek( + source.display, week + ) + ) } } } catch (_: Throwable) { diff --git a/app/src/main/java/com/lingion/sleepy/widget/TwoDayWidget.kt b/app/src/main/java/com/lingion/sleepy/widget/TwoDayWidget.kt index 84f6bd5b..409bea07 100644 --- a/app/src/main/java/com/lingion/sleepy/widget/TwoDayWidget.kt +++ b/app/src/main/java/com/lingion/sleepy/widget/TwoDayWidget.kt @@ -174,33 +174,29 @@ open class TwoDayWidgetReceiver : AppWidgetProvider() { val themeKey = com.lingion.sleepy.util.AppPrefs.getThemeKey(context) return try { runBlocking { - val app = SleepyApp.get() - val repo = app.repository - val table = WidgetTableResolver.resolveBoundTable(appWidgetId) - ?: WidgetTableResolver.resolveCurrentTable() - if (table == null) { + val source = WidgetWeekDataLoader.resolve(appWidgetId) + if (source == null) { TwoDayData(days = emptyList(), hasTable = false, isDark = isDark, themeKey = themeKey) } else { - val week = DateUtils.currentWeek(table.startDate, today) - val status = DateUtils.semesterStatus(table.startDate, table.maxWeek, today) - val todayDow = today.dayOfWeek.value - val tomorrowDow = tomorrow.dayOfWeek.value - // 学期外不展示课程 — 与 App 今日页同语义 - val todayCourses = if (status != DateUtils.SemesterStatus.IN_RANGE) emptyList() else - repo.getCoursesByDayOnce(table.id, todayDow) - .filter { it.inWeek(week) }.sortedBy { it.startNode } - val tomorrowCourses = if (status != DateUtils.SemesterStatus.IN_RANGE) emptyList() else - repo.getCoursesByDayOnce(table.id, tomorrowDow) - .filter { it.inWeek(week) }.sortedBy { it.startNode } + val table = source.table + val dates = if (source.display.status == com.lingion.sleepy.util.WeekDisplayStatus.NEXT_WEEK) { + listOf(source.dateFor(1), source.dateFor(2)) + } else listOf(today, tomorrow) + val status = DateUtils.semesterStatus(table.startDate, table.maxWeek, dates.first()) + val days = dates.map { date -> + val week = DateUtils.currentWeek(table.startDate, date) + val dow = date.dayOfWeek.value + val courses = if (status != DateUtils.SemesterStatus.IN_RANGE) emptyList() + else source.coursesFor(dow, week) + DayData(date = date, dayOfWeek = dow, courses = courses, timeJson = table.timeJson) + } TwoDayData( - days = listOf( - DayData(date = today, dayOfWeek = todayDow, courses = todayCourses, timeJson = table.timeJson), - DayData(date = tomorrow, dayOfWeek = tomorrowDow, courses = tomorrowCourses, timeJson = table.timeJson) - ), + days = days, hasTable = true, isDark = isDark, themeKey = themeKey, - semesterStatus = status + semesterStatus = status, + weekDisplayStatus = source.display.status ) } } diff --git a/app/src/main/java/com/lingion/sleepy/widget/WeekGridWidgetProvider.kt b/app/src/main/java/com/lingion/sleepy/widget/WeekGridWidgetProvider.kt index bf8001c3..9533b029 100644 --- a/app/src/main/java/com/lingion/sleepy/widget/WeekGridWidgetProvider.kt +++ b/app/src/main/java/com/lingion/sleepy/widget/WeekGridWidgetProvider.kt @@ -190,7 +190,6 @@ open class WeekGridWidgetProvider : AppWidgetProvider() { val slots = allSlots.take(maxNode) val sortedDays = data.visibleDays.sorted() val dayCount = sortedDays.size.coerceIn(1, 7) - val todayDow = LocalDate.now().dayOfWeek.value // ── 布局 (dp → px, 跟 CourseTableView 同参数) ── val dp = { v: Float -> (v * density).roundToInt() } @@ -225,7 +224,11 @@ open class WeekGridWidgetProvider : AppWidgetProvider() { // 空状态: 无课表时显示占位提示, 不渲染空白网格 // 学期后课程被清空 → 落到这分支; 学期状态文案优先于"去创建课表" - if (!data.hasTable || data.days.isEmpty() || data.days.all { it.courses.isEmpty() }) { + if (!data.hasTable || data.days.isEmpty() || + (data.days.all { it.courses.isEmpty() } && + data.semesterStatus == DateUtils.SemesterStatus.IN_RANGE && + data.weekDisplayStatus == com.lingion.sleepy.util.WeekDisplayStatus.NORMAL) + ) { val ctx = SleepyApp.get() p.textAlign = Paint.Align.CENTER p.color = fgOnSurface @@ -252,6 +255,28 @@ open class WeekGridWidgetProvider : AppWidgetProvider() { return bmp } + if (data.days.all { it.courses.isEmpty() } && + data.semesterStatus == DateUtils.SemesterStatus.IN_RANGE + ) { + val ctx = SleepyApp.get() + val label = when (data.weekDisplayStatus) { + com.lingion.sleepy.util.WeekDisplayStatus.NEXT_WEEK -> ctx.getString(R.string.schedule_next_week) + com.lingion.sleepy.util.WeekDisplayStatus.WEEKEND_CURRENT -> ctx.getString(R.string.schedule_weekend) + com.lingion.sleepy.util.WeekDisplayStatus.CURRENT_ENDED -> ctx.getString(R.string.schedule_week_ended) + com.lingion.sleepy.util.WeekDisplayStatus.NORMAL -> ctx.getString(R.string.no_course) + } + p.textAlign = Paint.Align.CENTER + p.color = fgOnSurface + p.textSize = dp(15f).toFloat() + p.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + c.drawText(label, wPx / 2f, hPx / 2f - dp(6f), p) + p.color = fgOnSurfaceVar + p.textSize = dp(11f).toFloat() + p.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL) + c.drawText(ctx.getString(R.string.no_course), wPx / 2f, hPx / 2f + dp(14f), p) + return bmp + } + // ── Header (Day labels) ── var x = outerPad.toFloat() var y = outerPad.toFloat() @@ -274,13 +299,28 @@ open class WeekGridWidgetProvider : AppWidgetProvider() { p.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL) p.textAlign = Paint.Align.CENTER c.drawText(ctx2.getString(R.string.semester_not_started), x + timeW / 2f, y + headH * 0.6f, p) + } else if (data.semesterStatus == DateUtils.SemesterStatus.IN_RANGE) { + val ctx2 = SleepyApp.get() + val text = when (data.weekDisplayStatus) { + com.lingion.sleepy.util.WeekDisplayStatus.NEXT_WEEK -> ctx2.getString(R.string.schedule_next_week) + com.lingion.sleepy.util.WeekDisplayStatus.WEEKEND_CURRENT -> ctx2.getString(R.string.schedule_weekend) + com.lingion.sleepy.util.WeekDisplayStatus.CURRENT_ENDED -> ctx2.getString(R.string.schedule_week_ended) + com.lingion.sleepy.util.WeekDisplayStatus.NORMAL -> null + } + if (text != null) { + p.color = fgOnSurfaceVar + p.textSize = (headH * 0.16f).coerceAtMost(dp(9f).toFloat()).coerceAtLeast(dp(6f).toFloat()) + p.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + p.textAlign = Paint.Align.CENTER + c.drawText(text, x + timeW / 2f, y + headH * 0.6f, p) + } } // day headers for ((idx, dow) in sortedDays.withIndex()) { val cellX = x + timeW + gapW + idx * (dayW + gapW) - val isToday = dow == todayDow val dayData = data.days.firstOrNull { it.dayOfWeek == dow } + val isToday = dayData?.isToday == true val count = dayData?.courses?.size ?: 0 val dateStr = if (data.showDate && dayData != null) DateUtils.shortDate(dayData.date) else null @@ -315,7 +355,7 @@ open class WeekGridWidgetProvider : AppWidgetProvider() { for ((idx, dow) in sortedDays.withIndex()) { val colX = x + timeW + gapW + idx * (dayW + gapW) val dayData = data.days.firstOrNull { it.dayOfWeek == dow } ?: continue - if (dow == todayDow) { + if (dayData.isToday) { p.color = bgToday p.alpha = 40 c.drawRect(RectF(colX, bodyTop, colX + dayW, bodyTop + bodyH), p) @@ -413,7 +453,7 @@ open class WeekGridWidgetProvider : AppWidgetProvider() { for ((idx, dow) in sortedDays.withIndex()) { val colX = x + timeW + gapW + idx * (dayW + gapW) val dayData = data.days.firstOrNull { it.dayOfWeek == dow } ?: continue - val isToday = dow == todayDow + val isToday = dayData.isToday // today 背景列 if (isToday) { @@ -747,42 +787,25 @@ open class WeekGridWidgetProvider : AppWidgetProvider() { val showDate = AppPrefs.isShowDate(context) val visibleDays = AppPrefs.getVisibleDays(context) return try { - // Triple>> val loaded = kotlinx.coroutines.runBlocking { - val app = SleepyApp.get() - val repo = app.repository - // 选表逻辑:先按 widgetId 取绑定表,未绑定则走 WidgetTableResolver(默认表优先),避免与 App 选中表不同步 - val t = WidgetTableResolver.resolveBoundTable(appWidgetId) - ?: WidgetTableResolver.resolveCurrentTable() - val status = if (t != null) - DateUtils.semesterStatus(t.startDate, t.maxWeek, today) - else DateUtils.SemesterStatus.IN_RANGE - val map = if (t != null) { - val week = DateUtils.currentWeek(t.startDate, today) - (1..7).map { dow -> - // 学期前: 第 1 周课照常显示(预习); 学期后: 课程清空, renderer 画状态行 - val courses = if (status == DateUtils.SemesterStatus.AFTER_END) emptyList() else - repo.getCoursesByDayOnce(t.id, dow) - .filter { it.inWeek(week) }.sortedBy { it.startNode } - dow to courses - } - } else emptyList() - Triple(t, status, map) + WidgetWeekDataLoader.resolve(appWidgetId) } - val (t, status, daysPerCourse) = loaded - if (t == null) { + val source = loaded + if (source == null) { WeekData(days = emptyList(), hasTable = false, isDark = isDark, themeKey = themeKey, showDate = showDate, visibleDays = visibleDays) } else { - val days = daysPerCourse.map { (dow, courses) -> - val date = DateUtils.dateOfWeekDay(today, dow) - DayData(date = date, dayOfWeek = dow, courses = courses, timeJson = t.timeJson) + val days = (1..7).map { dow -> + val courses = if (source.display.semesterStatus == DateUtils.SemesterStatus.AFTER_END) emptyList() + else source.coursesFor(dow) + DayData(date = source.dateFor(dow), dayOfWeek = dow, courses = courses, timeJson = source.table.timeJson) } WeekData(days = days, hasTable = true, isDark = isDark, themeKey = themeKey, showDate = showDate, visibleDays = visibleDays, - semesterStatus = status) + semesterStatus = source.display.semesterStatus, + weekDisplayStatus = source.display.status) } } catch (e: Throwable) { Log.e(TAG, "loadWeekData failed", e) diff --git a/app/src/main/java/com/lingion/sleepy/widget/WeekListWidget.kt b/app/src/main/java/com/lingion/sleepy/widget/WeekListWidget.kt index b221a1ea..b6760806 100644 --- a/app/src/main/java/com/lingion/sleepy/widget/WeekListWidget.kt +++ b/app/src/main/java/com/lingion/sleepy/widget/WeekListWidget.kt @@ -55,7 +55,8 @@ open class WeekListWidgetReceiver : AppWidgetProvider() { } else WidgetBitmapRenderers.weekListContentHeightDp(context, data) val shownDays = if (visibleDays.isEmpty()) data.days else data.days.filter { it.dayOfWeek in visibleDays }.sortedBy { it.dayOfWeek } - val statusH = if (data.semesterStatus != DateUtils.SemesterStatus.IN_RANGE) 16f else 0f + val statusH = if (data.semesterStatus != DateUtils.SemesterStatus.IN_RANGE || + data.weekDisplayStatus != com.lingion.sleepy.util.WeekDisplayStatus.NORMAL) 16f else 0f val wins = if (!forceScroll && !compactFace && data.hasTable && shownDays.isNotEmpty() && data.days.any { it.courses.isNotEmpty() } ) computeWeekListWindows(shownDays, hDp.toFloat(), statusH) else null @@ -163,27 +164,29 @@ open class WeekListWidgetReceiver : AppWidgetProvider() { runBlocking { val app = SleepyApp.get() val repo = app.repository - val table = WidgetTableResolver.resolveBoundTable(appWidgetId) - ?: WidgetTableResolver.resolveCurrentTable() - if (table == null) { + val source = WidgetWeekDataLoader.resolve(appWidgetId) + if (source == null) { WeekData(days = emptyList(), hasTable = false, isDark = isDark, themeKey = themeKey) } else { - val week = DateUtils.currentWeek(table.startDate, today) - val status = DateUtils.semesterStatus(table.startDate, table.maxWeek, today) + val table = source.table + val week = source.display.displayWeek + val status = source.display.semesterStatus // 学期前: 钳制周=1, 第 1 周课照常显示(预习); 学期后: 课程清空, renderer 画状态行 val days = (1..7).map { dayOfWeek -> - val date = DateUtils.dateOfWeekDay(today, dayOfWeek) - val all = repo.getCoursesByDayOnce(table.id, dayOfWeek) + val date = source.dateFor(dayOfWeek) val visible = if (status == DateUtils.SemesterStatus.AFTER_END) emptyList() else - all.filter { it.inWeek(week) }.sortedBy { it.startNode } + source.coursesFor(dayOfWeek, week) DayData(date = date, dayOfWeek = dayOfWeek, courses = visible, timeJson = table.timeJson) } // 最小档三天窗口 (2026-09-15 用户令): 真实日期, 上下周打通 val compactWindow = WidgetCompactWindow.build( repo, table.id, table.timeJson, table.startDate, table.maxWeek, - today, WidgetCompactWindowStore.isTodayFirst(context, appWidgetId) + today, WidgetCompactWindowStore.isTodayFirst(context, appWidgetId), + displayWeek = source.display.displayWeek.takeIf { + source.display.status == com.lingion.sleepy.util.WeekDisplayStatus.NEXT_WEEK + } ) - WeekData(days = days, hasTable = true, isDark = isDark, themeKey = themeKey, semesterStatus = status, compactWindow = compactWindow) + WeekData(days = days, hasTable = true, isDark = isDark, themeKey = themeKey, semesterStatus = status, compactWindow = compactWindow, weekDisplayStatus = source.display.status) } } } catch (_: Throwable) { diff --git a/app/src/main/java/com/lingion/sleepy/widget/WeekViewWidget.kt b/app/src/main/java/com/lingion/sleepy/widget/WeekViewWidget.kt index a98953dc..1723c92d 100644 --- a/app/src/main/java/com/lingion/sleepy/widget/WeekViewWidget.kt +++ b/app/src/main/java/com/lingion/sleepy/widget/WeekViewWidget.kt @@ -61,7 +61,8 @@ open class WeekViewWidgetReceiver : AppWidgetProvider() { } val shownDays = if (visibleDays.isEmpty()) data.days else data.days.filter { it.dayOfWeek in visibleDays }.sortedBy { it.dayOfWeek } - val statusH = if (data.semesterStatus != DateUtils.SemesterStatus.IN_RANGE) 16f else 0f + val statusH = if (data.semesterStatus != DateUtils.SemesterStatus.IN_RANGE || + data.weekDisplayStatus != com.lingion.sleepy.util.WeekDisplayStatus.NORMAL) 16f else 0f val wins = if (!forceScroll && !compactFace && data.hasTable && shownDays.isNotEmpty() && data.days.any { it.courses.isNotEmpty() } ) { @@ -188,27 +189,29 @@ open class WeekViewWidgetReceiver : AppWidgetProvider() { kotlinx.coroutines.runBlocking { val app = com.lingion.sleepy.SleepyApp.get() val repo = app.repository - val table = WidgetTableResolver.resolveBoundTable(appWidgetId) - ?: WidgetTableResolver.resolveCurrentTable() - if (table == null) { + val source = WidgetWeekDataLoader.resolve(appWidgetId) + if (source == null) { WeekData(days = emptyList(), hasTable = false, isDark = isDark, themeKey = themeKey) } else { - val week = DateUtils.currentWeek(table.startDate, today) - val status = DateUtils.semesterStatus(table.startDate, table.maxWeek, today) + val table = source.table + val week = source.display.displayWeek + val status = source.display.semesterStatus // 学期前: 第 1 周课照常显示(预习); 学期后: 课程清空, renderer 画状态行 val days = (1..7).map { dayOfWeek -> - val date = DateUtils.dateOfWeekDay(today, dayOfWeek) - val all = repo.getCoursesByDayOnce(table.id, dayOfWeek) + val date = source.dateFor(dayOfWeek) val visible = if (status == DateUtils.SemesterStatus.AFTER_END) emptyList() else - all.filter { it.inWeek(week) }.sortedBy { it.startNode } + source.coursesFor(dayOfWeek, week) DayData(date = date, dayOfWeek = dayOfWeek, courses = visible, timeJson = table.timeJson) } // 最小档三天窗口 (2026-09-15 用户令): 真实日期, 上下周打通 val compactWindow = WidgetCompactWindow.build( repo, table.id, table.timeJson, table.startDate, table.maxWeek, - today, WidgetCompactWindowStore.isTodayFirst(context, appWidgetId) + today, WidgetCompactWindowStore.isTodayFirst(context, appWidgetId), + displayWeek = source.display.displayWeek.takeIf { + source.display.status == com.lingion.sleepy.util.WeekDisplayStatus.NEXT_WEEK + } ) - WeekData(days = days, hasTable = true, isDark = isDark, themeKey = themeKey, semesterStatus = status, compactWindow = compactWindow) + WeekData(days = days, hasTable = true, isDark = isDark, themeKey = themeKey, semesterStatus = status, compactWindow = compactWindow, weekDisplayStatus = source.display.status) } } } catch (_: Throwable) { diff --git a/app/src/main/java/com/lingion/sleepy/widget/WidgetBitmapRenderers.kt b/app/src/main/java/com/lingion/sleepy/widget/WidgetBitmapRenderers.kt index 085bf763..c5effe1a 100644 --- a/app/src/main/java/com/lingion/sleepy/widget/WidgetBitmapRenderers.kt +++ b/app/src/main/java/com/lingion/sleepy/widget/WidgetBitmapRenderers.kt @@ -15,6 +15,7 @@ import com.lingion.sleepy.util.CourseColorUtil import com.lingion.sleepy.util.CourseDisplayUtil import com.lingion.sleepy.util.DateUtils import com.lingion.sleepy.util.TimeTableUtils +import com.lingion.sleepy.util.WeekDisplayStatus import java.time.LocalDate import kotlin.math.roundToInt @@ -26,6 +27,13 @@ import kotlin.math.roundToInt */ object WidgetBitmapRenderers { + private fun weekDisplayText(context: Context, status: WeekDisplayStatus): String? = when (status) { + WeekDisplayStatus.NEXT_WEEK -> context.getString(R.string.schedule_next_week) + WeekDisplayStatus.WEEKEND_CURRENT -> context.getString(R.string.schedule_weekend) + WeekDisplayStatus.CURRENT_ENDED -> context.getString(R.string.schedule_week_ended) + WeekDisplayStatus.NORMAL -> null + } + // ── Scheme 颜色(与 WidgetContent.resolveSchemePublic 一致) ── // 死代码清理: cPrimary…cPractice 9 个课程色字段与 surface 字段赋值后从未被渲染消费 // (课程底色走 CourseColorUtil, 背景实际用 bg/surfaceContainer), 已删。 @@ -245,17 +253,25 @@ object WidgetBitmapRenderers { fun todayHeaderParts( data: WidgetData, dayName: String, showDate: Boolean, resolve: (Int) -> String, - showBackToToday: Boolean = true + showBackToToday: Boolean = true, + weekDisplayStatus: WeekDisplayStatus = data.weekDisplayStatus ): TodayHeaderParts { + val statusText = when (weekDisplayStatus) { + WeekDisplayStatus.NEXT_WEEK -> resolve(R.string.schedule_next_week) + WeekDisplayStatus.WEEKEND_CURRENT -> resolve(R.string.schedule_weekend) + WeekDisplayStatus.CURRENT_ENDED -> resolve(R.string.schedule_week_ended) + WeekDisplayStatus.NORMAL -> null + } val title = if (data.isToday) "${resolve(R.string.today_today)} · $dayName" else "${data.dateLabel} · $dayName" + val displayTitle = statusText?.let { "$it · $dayName" } ?: title return if (!data.isToday && showBackToToday) { - TodayHeaderParts(title, resolve(R.string.today_nav_back_to_today), true) + TodayHeaderParts(displayTitle, resolve(R.string.today_nav_back_to_today), true) } else if (showDate && data.isToday) { - TodayHeaderParts(title, data.dateLabel, false) + TodayHeaderParts(displayTitle, data.dateLabel, false) } else { // 导航态的日期已在 title 中;showBackToToday=false 时也不得再画第二份日期。 - TodayHeaderParts(title, null, false) + TodayHeaderParts(displayTitle, null, false) } } @@ -622,6 +638,7 @@ object WidgetBitmapRenderers { var h = 12f // pad (2026-09-14c: 顶部标签行已删) if (!data.hasTable || data.days.isEmpty()) return h + 20f if (data.semesterStatus != DateUtils.SemesterStatus.IN_RANGE) return h + 22f + 14f // 状态 + 提示 + if (data.weekDisplayStatus != WeekDisplayStatus.NORMAL) h += 22f // 最高一列决定整体高度; 每列: 列头(20) + 冲突分行课程 / "无课程"一行 val colH = data.days.maxOf { day -> if (day.courses.isEmpty()) return@maxOf 20f + 16f @@ -653,7 +670,8 @@ object WidgetBitmapRenderers { else data.days.filter { it.dayOfWeek in visibleDays }.sortedBy { it.dayOfWeek } if (shownDays.isEmpty()) return outerPad * 2 + 20f // 学期外状态行: 顶部全宽 +16dp (renderWeekList 学期外段) - val statusH = if (data.semesterStatus != DateUtils.SemesterStatus.IN_RANGE) 16f else 0f + val statusH = if (data.semesterStatus != DateUtils.SemesterStatus.IN_RANGE || + data.weekDisplayStatus != WeekDisplayStatus.NORMAL) 16f else 0f // 最高一列: [状态行] + 标题(12+14) + chip 行(14+6) + 课程行 (16+3)*n val colH = shownDays.maxOf { day -> var cy = statusH + 12f + 14f @@ -685,7 +703,8 @@ object WidgetBitmapRenderers { val shownDays = if (visibleDays.isEmpty()) data.days else data.days.filter { it.dayOfWeek in visibleDays }.sortedBy { it.dayOfWeek } if (shownDays.isEmpty()) return outerPad * 2 + 20f - val statusH = if (data.semesterStatus != DateUtils.SemesterStatus.IN_RANGE) 16f else 0f + val statusH = if (data.semesterStatus != DateUtils.SemesterStatus.IN_RANGE || + data.weekDisplayStatus != WeekDisplayStatus.NORMAL) 16f else 0f val density = context.resources.displayMetrics.density val p = android.graphics.Paint().apply { textSize = 9f * density @@ -805,7 +824,6 @@ object WidgetBitmapRenderers { return bmp.apply { eraseColor(Color.TRANSPARENT); Canvas(this).drawBitmap(c, 0f, 0f, null) } } - val todayDow = LocalDate.now().dayOfWeek.value val colGap = 4f * density val dayCount = shownDays.size val colW = (innerW - colGap * (dayCount - 1)) / dayCount @@ -823,12 +841,22 @@ object WidgetBitmapRenderers { canvas.drawText(statusText, (w - stw) / 2f, outerPad + 10f * density, p) colTop = outerPad + 16f * density } + if (data.semesterStatus == DateUtils.SemesterStatus.IN_RANGE) { + weekDisplayText(SleepyApp.get(), data.weekDisplayStatus)?.let { statusText -> + p.color = s.onSurfaceVariant + p.textSize = 10f * density + p.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + val stw = p.measureText(statusText) + canvas.drawText(statusText, (w - stw) / 2f, outerPad + 10f * density, p) + colTop = outerPad + 16f * density + } + } // 列数随 visibleDays 变化 (原硬编码 7 列) for (i in shownDays.indices) { val day = shownDays[i] val x = outerPad + i * (colW + colGap) - val isToday = day.dayOfWeek == todayDow + val isToday = day.isToday val cardBg = if (isToday) s.primaryContainer else s.surfaceContainer // 列背景 @@ -962,15 +990,20 @@ object WidgetBitmapRenderers { */ fun weekGridMinimumTodayData(data: WeekData, today: LocalDate): WidgetData { val timeJson = data.days.firstOrNull()?.timeJson ?: "" - val todayDay = data.days.firstOrNull { it.dayOfWeek == today.dayOfWeek.value } + val targetDate = if (data.weekDisplayStatus == WeekDisplayStatus.NEXT_WEEK) { + data.days.minByOrNull { it.date }?.date ?: today + } else today + val todayDay = data.days.firstOrNull { it.date == targetDate } return WidgetData( - date = today, + date = targetDate, courses = todayDay?.courses ?: emptyList(), timeJson = timeJson, hasTable = data.hasTable, isDark = data.isDark, themeKey = data.themeKey, - semesterStatus = data.semesterStatus + semesterStatus = data.semesterStatus, + isToday = targetDate == today, + weekDisplayStatus = data.weekDisplayStatus ) } @@ -1071,7 +1104,6 @@ object WidgetBitmapRenderers { return bmp.apply { eraseColor(Color.TRANSPARENT); Canvas(this).drawBitmap(c, 0f, 0f, null) } } - val todayDow = LocalDate.now().dayOfWeek.value val colGap = 4f * density val dayCount = shownDays.size val colW = (innerW - colGap * (dayCount - 1)) / dayCount @@ -1089,12 +1121,22 @@ object WidgetBitmapRenderers { canvas.drawText(statusText, (w - stw) / 2f, outerPad + 10f * density, p) colTop = outerPad + 16f * density } + if (data.semesterStatus == DateUtils.SemesterStatus.IN_RANGE) { + weekDisplayText(SleepyApp.get(), data.weekDisplayStatus)?.let { statusText -> + p.color = s.onSurfaceVariant + p.textSize = 10f * density + p.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + val stw = p.measureText(statusText) + canvas.drawText(statusText, (w - stw) / 2f, outerPad + 10f * density, p) + colTop = outerPad + 16f * density + } + } // 列数随 visibleDays 变化 (原硬编码 7 列) for (i in shownDays.indices) { val day = shownDays[i] val x = outerPad + i * (colW + colGap) - val isToday = day.dayOfWeek == todayDow + val isToday = day.isToday val cardBg = if (isToday) s.primaryContainer else s.surfaceContainer // 列背景 @@ -1262,6 +1304,14 @@ object WidgetBitmapRenderers { return bmp.apply { eraseColor(Color.TRANSPARENT); Canvas(this).drawBitmap(c, 0f, 0f, null) } } + weekDisplayText(ctx, data.weekDisplayStatus)?.let { statusText -> + p.color = s.onSurface + p.textSize = 13f * density + p.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + canvas.drawText(statusText, pad, y + 15f * density, p) + y += 22f * density + } + // 左右两栏: 每天一列, 中间竖直分隔 val colGap = 10f * density val colW = (w - pad * 2 - colGap * (data.days.size - 1)) / data.days.size diff --git a/app/src/main/java/com/lingion/sleepy/widget/WidgetCompactWindow.kt b/app/src/main/java/com/lingion/sleepy/widget/WidgetCompactWindow.kt index 2b75a5e6..6f8d742a 100644 --- a/app/src/main/java/com/lingion/sleepy/widget/WidgetCompactWindow.kt +++ b/app/src/main/java/com/lingion/sleepy/widget/WidgetCompactWindow.kt @@ -21,12 +21,20 @@ internal object WidgetCompactWindow { maxWeek: Int, today: LocalDate, todayFirst: Boolean, - ): List = WidgetBitmapRenderers.compactWindowDates(today, todayFirst).map { date -> - val dow = date.dayOfWeek.value - val week = DateUtils.currentWeek(startDate, date) - val afterEnd = DateUtils.semesterStatus(startDate, maxWeek, date) == DateUtils.SemesterStatus.AFTER_END - val visible = if (afterEnd) emptyList() - else repo.getCoursesByDayOnce(tableId, dow).filter { it.inWeek(week) }.sortedBy { it.startNode } - DayData(date = date, dayOfWeek = dow, courses = visible, timeJson = timeJson) + displayWeek: Int? = null, + ): List { + val dates = if (displayWeek != null) { + (1..3).map { day -> DateUtils.dateOfWeek(startDate, displayWeek, day) } + } else { + WidgetBitmapRenderers.compactWindowDates(today, todayFirst) + } + return dates.map { effectiveDate -> + val dow = effectiveDate.dayOfWeek.value + val week = displayWeek ?: DateUtils.currentWeek(startDate, effectiveDate) + val afterEnd = DateUtils.semesterStatus(startDate, maxWeek, effectiveDate) == DateUtils.SemesterStatus.AFTER_END + val visible = if (afterEnd) emptyList() + else repo.getCoursesByDayOnce(tableId, dow).filter { it.inWeek(week) }.sortedBy { it.startNode } + DayData(date = effectiveDate, dayOfWeek = dow, courses = visible, timeJson = timeJson) + } } } diff --git a/app/src/main/java/com/lingion/sleepy/widget/WidgetContent.kt b/app/src/main/java/com/lingion/sleepy/widget/WidgetContent.kt index 7d788d64..40151439 100644 --- a/app/src/main/java/com/lingion/sleepy/widget/WidgetContent.kt +++ b/app/src/main/java/com/lingion/sleepy/widget/WidgetContent.kt @@ -6,6 +6,7 @@ import com.lingion.sleepy.data.entity.CourseEntity import com.lingion.sleepy.ui.theme.ThemePresets import com.lingion.sleepy.ui.theme.WakeUpColorScheme import com.lingion.sleepy.util.DateUtils +import com.lingion.sleepy.util.WeekDisplayStatus import java.time.LocalDate /** @@ -43,7 +44,9 @@ data class WidgetData( * 默认 true: 既有调用方 (WidgetRenderActivity 预览 / weekGridMinimumTodayData) * 构造的都是今日数据, 不加字段零改动。 */ - val isToday: Boolean = true + val isToday: Boolean = true, + /** 当前展示周状态:下一周/周末返回实际周/本周已结束。 */ + val weekDisplayStatus: WeekDisplayStatus = WeekDisplayStatus.NORMAL ) { val dayName: String get() = DateUtils.localizedDay(date.dayOfWeek.value, com.lingion.sleepy.SleepyApp.get()) val dateLabel: String get() = "${date.monthValue}/${date.dayOfMonth}" @@ -162,7 +165,9 @@ data class WeekData( * (周一「今日居第二位」= 上周日/周一/周二, 各按所在周周次过滤课程)。 * 空 = 数据源未提供, compact 渲染回退旧 weekViewCompactColumns 口径。 */ - val compactWindow: List = emptyList() + val compactWindow: List = emptyList(), + /** 当前展示周状态。 */ + val weekDisplayStatus: WeekDisplayStatus = WeekDisplayStatus.NORMAL ) /** 两天视图数据 */ @@ -172,5 +177,7 @@ data class TwoDayData( val isDark: Boolean = false, val themeKey: String = ThemePresets.KEY_DEFAULT, /** 学期状态(v1.0.37): 学期外时渲染状态文案不渲染课程 */ - val semesterStatus: DateUtils.SemesterStatus = DateUtils.SemesterStatus.IN_RANGE + val semesterStatus: DateUtils.SemesterStatus = DateUtils.SemesterStatus.IN_RANGE, + /** 当前展示周状态。 */ + val weekDisplayStatus: WeekDisplayStatus = WeekDisplayStatus.NORMAL ) diff --git a/app/src/main/java/com/lingion/sleepy/widget/WidgetRenderActivity.kt b/app/src/main/java/com/lingion/sleepy/widget/WidgetRenderActivity.kt index 38016dc7..3f45ff75 100644 --- a/app/src/main/java/com/lingion/sleepy/widget/WidgetRenderActivity.kt +++ b/app/src/main/java/com/lingion/sleepy/widget/WidgetRenderActivity.kt @@ -8,12 +8,6 @@ import android.util.Log import android.view.Gravity import android.widget.FrameLayout import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.TextView -import com.lingion.sleepy.R -import com.lingion.sleepy.SleepyApp -import com.lingion.sleepy.data.entity.TimeTableEntity -import com.lingion.sleepy.util.TimeTableUtils import kotlinx.coroutines.MainScope import kotlinx.coroutines.cancel import kotlinx.coroutines.launch @@ -77,71 +71,25 @@ class WidgetRenderActivity : Activity() { } private suspend fun renderWidgetBitmap(which: String, wDp: Float, hDp: Float): android.graphics.Bitmap { - val isSystemDark = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_YES - val isDark = com.lingion.sleepy.util.AppPrefs.isDarkMode(this, isSystemDark) - val themeKey = com.lingion.sleepy.util.AppPrefs.getThemeKey(this) return when (which) { "today" -> { - val today = java.time.LocalDate.now() - val dayOfWeek = com.lingion.sleepy.util.DateUtils.todayDayOfWeek(today) - val table = WidgetTableResolver.resolveCurrentTable() - val courses = if (table != null) { - val week = com.lingion.sleepy.util.DateUtils.currentWeek(table.startDate, today) - val all = SleepyApp.get().repository.getCoursesByDayOnce(table.id, dayOfWeek) - all.filter { it.inWeek(week) }.sortedBy { it.startNode } - } else emptyList() WidgetBitmapRenderers.renderToday( this, - WidgetData( - date = today, - courses = courses, - timeJson = table?.timeJson ?: TimeTableUtils.DEFAULT_TIME_JSON, - hasTable = table != null, - isDark = isDark, themeKey = themeKey - ), + TodayWidgetReceiver.loadDataSync(this, AppWidgetManager.INVALID_APPWIDGET_ID), wDp, hDp ) } "twoday" -> { - val today = java.time.LocalDate.now() - val tomorrow = today.plusDays(1) - val table = WidgetTableResolver.resolveCurrentTable() - val days = if (table != null) { - val week = com.lingion.sleepy.util.DateUtils.currentWeek(table.startDate, today) - listOf(today, tomorrow).map { date -> - val dow = date.dayOfWeek.value - val all = SleepyApp.get().repository.getCoursesByDayOnce(table.id, dow) - val visible = all.filter { it.inWeek(week) }.sortedBy { it.startNode } - DayData(date = date, dayOfWeek = dow, courses = visible, timeJson = table.timeJson) - } - } else emptyList() WidgetBitmapRenderers.renderTwoDay( this, - TwoDayData( - days = days, hasTable = table != null, - isDark = isDark, themeKey = themeKey - ), + TwoDayWidgetReceiver.loadDataSync(this, AppWidgetManager.INVALID_APPWIDGET_ID), wDp, hDp ) } "weeklist" -> { - val today = java.time.LocalDate.now() - val table = WidgetTableResolver.resolveCurrentTable() - val days = if (table != null) { - val week = com.lingion.sleepy.util.DateUtils.currentWeek(table.startDate, today) - (1..7).map { dow -> - val date = com.lingion.sleepy.util.DateUtils.dateOfWeekDay(today, dow) - val all = SleepyApp.get().repository.getCoursesByDayOnce(table.id, dow) - val visible = all.filter { it.inWeek(week) }.sortedBy { it.startNode } - DayData(date = date, dayOfWeek = dow, courses = visible, timeJson = table.timeJson) - } - } else emptyList() WidgetBitmapRenderers.renderWeekList( this, - WeekData( - days = days, hasTable = table != null, - isDark = isDark, themeKey = themeKey - ), + WeekListWidgetReceiver.loadDataSync(this, AppWidgetManager.INVALID_APPWIDGET_ID), wDp, hDp ) } @@ -174,4 +122,4 @@ class WidgetRenderActivity : Activity() { ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lingion/sleepy/widget/WidgetWeekDataLoader.kt b/app/src/main/java/com/lingion/sleepy/widget/WidgetWeekDataLoader.kt new file mode 100644 index 00000000..223c53f2 --- /dev/null +++ b/app/src/main/java/com/lingion/sleepy/widget/WidgetWeekDataLoader.kt @@ -0,0 +1,46 @@ +package com.lingion.sleepy.widget + +import com.lingion.sleepy.SleepyApp +import com.lingion.sleepy.data.entity.CourseEntity +import com.lingion.sleepy.data.entity.TimeTableEntity +import com.lingion.sleepy.util.AppPrefs +import com.lingion.sleepy.util.DateUtils +import com.lingion.sleepy.util.WeekDisplayContext +import com.lingion.sleepy.util.WeekDisplayResolver +import java.time.LocalDateTime + +/** 小部件统一读取课表并计算展示周,避免各 provider 各自判断周末。 */ +internal data class WidgetWeekSource( + val table: TimeTableEntity, + val courses: List, + val display: WeekDisplayContext +) { + fun coursesFor(dayOfWeek: Int, week: Int = display.displayWeek): List = + courses.asSequence() + .filter { it.day == dayOfWeek && it.inWeek(week) } + .sortedBy { it.startNode } + .toList() + + fun dateFor(dayOfWeek: Int): java.time.LocalDate = + DateUtils.dateOfWeek(table.startDate, display.displayWeek, dayOfWeek) +} + +internal object WidgetWeekDataLoader { + suspend fun resolve(appWidgetId: Int, now: LocalDateTime = LocalDateTime.now()): WidgetWeekSource? { + val app = SleepyApp.get() + val repo = app.repository + val table = WidgetTableResolver.resolveBoundTable(appWidgetId) + ?: WidgetTableResolver.resolveCurrentTable() + ?: return null + val courses = repo.getCourses(table.id) + val display = WeekDisplayResolver.resolve( + startDate = table.startDate, + maxWeek = table.maxWeek, + now = now, + courses = courses, + timeJson = table.timeJson, + enabled = AppPrefs.isAutoNextWeek(app) + ) + return WidgetWeekSource(table, courses, display) + } +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index b71085a8..367865df 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -30,7 +30,10 @@ Share Schedule Share Schedule Press back again to exit - Week %1$d + Week %1$d + Next week + Weekend + This week has ended No classes today Import Timetable/Bell Schedule @@ -327,7 +330,9 @@ Hide evening periods when there are no evening classes all term Evening starts at Adaptive timetable row height - Scale row heights to fit the timetable on screen + Scale row heights to fit the timetable on screen + Show next week when this week ends + Show next week in the app and widgets when no classes remain this week Pinch to resize rows Use a two-finger vertical pinch on the timetable to change row height; disabled when off Lab diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 70c98218..52e9ed40 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -25,7 +25,10 @@ Compartir horario Compartir horario Pulsa atrás de nuevo para salir - Semana %1$d + Semana %1$d + Horario de la próxima semana + Fin de semana + Esta semana ha terminado No hay clases hoy Importar horario/períodos @@ -310,7 +313,9 @@ Oculta los periodos nocturnos si no hay clases nocturnas en todo el semestre La noche comienza a las Altura de fila adaptable del horario - Ajusta la altura de las filas para que el horario quepa en pantalla + Ajusta la altura de las filas para que el horario quepa en pantalla + Mostrar la próxima semana al terminar esta + Muestra la próxima semana en la app y los widgets cuando no queden clases esta semana Pellizco para ajustar filas Pellizco vertical con dos dedos en el horario para cambiar la altura de fila; desactivado si está apagado Laboratorio diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index dd2dce2f..73d8ed80 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -25,7 +25,10 @@ 時間割を共有 時間割を共有 もう一度戻るボタンを押すと終了します - 第 %1$d 週 + 第 %1$d 週 + 来週の時間割 + 週末 + 今週の授業は終了しました 今日は授業がありません 時間割・時程表をインポート @@ -310,7 +313,9 @@ 学期全体に夜間授業がない場合、夜間のコマを自動で隠します 夜間の開始時刻 時間割の行の高さ自動調整 - 画面に収まるよう行の高さを自動調整します + 画面に収まるよう行の高さを自動調整します + 今週終了後に来週を表示 + 今週の授業が残っていないとき、アプリとウィジェットに来週の時間割を表示します ピンチで行の高さを調整 時間割上で2本指の上下ピンチを行の高さ変更に使います。オフでは無効 ラボ diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d0c83b85..e2f52429 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -27,7 +27,10 @@ 分享课表 分享课表 再按一次返回退出 - 第 %1$d 周 + 第 %1$d 周 + 下周课表 + 正在周末 + 本周课表已结束 今天没有课哦 @@ -352,7 +355,9 @@ 整个学期没有晚课时自动收起晚间节次 晚间起始时间 课表自适应行高 - 按可用高度自动缩放行高,优先完整显示课表 + 按可用高度自动缩放行高,优先完整显示课表 + 当周结束自动显示下周 + 当本周没有剩余课程时,主界面和小组件自动显示下周课表 双指捏放调整行高 在课表上用双指上下捏放改变行高;关闭后不可捏放 实验室 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 6bc0e29a..bc4bc8ab 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -25,7 +25,10 @@ 分享課表 分享課表 再按一次返回退出 - 第 %1$d 週 + 第 %1$d 週 + 下週課表 + 正在週末 + 本週課表已結束 今天沒有課 匯入課表/作息表 @@ -310,7 +313,9 @@ 整個學期沒有晚課時自動收起晚間節次 晚間起始時間 課表自適應行高 - 按可用高度自動縮放行高,優先完整顯示課表 + 按可用高度自動縮放行高,優先完整顯示課表 + 本週結束後自動顯示下週 + 本週沒有剩餘課程時,在 App 和小工具自動顯示下週課表 雙指捏放調整行高 在課表上用雙指上下捏放改變行高;關閉後不可捏放 實驗室 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0fcec924..748372eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -31,7 +31,10 @@ 添加课程 分享课表 再按一次返回退出 - 第 %1$d 周 + 第 %1$d 周 + 下周课表 + 正在周末 + 本周课表已结束 今天没有课哦 @@ -363,7 +366,9 @@ 整个学期没有晚课时自动收起晚间节次 晚间起始时间 课表自适应行高 - 按可用高度自动缩放行高,优先完整显示课表 + 按可用高度自动缩放行高,优先完整显示课表 + 当周结束自动显示下周 + 当本周没有剩余课程时,主界面和小组件自动显示下周课表 双指捏放调整行高 在课表上用双指上下捏放改变行高;关闭后不可捏放 实验室 diff --git a/app/src/test/java/com/lingion/sleepy/util/WeekDisplayResolverTest.kt b/app/src/test/java/com/lingion/sleepy/util/WeekDisplayResolverTest.kt new file mode 100644 index 00000000..87cc28ae --- /dev/null +++ b/app/src/test/java/com/lingion/sleepy/util/WeekDisplayResolverTest.kt @@ -0,0 +1,158 @@ +package com.lingion.sleepy.util + +import com.lingion.sleepy.data.entity.CourseEntity +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDateTime + +/** WeekDisplayResolver 的周末、课程结束和边界行为。 */ +class WeekDisplayResolverTest { + + private val startDate = "2026-09-14" // Monday, week 1 + private val timeJson = TimeTableUtils.DEFAULT_TIME_JSON + + private fun course( + id: Long, + day: Int, + startWeek: Int = 1, + endWeek: Int = 3, + startTime: String = "08:00", + endTime: String = "09:00" + ) = CourseEntity( + id = id, + groupId = "g$id", + tableId = 1, + courseName = "课程$id", + day = day, + startNode = 1, + step = 1, + startWeek = startWeek, + endWeek = endWeek, + type = 0, + color = "", + ownTime = true, + isIrregularTime = true, + startTime = startTime, + endTime = endTime + ) + + private fun resolve( + dateTime: String, + courses: List = emptyList(), + maxWeek: Int = 3, + enabled: Boolean = true + ) = WeekDisplayResolver.resolve( + startDate = startDate, + maxWeek = maxWeek, + now = LocalDateTime.parse(dateTime), + courses = courses, + timeJson = timeJson, + enabled = enabled + ) + + @Test + fun `disabled keeps actual week even when current week has ended`() { + val result = resolve("2026-09-19T23:00", enabled = false) + + assertEquals(1, result.actualWeek) + assertEquals(1, result.displayWeek) + assertEquals(WeekDisplayStatus.NORMAL, result.status) + assertFalse(result.enabled) + } + + @Test + fun `saturday course prevents switching to next week`() { + val result = resolve( + "2026-09-19T07:30", + courses = listOf(course(1, day = 6, startTime = "08:00", endTime = "09:00")) + ) + + assertEquals(1, result.displayWeek) + assertEquals(WeekDisplayStatus.NORMAL, result.status) + assertFalse(result.actualWeekEnded) + } + + @Test + fun `saturday without class does not switch if sunday still has class`() { + val result = resolve( + "2026-09-19T12:00", + courses = listOf(course(2, day = 7, startTime = "08:00", endTime = "09:00")) + ) + + assertEquals(1, result.displayWeek) + assertEquals(WeekDisplayStatus.NORMAL, result.status) + assertFalse(result.actualWeekEnded) + } + + @Test + fun `sunday before class ends remains on current week`() { + val result = resolve( + "2026-09-20T08:30", + courses = listOf(course(3, day = 7, startTime = "08:00", endTime = "10:00")) + ) + + assertEquals(1, result.displayWeek) + assertEquals(WeekDisplayStatus.NORMAL, result.status) + } + + @Test + fun `sunday after last class switches to next week`() { + val result = resolve( + "2026-09-20T10:01", + courses = listOf(course(4, day = 7, startTime = "08:00", endTime = "10:00")) + ) + + assertEquals(2, result.displayWeek) + assertEquals(WeekDisplayStatus.NEXT_WEEK, result.status) + assertTrue(result.actualWeekEnded) + } + + @Test + fun `weekday after last class switches to next week`() { + val result = resolve( + "2026-09-18T10:01", + courses = listOf(course(5, day = 5, startTime = "08:00", endTime = "10:00")) + ) + + assertEquals(2, result.displayWeek) + assertEquals(WeekDisplayStatus.NEXT_WEEK, result.status) + } + + @Test + fun `invalid class time is treated as remaining`() { + val result = resolve( + "2026-09-19T23:00", + courses = listOf(course(6, day = 6, startTime = "not-a-time", endTime = "also-invalid")) + ) + + assertEquals(1, result.displayWeek) + assertEquals(WeekDisplayStatus.NORMAL, result.status) + assertFalse(result.actualWeekEnded) + } + + @Test + fun `last semester week never advances beyond max week`() { + val result = resolve( + "2026-09-20T23:00", + courses = emptyList(), + maxWeek = 1 + ) + + assertEquals(1, result.actualWeek) + assertEquals(1, result.displayWeek) + assertEquals(WeekDisplayStatus.WEEKEND_CURRENT, result.status) + } + + @Test + fun `course outside current week is ignored`() { + val result = resolve( + "2026-09-19T23:00", + courses = listOf(course(7, day = 6, startWeek = 2, endWeek = 3)) + ) + + assertEquals(2, result.displayWeek) + assertEquals(WeekDisplayStatus.NEXT_WEEK, result.status) + } +} diff --git a/app/src/test/java/com/lingion/sleepy/widget/WeekDisplayWidgetContractTest.kt b/app/src/test/java/com/lingion/sleepy/widget/WeekDisplayWidgetContractTest.kt new file mode 100644 index 00000000..8cc1a099 --- /dev/null +++ b/app/src/test/java/com/lingion/sleepy/widget/WeekDisplayWidgetContractTest.kt @@ -0,0 +1,106 @@ +package com.lingion.sleepy.widget + +import com.lingion.sleepy.R +import com.lingion.sleepy.data.entity.CourseEntity +import com.lingion.sleepy.util.AppPrefs +import com.lingion.sleepy.util.WeekDisplayStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate + +/** 主界面/小组件共用展示周契约的纯 JVM 覆盖。 */ +class WeekDisplayWidgetContractTest { + + private fun course(day: Int, name: String) = CourseEntity( + id = day.toLong(), + groupId = "g$day", + tableId = 1, + courseName = name, + day = day, + startNode = 1, + step = 1, + startWeek = 2, + endWeek = 3, + color = "" + ) + + @Test + fun `auto next week is opt in by default`() { + assertFalse(AppPrefs.DEFAULT_AUTO_NEXT_WEEK) + } + + @Test + fun `manual return to actual week is labelled weekend`() { + val context = com.lingion.sleepy.util.WeekDisplayResolver.resolve( + startDate = "2026-09-14", + maxWeek = 3, + now = java.time.LocalDateTime.parse("2026-09-19T23:00"), + courses = emptyList(), + timeJson = com.lingion.sleepy.util.TimeTableUtils.DEFAULT_TIME_JSON, + enabled = true + ) + + assertEquals(WeekDisplayStatus.NEXT_WEEK, context.status) + assertEquals( + WeekDisplayStatus.WEEKEND_CURRENT, + com.lingion.sleepy.util.WeekDisplayResolver.statusForSelectedWeek(context, context.actualWeek) + ) + assertEquals( + WeekDisplayStatus.NEXT_WEEK, + com.lingion.sleepy.util.WeekDisplayResolver.statusForSelectedWeek(context, context.displayWeek) + ) + } + + @Test + fun `small grid mode uses next monday data instead of today's weekday`() { + val monday = LocalDate.of(2026, 9, 21) + val data = WeekData( + days = listOf( + DayData(monday, 1, listOf(course(1, "下周高数")), ""), + DayData(monday.plusDays(1), 2, emptyList(), "") + ), + hasTable = true, + weekDisplayStatus = WeekDisplayStatus.NEXT_WEEK + ) + + val result = WidgetBitmapRenderers.weekGridMinimumTodayData( + data, + today = LocalDate.of(2026, 9, 20) + ) + + assertEquals(monday, result.date) + assertEquals(listOf("下周高数"), result.courses.map { it.courseName }) + assertFalse(result.isToday) + assertEquals(WeekDisplayStatus.NEXT_WEEK, result.weekDisplayStatus) + } + + @Test + fun `today header gives next week an actionable status title`() { + val data = WidgetData( + date = LocalDate.of(2026, 9, 21), + courses = emptyList(), + timeJson = "", + hasTable = true, + isToday = false, + weekDisplayStatus = WeekDisplayStatus.NEXT_WEEK + ) + val parts = WidgetBitmapRenderers.todayHeaderParts( + data = data, + dayName = "周一", + showDate = true, + resolve = { id -> + when (id) { + R.string.schedule_next_week -> "下周课表" + R.string.today_nav_back_to_today -> "回到今天" + else -> "其他" + } + } + ) + + assertTrue(parts.title.startsWith("下周课表")) + assertEquals("回到今天", parts.rightText) + assertTrue(parts.rightIsAction) + } +}