feat: deliver Riverside Schedule seven-day grid and school HTML import
This commit is contained in:
@@ -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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user