310 lines
11 KiB
Dart
310 lines
11 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;
|
||
@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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
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};
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
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)),
|
||
),
|
||
child: SingleChildScrollView(
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|