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
+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)),
),
],
),
),
),
);
}