Files
TPSchedule/mobile/lib/features/timetable/week_grid.dart
T

373 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
this.periodCount = 11,
}) : assert(periodCount == 11 || periodCount == 12);
final int periodCount;
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 * settings.courseHeightScale * 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 = periodCount * rowHeight + 2 * gap;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: gutter,
height: header + height,
child: Stack(
children: [
for (final slot in lessonSlots.take(periodCount))
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 <= periodCount; 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)),
),
],
),
),
),
);
}