feat: deliver Riverside Schedule seven-day grid and school HTML import

This commit is contained in:
Codex
2026-10-13 11:18:50 +08:00
parent 91a2522043
commit 8403be05da
29 changed files with 1246 additions and 446 deletions
@@ -40,6 +40,7 @@ class _ImportPreviewState extends State<ImportPreview> {
'${draft.courses.length} 条课程安排 · ${draft.source == CourseSource.demo ? '示例体验' : '导入预览'}',
style: const TextStyle(color: river),
),
if (draft.semesterLabel != null) Text('学期:${draft.semesterLabel}'),
const SizedBox(height: 18),
if (empty)
Text(draft.explicitlyEmpty ? '此操作会清空当前课表。' : '没有识别到课程,当前课表保持不变。'),
+10 -25
View File
@@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_theme.dart';
import '../../data/json_schedule_import.dart';
import '../../data/schedule_file_import.dart';
import 'settings_page.dart';
import 'import_preview.dart';
@@ -26,7 +26,7 @@ class ProfilePage extends StatelessWidget {
try {
final file = await FilePicker.pickFile(
type: FileType.custom,
allowedExtensions: ['json'],
allowedExtensions: ['json', 'html', 'htm'],
);
if (file == null) return;
final bytes = BytesBuilder(copy: false);
@@ -38,7 +38,7 @@ class ProfilePage extends StatelessWidget {
}
bytes.add(chunk);
}
final draft = JsonScheduleImport.parse(utf8.decode(bytes.takeBytes()));
final draft = ScheduleFileImport.parse(utf8.decode(bytes.takeBytes()));
if (!context.mounted) return;
await showModalBottomSheet<bool>(
context: context,
@@ -61,31 +61,16 @@ class ProfilePage extends StatelessWidget {
return ListView(
padding: EdgeInsets.fromLTRB(
22,
26,
10,
22,
145 + MediaQuery.paddingOf(context).bottom,
),
children: [
Text(
'YOUR LITTLE RIVERSIDE',
style: TextStyle(
fontSize: 10,
letterSpacing: 1.8,
color: river.withValues(alpha: 0.75),
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
const Text(
'我的河畔',
style: TextStyle(fontSize: 29, fontWeight: FontWeight.w700),
'Riverside Schedule',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
),
const SizedBox(height: 7),
Text(
'把时间,留给真正重要的事。',
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: 0.5)),
),
const SizedBox(height: 26),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -207,14 +192,14 @@ class ProfilePage extends StatelessWidget {
_tile(
Icons.file_download_outlined,
'从文件导入',
'先预览,再替换课表',
'学校 HTML / JSON · 先预览再导入',
() => _import(context),
),
Divider(height: 1, color: ink.withValues(alpha: 0.06)),
_tile(
Icons.auto_awesome_outlined,
'体验示例课表',
'无需登录,看看河畔的样子',
'无需登录,预览课表样式',
controller.showDemo,
),
],
@@ -225,7 +210,7 @@ class ProfilePage extends StatelessWidget {
child: Column(
children: [
const Text(
'河畔Everyday',
'Riverside Schedule',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(height: 6),
@@ -78,7 +78,7 @@ class _SettingsPageState extends State<SettingsPage> {
context: context,
builder: (context) => AlertDialog(
title: const Text('关闭日历同步'),
content: const Text('是否删除河畔创建的课程日程?其他日程不会受影响。'),
content: const Text('是否删除Riverside Schedule 创建的课程日程?其他日程不会受影响。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
@@ -92,7 +92,7 @@ class _SchoolAuthPageState extends State<SchoolAuthPage> {
),
actions: [
IconButton(
tooltip: _showWeb ? '河畔登录界面' : '学校页面',
tooltip: _showWeb ? '应用登录界面' : '学校页面',
onPressed: () => setState(() => _showWeb = !_showWeb),
icon: Icon(
_showWeb ? Icons.person_outline : Icons.language_rounded,
@@ -195,7 +195,7 @@ class _SchoolAuthPageState extends State<SchoolAuthPage> {
),
const SizedBox(height: 20),
Text(
'密码仅交给学校处理,不在河畔保存。需要验证码或其他验证时,将由学校页面继续处理。',
'密码仅交给学校处理,不在应用中保存。需要验证码或其他验证时,将由学校页面继续处理。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12,
@@ -12,7 +12,10 @@ class SchoolNavigationPolicy {
static bool isTimetable(Uri uri) =>
_https(uri) &&
uri.host == 'yjsjy.uestc.edu.cn' &&
uri.path == '/pyxx/pygl/xskbcx/index/1';
RegExp(
r'^/pyxx/pygl/xskbcx/index/[1-9]\d*'
r'$',
).hasMatch(uri.path);
static bool isSchoolPage(Uri uri) =>
_https(uri) &&
const {'idas.uestc.edu.cn', 'yjsjy.uestc.edu.cn'}.contains(uri.host);
@@ -1,16 +1,177 @@
import 'dart:convert';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as html;
import '../../data/json_schedule_import.dart';
import '../../domain/course.dart';
import 'school_navigation_policy.dart';
class UnsupportedSchoolLayout implements Exception {
@override
String toString() => '学校课表识别待联调,当前课表保持不变。';
String toString() => '未识别到完整的学校课表,当前课表保持不变。请在课表查询页面保存 HTML 后重试。';
}
/// Reads the supplied graduate-system table as data; never executes its scripts.
class SchoolPageAdapter {
ImportDraft extract(String html, Uri source) {
// A real authenticated timetable fixture is required before registering a parser.
// Unknown HTML must never erase the student's last valid imported timetable.
throw UnsupportedSchoolLayout();
ImportDraft extract(
String content,
Uri origin, {
CourseSource source = CourseSource.school,
}) {
try {
if (!SchoolNavigationPolicy.isTimetable(origin) ||
source == CourseSource.demo ||
utf8.encode(content).length > 2 * 1024 * 1024) {
throw UnsupportedSchoolLayout();
}
final document = html.parse(content);
final table = document.querySelector('#tbl');
final selected = document.querySelector('#drpxq option[selected]');
final term = selected?.attributes['value'];
if (table == null ||
term == null ||
!RegExp(r'^\d+$').hasMatch(term) ||
selected!.text.trim().isEmpty) {
throw UnsupportedSchoolLayout();
}
final rows = table.querySelectorAll('tr');
const weekdays = ['节次', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六', '星期日'];
if (rows.length != 6 || rows.first.children.length != 8) {
throw UnsupportedSchoolLayout();
}
for (var i = 0; i < 8; i++) {
if (rows.first.children[i].text.trim() != weekdays[i]) {
throw UnsupportedSchoolLayout();
}
}
final courses = <String, Course>{};
final ids = <String>{};
const starts = [1, 3, 5, 7, 9];
for (var row = 0; row < starts.length; row++) {
final first = starts[row], last = first == 9 ? 11 : first + 1;
final cells = rows[row + 1].children;
if (cells.length != 8 ||
cells.first.id != 't0$first' ||
cells.first.text.trim() != '$first-$last节') {
throw UnsupportedSchoolLayout();
}
for (var day = 1; day <= 7; day++) {
final cell = cells[day];
if (cell.id != 't$day$first') throw UnsupportedSchoolLayout();
for (final line in _lines(cell)) {
final record = RegExp(
r'^(\d+)/(.+?)/(\d+(?:\.\d+)?)/(\d+(?:\.\d+)?)/([^/]+)/([^/]+)周/(\d+)[~~-](\d+)节/(.+)$',
).firstMatch(line);
if (record == null ||
int.parse(record[7]!) != first ||
int.parse(record[8]!) != last) {
throw UnsupportedSchoolLayout();
}
final code = record[1]!,
name = record[2]!.trim(),
teacher = record[5]!.trim(),
room = record[9]!.trim();
final weeks = _weeks(record[6]!);
final identity = jsonEncode([
term,
code,
name,
teacher,
room,
day,
first,
last,
]);
final previous = courses[identity];
final id =
previous?.id ??
'uestc-$term-$code-$day-$first-$last-${_hash(identity)}';
if (previous == null && !ids.add(id)) {
throw UnsupportedSchoolLayout();
}
courses[identity] = Course(
id: id,
name: name,
teacher: teacher,
room: room,
weekday: day,
startPeriod: first,
endPeriod: last,
weeks: {...?previous?.weeks, ...weeks},
source: source,
);
}
}
}
// An empty DOM can be an expired session or an incomplete response.
if (courses.isEmpty) throw UnsupportedSchoolLayout();
return ImportDraft(
courses: List.unmodifiable(courses.values),
explicitlyEmpty: false,
source: source,
parserVersion: 'uestc-graduate-html-v1',
semesterLabel: selected.text.trim(),
);
} on UnsupportedSchoolLayout {
rethrow;
} catch (_) {
throw UnsupportedSchoolLayout();
}
}
List<String> _lines(dom.Element cell) {
final buffer = StringBuffer();
void visit(dom.Node node) {
if (node is dom.Text) {
buffer.write(node.text);
} else if (node is dom.Element) {
if (node.localName == 'br') {
buffer.write('\n');
} else {
if (node.localName == 'script' || node.localName == 'style') {
throw UnsupportedSchoolLayout();
}
for (final child in node.nodes) {
visit(child);
}
}
}
}
for (final node in cell.nodes) {
visit(node);
}
return buffer
.toString()
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
}
Set<int> _weeks(String text) {
final result = <int>{};
for (final part in text.split(RegExp('[,,、]'))) {
final range = RegExp(r'^\s*(\d+)(?:\s*[-~~]\s*(\d+))?\s*$')
.firstMatch(part);
if (range == null) throw UnsupportedSchoolLayout();
final first = int.parse(range[1]!),
last = int.parse(range[2] ?? range[1]!);
if (first < 1 || last > 30 || last < first) {
throw UnsupportedSchoolLayout();
}
result.addAll(List.generate(last - first + 1, (i) => first + i));
}
return result;
}
String _hash(String value) {
var hash = 2166136261;
for (final byte in utf8.encode(value)) {
hash = ((hash ^ byte) * 16777619) & 0xffffffff;
}
return hash.toRadixString(16).padLeft(8, '0');
}
}
@@ -18,17 +179,36 @@ class SchoolReader {
SchoolReader(this.adapter);
final SchoolPageAdapter adapter;
Future<ImportDraft>? _pending;
int _generation = 0;
Uri? _origin;
bool get busy => _pending != null;
void invalidate() {
_generation++;
_pending = null;
_origin = null;
}
Future<ImportDraft> read(Uri origin, Future<String> Function() getHtml) {
if (!SchoolNavigationPolicy.isTimetable(origin) ||
(_pending != null && origin != _origin)) {
return Future.error(UnsupportedSchoolLayout());
}
if (_pending != null) return _pending!;
final generation = _generation;
_origin = origin;
_pending = Future<ImportDraft>(() async {
try {
if (!SchoolNavigationPolicy.isTimetable(origin)) {
throw UnsupportedSchoolLayout();
}
return adapter.extract(await getHtml(), origin);
final content = await getHtml();
if (generation != _generation) throw UnsupportedSchoolLayout();
return adapter.extract(content, origin);
} finally {
_pending = null;
if (generation == _generation) {
_pending = null;
_origin = null;
}
}
});
return _pending!;
+17 -4
View File
@@ -35,6 +35,9 @@ class SchoolSession extends ChangeNotifier {
return NavigationDecision.navigate;
},
onPageStarted: (_) {
_pageGeneration++;
_reader.invalidate();
pendingDraft = null;
if (!hasOpened) {
return;
}
@@ -72,6 +75,7 @@ class SchoolSession extends ChangeNotifier {
hasOpened = false,
formReady = false;
bool get busy => _loading || _submitting || _reader.busy;
int _pageGeneration = 0;
ImportDraft? pendingDraft;
String message = '请通过学校统一身份认证登录。';
String get statusLabel => switch (connectionState) {
@@ -79,7 +83,7 @@ class SchoolSession extends ChangeNotifier {
SchoolConnectionState.authenticating => '请在学校页面完成认证',
SchoolConnectionState.connected => '学校认证已确认',
SchoolConnectionState.expired => '学校登录已过期',
SchoolConnectionState.unknown => '学校页面已打开 · 课表识别待联调',
SchoolConnectionState.unknown => '学校页面已打开 · 等待完整课表',
};
static final authUrl = Uri.parse(
'https://idas.uestc.edu.cn/authserver/login',
@@ -134,26 +138,33 @@ class SchoolSession extends ChangeNotifier {
return;
}
_loading = false;
final generation = _pageGeneration;
final uri = Uri.tryParse(url);
if (uri != null && SchoolNavigationPolicy.canInjectCredentials(uri)) {
connectionState = connectionState == SchoolConnectionState.connected
? SchoolConnectionState.expired
: SchoolConnectionState.authenticating;
try {
formReady = _truth(
final ready = _truth(
await web.runJavaScriptReturningResult(_readyScript),
);
if (!hasOpened || generation != _pageGeneration) return;
formReady = ready;
} catch (_) {
if (!hasOpened || generation != _pageGeneration) return;
formReady = false;
}
message = formReady ? '可以使用河畔界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。';
message = formReady ? '可以使用应用界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。';
} else if (uri != null && SchoolNavigationPolicy.isTimetable(uri)) {
connectionState = SchoolConnectionState.unknown;
message = '已打开课表页面,正在核对课程结构。';
try {
pendingDraft = await readTimetable();
final draft = await readTimetable();
if (!hasOpened || generation != _pageGeneration) return;
pendingDraft = draft;
connectionState = SchoolConnectionState.connected;
} catch (error) {
if (!hasOpened || generation != _pageGeneration) return;
message = error is UnsupportedSchoolLayout
? error.toString()
: '课表读取未完成,已有课表保持不变。';
@@ -238,6 +249,8 @@ class SchoolSession extends ChangeNotifier {
}
Future<void> logout() async {
_pageGeneration++;
_reader.invalidate();
hasOpened = false;
await WebViewCookieManager().clearCookies();
await web.clearCache();
@@ -33,57 +33,23 @@ class _TimetablePageState extends State<TimetablePage> {
final sunday = monday.add(const Duration(days: 6));
return ListView(
padding: EdgeInsets.fromLTRB(
18,
20,
18,
8,
10,
8,
140 + MediaQuery.paddingOf(context).bottom,
),
children: [
Text(
'HEPAN / EVERYDAY',
const Text(
'Riverside Schedule',
style: TextStyle(
fontSize: 10,
letterSpacing: 2.2,
color: river.withValues(alpha: 0.8),
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
const SizedBox(height: 9),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Expanded(
child: Text(
'每一天,\n从容一点。',
style: TextStyle(
fontSize: 27,
fontWeight: FontWeight.w700,
height: 1.3,
letterSpacing: -0.6,
),
),
),
Container(
margin: const EdgeInsets.only(top: 5),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Text(
'${today.month} 月 ${today.day} 日',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: river,
),
),
),
],
),
const SizedBox(height: 22),
const SizedBox(height: 10),
SurfaceCard(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
child: Row(
children: [
IconButton(
@@ -126,7 +92,7 @@ class _TimetablePageState extends State<TimetablePage> {
],
),
),
const SizedBox(height: 14),
const SizedBox(height: 4),
if (c.isDemo)
Padding(
padding: const EdgeInsets.only(bottom: 12),
@@ -172,7 +138,7 @@ class _TimetablePageState extends State<TimetablePage> {
),
const SizedBox(height: 12),
Text(
'左右滑动查看整周 · 点击课程查看详情',
'点击课程查看完整信息',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: ink.withValues(alpha: 0.35)),
),
@@ -191,7 +157,7 @@ class _TimetablePageState extends State<TimetablePage> {
),
const SizedBox(height: 12),
Text(
'在「我的」设置学期并导入课程,\n或先看看河畔的示例课表。',
'在「我的」设置学期并导入课程,\n或先看看示例课表。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
+327 -266
View File
@@ -18,213 +18,18 @@ class WeekGrid extends StatelessWidget {
final List<CourseOccurrence> events;
final DateTime monday, today;
final AppSettings settings;
@override
Widget build(BuildContext context) {
final scale = MediaQuery.textScalerOf(context).scale(12) / 12;
final rowHeight = 78.0 * scale.clamp(1, 2.5),
gap = 20.0,
header = 56.0 * scale.clamp(1, 2);
double top(int period) =>
(period - 1) * rowHeight +
(period > 4 ? gap : 0) +
(period > 8 ? gap : 0);
final height = 11 * rowHeight + 2 * gap;
return LayoutBuilder(
builder: (context, constraints) {
final columnWidth = math.max(
90.0 * scale.clamp(1, 1.4),
(constraints.maxWidth - 52) / 7,
);
final gridWidth = columnWidth * 7;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 52,
height: header + height,
child: Stack(
children: [
for (final slot in lessonSlots)
Positioned(
top: header + top(slot.period),
left: 0,
right: 0,
height: rowHeight,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${slot.period}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 5),
Text(
minuteLabel(slot.startMinute),
style: TextStyle(
fontSize: 9,
color: ink.withValues(alpha: 0.5),
),
),
Text(
minuteLabel(slot.endMinute),
style: TextStyle(
fontSize: 9,
color: ink.withValues(alpha: 0.35),
),
),
],
),
),
],
),
),
Expanded(
child: SingleChildScrollView(
key: const Key('week-horizontal'),
scrollDirection: Axis.horizontal,
child: SizedBox(
width: gridWidth,
child: Column(
children: [
SizedBox(
height: header,
child: Row(
children: List.generate(7, (i) {
final date = monday.add(Duration(days: i));
final active =
date.year == today.year &&
date.month == today.month &&
date.day == today.day;
return SizedBox(
width: columnWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
const [
'周一',
'周二',
'周三',
'周四',
'周五',
'周六',
'周日',
][i],
style: TextStyle(
fontSize: 12,
color: active
? river
: ink.withValues(alpha: 0.55),
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 9,
vertical: 3,
),
decoration: BoxDecoration(
color: active
? river
: Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${date.day}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: active ? Colors.white : ink,
),
),
),
],
),
);
}),
),
),
SizedBox(
height: height,
child: Stack(
children: [
for (var i = 0; i < 7; i++)
Positioned(
left: i * columnWidth,
top: 0,
width: columnWidth,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: i < 5
? Colors.white.withValues(alpha: 0.45)
: river.withValues(alpha: 0.025),
border: Border(
left: BorderSide(
color: ink.withValues(alpha: 0.035),
),
),
),
),
),
for (var p = 1; p <= 11; p++)
Positioned(
left: 0,
right: 0,
top: top(p),
child: Divider(
height: 1,
color: ink.withValues(alpha: 0.05),
),
),
for (var day = 1; day <= 7; day++)
..._cards(
context,
day,
columnWidth,
rowHeight,
top,
),
],
),
),
],
),
),
),
),
],
);
},
);
}
static const _nameStyle = TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
height: 1.25,
);
static const _metadataStyle = TextStyle(fontSize: 9.5, height: 1.22);
List<Widget> _cards(
BuildContext context,
int day,
double width,
double rowHeight,
double Function(int) top,
) {
final values = events.where((e) => e.course.weekday == day).toList();
return [
for (final e in values)
_position(context, e, values, width, rowHeight, top),
];
}
Widget _position(
BuildContext context,
CourseOccurrence e,
List<CourseOccurrence> values,
double width,
double rowHeight,
double Function(int) top,
) {
final connected = <CourseOccurrence>{e};
List<CourseOccurrence> _group(CourseOccurrence event) {
final values = events
.where((e) => e.course.weekday == event.course.weekday)
.toList();
final connected = <CourseOccurrence>{event};
var grew = true;
while (grew) {
grew = false;
@@ -236,74 +41,330 @@ class WeekGrid extends StatelessWidget {
}
}
}
final group = values.where(connected.contains).toList();
final lane = group.indexOf(e), laneWidth = width / group.length;
return Positioned(
left: (e.course.weekday - 1) * width + lane * laneWidth + 3,
top: top(e.course.startPeriod) + 4,
width: math.max(12, laneWidth - 6),
height:
top(e.course.endPeriod) + rowHeight - top(e.course.startPeriod) - 8,
child: Semantics(
label: '${e.course.name},${e.course.teacher},${e.course.room}',
button: true,
child: GestureDetector(
onTap: () => showCourseDetails(context, e),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: courseColor(e.course.id, settings),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.8)),
return values.where(connected.contains).toList();
}
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final scaler = MediaQuery.textScalerOf(context);
final scale = scaler.scale(10) / 10;
const gutter = 34.0, gap = 16.0;
final width = (constraints.maxWidth - gutter) / 7;
final header = 56.0 * scale.clamp(1, 2.5);
final groups = {for (final e in events) e: _group(e)};
final defaultStyle = DefaultTextStyle.of(context).style;
double measure(
String value,
TextStyle style,
double available, {
int? lines,
}) {
final painter = TextPainter(
text: TextSpan(text: value, style: defaultStyle.merge(style)),
textDirection: Directionality.of(context),
textScaler: scaler,
maxLines: lines,
ellipsis: lines == null ? null : '…',
)..layout(maxWidth: available);
final result = painter.height;
painter.dispose();
return result;
}
// A single shared lesson height keeps equal-duration cards identical in size.
// Increase that height for full metadata, never stretch one card independently.
var rowHeight = 54.0 * scale.clamp(1, 2.5);
for (final e in events) {
final group = groups[e]!;
if (group.first != e) continue;
final available = math.max(1.0, width - 12);
var entryHeight = 0.0;
for (final value in group) {
final required =
measure(value.course.name, _nameStyle, available, lines: 2) +
measure(value.course.teacher, _metadataStyle, available) +
measure(value.course.room, _metadataStyle, available) +
21;
final periodHeight = group.length > 1
? measure(
'${value.course.startPeriod}–${value.course.endPeriod}节',
_metadataStyle,
available,
) +
3
: 0.0;
entryHeight = math.max(entryHeight, required + periodHeight);
}
final first = group.map((v) => v.course.startPeriod).reduce(math.min);
final last = group.map((v) => v.course.endPeriod).reduce(math.max);
final warning = group.length > 1
? measure('冲突列表', _metadataStyle, width - 12) + 8
: 0.0;
rowHeight = math.max(
rowHeight,
(entryHeight * group.length + warning + 8) / (last - first + 1),
);
}
rowHeight = rowHeight.ceilToDouble();
double top(int period) =>
(period - 1) * rowHeight +
(period > 4 ? gap : 0) +
(period > 8 ? gap : 0);
final height = 11 * rowHeight + 2 * gap;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: gutter,
height: header + height,
child: Stack(
children: [
for (final slot in lessonSlots)
Positioned(
top: header + top(slot.period),
left: 0,
right: 3,
height: rowHeight,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${slot.period}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
FittedBox(
child: Text(
minuteLabel(slot.startMinute),
style: TextStyle(
fontSize: 8,
color: ink.withValues(alpha: 0.55),
),
),
),
FittedBox(
child: Text(
minuteLabel(slot.endMinute),
style: TextStyle(
fontSize: 8,
color: ink.withValues(alpha: 0.4),
),
),
),
],
),
),
],
),
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
),
Expanded(
child: Column(
children: [
SizedBox(
height: header,
child: Row(
children: List.generate(7, (i) {
final date = monday.add(Duration(days: i));
final active =
date.year == today.year &&
date.month == today.month &&
date.day == today.day;
return SizedBox(
width: width,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
const [
'周一',
'周二',
'周三',
'周四',
'周五',
'周六',
'周日',
][i],
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: active
? river
: ink.withValues(alpha: 0.6),
),
),
const SizedBox(height: 3),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 5,
vertical: 2,
),
decoration: BoxDecoration(
color: active ? river : Colors.transparent,
borderRadius: BorderRadius.circular(9),
),
child: Text(
'${date.day}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: active ? Colors.white : ink,
),
),
),
],
),
);
}),
),
),
SizedBox(
height: height,
child: Stack(
children: [
for (var i = 0; i < 7; i++)
Positioned(
left: i * width,
top: 0,
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: i < 5
? Colors.white.withValues(alpha: 0.45)
: river.withValues(alpha: 0.025),
border: Border(
left: BorderSide(
color: ink.withValues(alpha: 0.04),
),
),
),
),
),
for (var p = 1; p <= 11; p++)
Positioned(
left: 0,
right: 0,
top: top(p),
child: Divider(
height: 1,
color: ink.withValues(alpha: 0.055),
),
),
for (final e in events)
if (groups[e]!.first == e)
_block(context, groups[e]!, width, rowHeight, top),
],
),
),
],
),
),
],
);
},
);
Widget _block(
BuildContext context,
List<CourseOccurrence> group,
double width,
double rowHeight,
double Function(int) top,
) {
final first = group.map((e) => e.course.startPeriod).reduce(math.min);
final last = group.map((e) => e.course.endPeriod).reduce(math.max);
return Positioned(
left: (group.first.course.weekday - 1) * width + 2,
top: top(first) + 4,
width: width - 4,
height: (last - first + 1) * rowHeight - 8,
child: group.length == 1
? _entry(context, group.first)
: Container(
decoration: BoxDecoration(
color: river.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: river.withValues(alpha: 0.15)),
),
// Conflict entries are explicitly a list, with their own lesson ranges.
// They are not separate time-scaled bars squeezed into narrow lanes.
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (group.length > 1)
const Text(
'时间冲突',
style: TextStyle(fontSize: 9, color: Color(0xFFB45D54)),
),
Text(
e.course.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
height: 1.25,
),
),
const SizedBox(height: 7),
Text(
e.course.teacher,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
color: ink.withValues(alpha: 0.7),
),
),
const SizedBox(height: 3),
Text(
e.course.room,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
color: ink.withValues(alpha: 0.6),
height: 1.2,
const Padding(
padding: EdgeInsets.symmetric(horizontal: 3, vertical: 3),
child: Text(
'冲突列表',
style: TextStyle(
fontSize: 9.5,
height: 1.22,
color: Color(0xFFB45D54),
),
),
),
for (final e in group)
Expanded(child: _entry(context, e, inConflict: true)),
],
),
),
),
),
),
);
}
Widget _entry(
BuildContext context,
CourseOccurrence e, {
bool inConflict = false,
}) => Semantics(
label: '${e.course.name},${e.course.teacher},${e.course.room}',
button: true,
excludeSemantics: true,
onTap: () => showCourseDetails(context, e),
child: GestureDetector(
onTap: () => showCourseDetails(context, e),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 6),
decoration: inConflict
? null
: BoxDecoration(
color: courseColor(e.course.id, settings),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white.withValues(alpha: 0.8)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
e.course.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: _nameStyle,
),
if (inConflict) ...[
const SizedBox(height: 3),
Text(
'${e.course.startPeriod}–${e.course.endPeriod}节',
style: _metadataStyle,
),
],
const SizedBox(height: 4),
Text(
e.course.teacher,
style: _metadataStyle.copyWith(
color: ink.withValues(alpha: 0.85),
),
),
const SizedBox(height: 3),
Text(
e.course.room,
style: _metadataStyle.copyWith(color: ink.withValues(alpha: 0.7)),
),
],
),
),
),
);
}