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 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 _group(CourseOccurrence event) { final values = events .where((e) => e.course.weekday == event.course.weekday) .toList(); final connected = {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 = 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 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)), ), ], ), ), ), ); }