371 lines
13 KiB
Dart
371 lines
13 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../../domain/schedule_engine.dart';
|
||
import '../../domain/app_settings.dart';
|
||
import '../../app/app_theme.dart';
|
||
import 'course_details.dart';
|
||
|
||
class WeekGrid extends StatelessWidget {
|
||
const WeekGrid({
|
||
super.key,
|
||
required this.events,
|
||
required this.monday,
|
||
required this.today,
|
||
required this.settings,
|
||
});
|
||
final List<CourseOccurrence> events;
|
||
final DateTime monday, today;
|
||
final AppSettings settings;
|
||
static const _nameStyle = TextStyle(
|
||
fontSize: 10.5,
|
||
fontWeight: FontWeight.w700,
|
||
height: 1.25,
|
||
);
|
||
static const _metadataStyle = TextStyle(fontSize: 9.5, height: 1.22);
|
||
|
||
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;
|
||
for (final other in values) {
|
||
if (!connected.contains(other) &&
|
||
connected.any((known) => ScheduleEngine.overlaps(known, other))) {
|
||
connected.add(other);
|
||
grew = true;
|
||
}
|
||
}
|
||
}
|
||
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),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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(
|
||
children: [
|
||
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)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|