feat: add two-tab timetable UI and glass dock
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user