-
Notifications
You must be signed in to change notification settings - Fork 8
Course Model
一句话 TL;DR:课程和课表在 Room 数据库里怎么存、每个字段什么意思、怎么变成屏幕上的卡片。给想改数据层或写导入导出的开发者看。
本文基于 v1.0.54(versionCode 58,app/build.gradle.kts:16)。路径均相对仓库根。
数据库只有两个实体,schema 版本 6(app/src/main/java/com/lingion/sleepy/data/AppDatabase.kt:13)。第三个数据类 SmartPeriodConfig 不建表,以 JSON 字符串存在课表行里。
time_tables(课表/学期)
id ──────────────┐
│ 外键 onDelete = CASCADE
▼
courses(课程行) ── 同一门课的每个时间段各占一行,靠 groupId 归组
courses 建了三个索引:tableId、day、(startWeek, endWeek)(data/entity/CourseEntity.kt:15)。删课表时,课程行由外键级联删掉(data/entity/CourseEntity.kt:21)。
schema 演进有条硬规矩:禁用 fallbackToDestructiveMigration,改 schema 先在 data/Migrations.kt 登记迁移再升 version(data/AppDatabase.kt:36)。现存迁移三条:3→4、4→5、5→6(data/Migrations.kt:48)。
CourseEntity 的骨架与 WakeUp 原版 CourseBean 兼容(type / day / startNode / step / startWeek / endWeek / color / tableId,data/entity/CourseEntity.kt:10),在此之上加了 teacher / room / note / alias 等显示字段。从 WakeUp 迁数据可以少踩一轮坑。
源码:data/entity/TimeTableEntity.kt:12,表名 time_tables。
| 字段 | 类型 | 默认值 | 语义 |
|---|---|---|---|
id |
Long | 自增 | 主键,课程行的 tableId 指向它 |
name |
String | 必填 | 课表名 |
startDate |
String | 必填 | 学期开始日期 yyyy-MM-dd,当前周次由它算出 |
maxWeek |
Int | 20 | 学期总周数,翻周上限 |
nodesPerDay |
Int | 12 | 一天的节次数 |
timeJson |
String | DEFAULT_TIME_JSON |
每节课的起止时间 JSON,见下节 |
color |
String | #FF6750A4 |
课表颜色主题 |
isDefault |
Boolean | false | 是否默认课表 |
smartConfigJson |
String | "" |
智慧节次配置 JSON,空串 = 手动模式(data/entity/TimeTableEntity.kt:41) |
createdAt |
Long | 当前毫秒 | 创建时间,列表按 createdAt DESC 排序返回(data/dao/TimeTableDao.kt:37) |
当前周次由 DateUtils.currentWeek(startDate) 算,startDate 归一到所在周周一后按 7 天取商加 1,学期开始前钳制为 1(util/DateUtils.kt:35);学期内外的判定走 DateUtils.semesterStatus(startDate, maxWeek),超过 maxWeek 判为学期已结束(util/DateUtils.kt:20)。
格式是 JSON 数组,每节一项:[{"node":1,"start":"08:00","end":"08:45"}, ...](util/TimeTableUtils.kt:13)。
默认值 DEFAULT_TIME_JSON 是 12 节 / 45-50 分钟作息,从第 1 节 08:00-08:45 到第 12 节 21:45-22:30(util/TimeTableUtils.kt:27)。它是 timeJson 的唯一权威默认值,实体默认构造、解析、UI 渲染都从这里走(util/TimeTableUtils.kt:22)。
围绕 timeJson 的工具函数都在 util/TimeTableUtils.kt:
-
timeSlotsFor(timeJson)— JSON 转每节一行的 TimeSlot UI 模型(util/TimeTableUtils.kt:63) -
courseTimeParts(startNode, step, timeJson, ...)— 课程起止节换成起止时间,找不到节点返回 null(util/TimeTableUtils.kt:91) -
timeToNode(startTime, endTime, timeJson)— 反方向,自定义时间反算等效节次(util/TimeTableUtils.kt:120) -
parseTimeSlotRows/buildTimeJsonFromRows— 编辑器行模型与 JSON 互转(util/TimeTableUtils.kt:300、util/TimeTableUtils.kt:316)
编辑用的行模型 TimeSlotRow(node, start, end, edgeClass),删除某节后节点重新编号成连续的 1..N(util/TimeTableUtils.kt:291、util/TimeTableUtils.kt:340)。
不想手动逐节填时间,可以切「自动模式」:只给每节时长、总节数、首节开始时间和课间规则,整张作息表推导出来。配置类是 @Serializable data class SmartPeriodConfig(data/entity/SmartPeriodConfig.kt:23),不建表,序列化后存进 TimeTableEntity.smartConfigJson(data/entity/TimeTableEntity.kt:41)。
| 字段 | 类型 | 默认值 | 语义 |
|---|---|---|---|
startTime |
String | "08:00" |
第一节开始时间 HH:mm |
periodMinutes |
Int | 45 | 每节时长(分钟) |
totalPeriods |
Int | 12 | 总节数 N |
breaks |
List<BreakOption> | 空 | 课间模板列表,每项一个分组 |
transitionAssignments |
List<Int?> | 空 | 每个课间选哪个 break 索引,长度 = N−1,null 表示 0 分钟连排(data/entity/SmartPeriodConfig.kt:16) |
推导公式:第 i 节开始时间 = startTime + i × periodMinutes + Σ第 i 节之前的课间(data/entity/SmartPeriodConfig.kt:19)。
两个保护方法:
-
effectiveAssignments()— 截断/补齐到totalPeriods - 1长,越界索引归 null(data/entity/SmartPeriodConfig.kt:35) -
derive()— 按公式生成全部 N 节的TimeSlotRow列表,课间分钟数累加进时钟(data/entity/SmartPeriodConfig.kt:57)
课间模板 BreakOption(minutes, isLong, label):isLong 只影响大小课间的颜色和标签展示,label 可自定义名,缺省显示「小课间 X 分钟」或「大课间 X 分钟」(data/entity/SmartPeriodConfig.kt:92、data/entity/SmartPeriodConfig.kt:97)。
读写路径:编辑课表页用 kotlinx.serialization 做往返——打开时 Json.decodeFromString<SmartPeriodConfig>(table.smartConfigJson),失败则从现有行数推断初值;保存时 Json.encodeToString 写回(ui/screen/mine/EditTableScreen.kt:123、ui/screen/mine/EditTableScreen.kt:283)。导入确认弹层也会从解析出的作息行播种一个 SmartPeriodConfig 初值,避免切自动模式被默认值覆盖(ui/screen/imports/ImportSheet.kt:1124)。
源码:data/entity/CourseEntity.kt:25,表名 courses,共 23 个字段。
同一门课的每个时间段各占一行——周一 1-2 节一行,周三 3-4 节另一行——靠 groupId 归成一个组。编辑和删除按组操作,DAO 提供 deleteByGroupId(tableId, groupId)(data/entity/CourseEntity.kt:28、data/dao/CourseDao.kt:45)。列表查询按 day, startNode, startWeek 排序返回(data/dao/CourseDao.kt:59)。
| 字段 | 类型 | 默认值 | 语义 |
|---|---|---|---|
id |
Long | 自增 | 主键 |
groupId |
String | 必填 | 课程组 ID,同门课所有节次共享 |
tableId |
Long | 必填 | 所属课表 id,外键 |
courseName |
String | 必填 | 课程名(身份场景永远用它) |
teacher |
String | "" |
教师 |
room |
String | "" |
教室 |
note |
String | "" |
备注 |
alias |
String | "" |
课程别名,仅展示场景用,见下 |
day |
Int | 必填 | 周几,1-7,周一=1(data/entity/CourseEntity.kt:54) |
startNode |
Int | 必填 | 开始节次,1-based(data/entity/CourseEntity.kt:57) |
step |
Int | 必填 | 持续节数,结束节 = startNode + step − 1(data/entity/CourseEntity.kt:142) |
startWeek |
Int | 必填 | 起始周 |
endWeek |
Int | 必填 | 结束周 |
type |
Int | 0 | 周次类型,见下节 |
color |
String | 必填 | ARGB 十六进制,如 #FF6750A4(data/entity/CourseEntity.kt:77) |
colorMode |
Int | 0 | 颜色模式 0/1/2,见「颜色三态」(data/entity/CourseEntity.kt:94) |
ownTime |
Boolean | false | 是否用户自定义时间,与 isIrregularTime 恒同值(data/entity/CourseEntity.kt:100) |
isIrregularNode |
Boolean | false | 本卡片绑定边缘节次槽位(0/-1/N+1),网格位置 = 槽位编号本身(data/entity/CourseEntity.kt:106) |
isIrregularTime |
Boolean | false | 本卡片 startTime/endTime 是覆盖值,不受槽位默认时间窗约束(data/entity/CourseEntity.kt:112) |
startTime |
String | "" |
自定义开始时间 HH:mm,仅自定义时间时使用(data/entity/CourseEntity.kt:115) |
endTime |
String | "" |
自定义结束时间 HH:mm |
credit |
Float | 0f | 学分,保留字段,随 schema 兼容存在 |
level |
Int | 0 | 同上 |
alias 的边界要记牢:组级属性,同 groupId 所有行共享,编辑保存时整组覆盖;空串 = 处处显示原名。只有周视图、网格视图、桌面组件这类「展示」场景按各自设置取用,详情页、导入预览、通知、导出一律原名(data/entity/CourseEntity.kt:46)。
四个周次字段合起来表达「哪些周上这门课」:
type = 0 每周 [startWeek, endWeek] 区间内每周都上
type = 1 单周 区间内只上奇数周(第 1/3/5... 周)
type = 2 双周 区间内只上偶数周(第 2/4/6... 周)
type = 3 按周次列 区间内都上;解析器把区间收紧到实际列出的周,
用于「只在某一两周上」的单次实验课
(离散周「11,13,15」也是解析器先展开成一行一个小区间;实体只按区间+type 判定。)
判定的唯一入口是 inWeek(week)(data/entity/CourseEntity.kt:125):
fun inWeek(week: Int): Boolean {
if (week < startWeek || week > endWeek) return false
return when (type) {
0 -> true
1 -> week % 2 == 1 // 单周 = 奇数周号
2 -> week % 2 == 0 // 双周 = 偶数周号
3 -> true // 区间即周次列
else -> true // 旧数据非法 type,不丢课
}
}两个细节:单双周按绝对周号的奇偶判定,与课表起始周无关;教务解析器遇到类型列缺失或不明时默认填 3 而非 0,避免把「周次=6 的单次实验」误存成「每周都上」(data/entity/CourseEntity.kt:70、data/parser/ScheduleParser.kt:1124)。
colorMode 常量定义在 CourseColorMode(GROUP=0 / AUTO=1 / CUSTOM=2,data/entity/CourseEntity.kt:174),决定 color 字段怎么被解读(data/entity/CourseEntity.kt:79):
-
GROUP(0,默认):整门课共享组色。渲染时先看
color是否为用户自定义——判定标准是「非空且不等于哨兵值#FF6750A4」(util/CourseColorUtil.kt:64);没自定义就用黄金角 137.508° 按groupId哈希撒色相,同门课永远同色(util/CourseColorUtil.kt:53)。 -
AUTO(1):
color字段无意义,渲染时实时算。行级入口按「组色源的色相 + 行序号 × 137.508°」推进,组色源 = 同组内 colorMode=GROUP 中 id 最小的行(util/CourseColorUtil.kt:298、util/CourseColorUtil.kt:272)。 -
CUSTOM(2):
color存用户选定的十六进制,直接解析使用(util/CourseColorUtil.kt:217)。
取色入口分两族,同一份逻辑两套返回类型(Compose Color / Canvas Int):pickCourseColorCompose / pickCourseColorInt 是整门课一个色的组级入口;pickCourseColorComposeWithGroupRows / pickCourseColorIntWithGroupRows 是三态行级入口,每个 block 独立取色(util/CourseColorUtil.kt:159、util/CourseColorUtil.kt:209)。所有入口都接受 colorless 开关,开了返回调用方传入的中性色(surfaceVariant,util/CourseColorUtil.kt:157)。
色相种子必须是 groupId 而非自增 id——id 随导入顺序漂移,groupId 才是课程身份(util/CourseColorUtil.kt:51)。
ownTime=true 且 startTime/endTime 非空的课,时间就是用户写死的 HH:mm,不再看节次表。渲染进网格前要过一道 normalizeNode(timeJson):调 TimeTableUtils.timeToNode 反算等效的 startNode/step,让卡片落在正确格子里;反算不出就原样返回(data/entity/CourseEntity.kt:163)。
timeToNode 的归位规则(util/TimeTableUtils.kt:106,实现在 120):
- startNode = 时间表中 start ≤ 课程开始时间的最大节点(向下取)
- endNode 从 startNode 起沿节点序连续延伸,课程在节间空隙内结束时停在空隙前那一节,不跨空隙吸附下一节
- 早于第一节用第 1 节,晚于最后一节用最后一节;解析失败返回 null
勾了「非常规节次」(isIrregularNode=true)的卡片跳过这道重映射,网格位置 = 槽位编号本身(data/entity/CourseEntity.kt:165)。
时间文案有两个本地化方法:nodeString(context) 走 course_node_format 字符串(「第%1$s节」,输出如「第3-4节」),自定义时间则直接给「18:30-20:55」;shortNodeString(context) 走 course_period_range(%1$d-%2$d节,输出如「3-4节」)。两者的 KDoc 都标了「推荐 UI 使用」(data/entity/CourseEntity.kt:137、data/entity/CourseEntity.kt:148;app/src/main/res/values/strings.xml:459)。
新建课程的默认行:groupId 为新生成 UUID,courseName 取「新课程」字符串资源,day = 今天,startNode = 1,step = 1,startWeek = 当前周,endWeek = 当前周 + 16,color = "#FF6750A4"(ui/screen/schedule/ScheduleViewModel.kt:272)。
几个典型行:
高等数学, 周一 1-2 节, 1-16 周每周:
day=1 startNode=1 step=2 startWeek=1 endWeek=16 type=0
大学英语, 周二 3-4 节, 1-16 周单周:
day=2 startNode=3 step=2 startWeek=1 endWeek=16 type=1
第 6 周一次的单次实验课:
startWeek=6 endWeek=6 type=3
自定义时间课(18:30-20:55):
ownTime=true startTime="18:30" endTime="20:55"
WakeUp 分享 JSON 里的同款字段叫法略有差异:教室是 position(data/parser/ScheduleParser.kt:19)。导出沿用这套字段名(data/parser/ScheduleExporter.kt:32)。
全部插入走 OnConflictStrategy.REPLACE,更新走 @Update 按主键覆盖。按能力分组:
- 写:
insert/insertAll(REPLACE 冲突策略);update/updateAll(批量更新,保留各行 id);insertKeepId(按已有 id 覆盖写入,REPLACE 冲突策略)(data/dao/CourseDao.kt:15、data/dao/CourseDao.kt:21、data/dao/CourseDao.kt:32) - 删:
deleteById/deleteByIds/deleteByTableId/deleteByGroupId/deleteAll(data/dao/CourseDao.kt:36-data/dao/CourseDao.kt:50) - 一次性读:
getById、getByTable、getByTableAndDayOnce、getByGroupId、getAll、countByTable、totalCount - 响应式读:
observeByTable返回Flow<List<CourseEntity>>,课程一变就重新发射,UI 自动刷新(data/dao/CourseDao.kt:59);observeByTableAndDay同理按天过滤 - 排序契约:
observeByTable/getByTable按day, startNode, startWeek升序;按天查询按startNode升序(data/dao/CourseDao.kt:62) - 两个原子事务:
replaceAll(tableId, courses)= 删该表全部课程再插入,整表导入用;replaceGroup(tableId, groupId, newCourses)= 删同组再插入,编辑课程组用(data/dao/CourseDao.kt:81、data/dao/CourseDao.kt:88)
updateAll / insertKeepId / deleteByIds 三个方法专门服务编辑页的行级 diff 写回(仓库层 applyDiff 驱动):只动差异行,不重写整组(data/repository/ScheduleRepository.kt:281)。
标准增删改查:insert / insertAll / update / deleteById / deleteAll / getById / observeById / getAll / count。两个特殊的:
-
observeAll按createdAt DESC返回,新建的表排最前(data/dao/TimeTableDao.kt:37) -
setDefault(id)用一条 SQL 把全库isDefault重排——UPDATE time_tables SET isDefault = (id = :id),保证任何时刻至多一行是默认表(data/dao/TimeTableDao.kt:46);getDefault取那一行(data/dao/TimeTableDao.kt:49)
这层没有独立 DTO。UI 直接消费 CourseEntity:ScheduleState 持有 List<CourseEntity>(ui/screen/schedule/ScheduleViewModel.kt:24),网格组件的签名也是 courses: List<CourseEntity>(ui/component/CourseTableView.kt:105)。「转换」分散成三步,每步都有明确归属:
DB 行 (CourseEntity)
│ ① inWeek(week) 过滤本周 实体自带方法
│ ② normalizeNode(timeJson) 归位 实体自带方法,委托 TimeTableUtils.timeToNode
▼
本周课程列表
│ ③ 渲染时按需取:
│ 颜色 → CourseColorUtil(三态决策树)
│ 名字 → CourseDisplayUtil.displayName(alias 开关)
│ 时间 → CourseEntity.nodeString / TimeTableUtils.courseTimeParts
│ 节次表 → TimeTableUtils.timeSlotsFor(timeJson) → TimeSlot
▼
屏幕上的卡片
第①②步的典型现场:ScheduleViewModel.currentWeekCourses 先 filter { it.inWeek(selectedWeek) } 再 map { c -> c.normalizeNode(tj) }(ui/screen/schedule/ScheduleViewModel.kt:35);同样的组合出现在周视图分页(ui/screen/schedule/ScheduleScreen.kt:236)、今日页(ui/screen/today/TodayScreen.kt:70)、详情弹层(ui/component/CourseDetailSheet.kt:84)和各桌面组件(widget/WeekViewWidget.kt:100 等)。
唯一的例外是时间栏:数据来自 timeJson 而非课程行,由 timeSlotsFor 转成 UI 模型 TimeSlot(label, start, end, displayStart, displayEnd, nodeStart, nodeEnd)(ui/component/CourseTableView.kt:69)。跨节次空隙的渲染占位行 label 为空(isPlaceholder),只显示时间,绝不写回 timeJson(ui/component/CourseTableView.kt:86)。
别名解析的归属也单一:CourseDisplayUtil.displayName(course, useAlias)——开关关了给原名,开了取 trim 后的别名,空别名回退原名(util/CourseDisplayUtil.kt:18)。
两个周次区间(含单双周类型)有没有公共上课周,判定函数是顶层函数 weekRangesOverlap(util/WeekRangeOverlap.kt:5):
fun weekRangesOverlap(
aStart: Int, aEnd: Int, aType: Int,
bStart: Int, bEnd: Int, bType: Int
): Boolean算法分两步(util/WeekRangeOverlap.kt:9):
- 取区间交集
[max(aStart, bStart), min(aEnd, bEnd)],交集为空直接 false - 交集中的每个周,双方各自按 type 命中(1=奇数周、2=偶数周、0/3 及其他=区间内皆命中),存在一个周双双命中即 true
type 编码与 CourseEntity.inWeek 完全一致,奇偶同样按绝对周号判定(util/WeekRangeOverlap.kt:4)。
当前唯一调用点在编辑页的冲突校验:两节课先比星期几交集、再比分钟区间重叠,都撞上了才调 weekRangesOverlap——周次不重叠就不算冲突,一门单周配一门双周,时间撞了也放行(ui/screen/edit/AddCourseScreen.kt:879)。
Sleepy Wiki
入门
界面与视图
课程管理
导入导出
小组件与提醒
数据与设置
项目
社区