feat: add two-tab timetable UI and glass dock

This commit is contained in:
Codex
2026-10-12 23:33:33 +08:00
parent 808af007a2
commit 8a98d49d12
17 changed files with 1912 additions and 9 deletions
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../domain/course_occurrence.dart';
import '../../domain/lesson_times.dart';
import '../../app/app_theme.dart';
void showCourseDetails(BuildContext context, CourseOccurrence event) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
backgroundColor: canvas,
builder: (context) => SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
event.course.name,
style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w700),
),
const SizedBox(height: 20),
_line(Icons.person_outline_rounded, event.course.teacher),
_line(Icons.location_on_outlined, event.course.room),
_line(
Icons.schedule_rounded,
'${minuteLabel(event.start.hour * 60 + event.start.minute)}–${minuteLabel(event.end.hour * 60 + event.end.minute)} · 第 ${event.course.startPeriod}–${event.course.endPeriod} 节',
),
_line(
Icons.date_range_outlined,
'第 ${event.week} 周 · ${event.start.month} 月 ${event.start.day} 日',
),
_line(
Icons.repeat_rounded,
'上课周次:${(event.course.weeks.toList()..sort()).join('、')}',
),
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.pop(context),
child: const Text('知道了'),
),
),
],
),
),
),
);
}
Widget _line(IconData icon, String text) => Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 21, color: river),
const SizedBox(width: 12),
Expanded(
child: Text(text, style: const TextStyle(fontSize: 15, height: 1.5)),
),
],
),
);
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_theme.dart';
import '../../domain/schedule_engine.dart';
import 'week_grid.dart';
class TimetablePage extends StatefulWidget {
const TimetablePage({super.key, required this.controller});
final AppController controller;
@override
State<TimetablePage> createState() => _TimetablePageState();
}
class _TimetablePageState extends State<TimetablePage> {
int? _week;
bool? _wasDemo;
@override
Widget build(BuildContext context) {
final c = widget.controller, term = c.visibleSettings.semester;
final today = ScheduleEngine.schoolDate(c.clock());
if (_wasDemo != c.isDemo) {
_week = null;
_wasDemo = c.isDemo;
}
final current = term == null
? 1
: (ScheduleEngine.weekNumber(term, today) ?? 1);
final week = (_week ?? current).clamp(1, term?.totalWeeks ?? 1);
final monday = term == null
? today.subtract(Duration(days: today.weekday - 1))
: term.firstMonday.add(Duration(days: (week - 1) * 7));
final sunday = monday.add(const Duration(days: 6));
return ListView(
padding: EdgeInsets.fromLTRB(
18,
20,
18,
140 + MediaQuery.paddingOf(context).bottom,
),
children: [
Text(
'HEPAN / EVERYDAY',
style: TextStyle(
fontSize: 10,
letterSpacing: 2.2,
color: river.withValues(alpha: 0.8),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 9),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Expanded(
child: Text(
'每一天,\n从容一点。',
style: TextStyle(
fontSize: 27,
fontWeight: FontWeight.w700,
height: 1.3,
letterSpacing: -0.6,
),
),
),
Container(
margin: const EdgeInsets.only(top: 5),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Text(
'${today.month} 月 ${today.day} 日',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: river,
),
),
),
],
),
const SizedBox(height: 22),
SurfaceCard(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 12),
child: Row(
children: [
IconButton(
onPressed: term != null && week > 1
? () => setState(() => _week = week - 1)
: null,
icon: const Icon(Icons.chevron_left_rounded),
),
Expanded(
child: Column(
children: [
Text(
term == null ? '本周' : '第 $week 周',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'${monday.month}.${monday.day.toString().padLeft(2, '0')} – ${sunday.month}.${sunday.day.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 12,
color: ink.withValues(alpha: 0.5),
),
),
],
),
),
IconButton(
onPressed: term != null && week < term.totalWeeks
? () => setState(() => _week = week + 1)
: null,
icon: const Icon(Icons.chevron_right_rounded),
),
TextButton(
onPressed: () => setState(() => _week = null),
child: const Text('本周'),
),
],
),
),
const SizedBox(height: 14),
if (c.isDemo)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 7,
),
decoration: BoxDecoration(
color: const Color(0xFFF3E9D6),
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'示例课表',
style: TextStyle(
fontSize: 11,
color: Color(0xFF94733D),
fontWeight: FontWeight.w600,
),
),
),
const Text(
'不会生成提醒或日历事件',
style: TextStyle(fontSize: 10, color: Color(0xFF85928E)),
),
TextButton(onPressed: c.leaveDemo, child: const Text('退出示例')),
],
),
),
if (term != null) ...[
WeekGrid(
events: ScheduleEngine.occurrences(
c.visibleCourses,
term,
).where((e) => e.week == week).toList(),
monday: monday,
today: today,
settings: c.visibleSettings,
),
const SizedBox(height: 12),
Text(
'左右滑动查看整周 · 点击课程查看详情',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 11, color: ink.withValues(alpha: 0.35)),
),
] else ...[
const SizedBox(height: 38),
const Icon(
Icons.calendar_month_outlined,
size: 66,
color: Color(0xFFB9D6D3),
),
const SizedBox(height: 22),
const Text(
'让课表,陪你开启每一天',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
Text(
'在「我的」设置学期并导入课程,\n或先看看河畔的示例课表。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
height: 1.8,
color: ink.withValues(alpha: 0.5),
),
),
const SizedBox(height: 25),
Center(
child: FilledButton.icon(
onPressed: c.showDemo,
icon: const Icon(Icons.auto_awesome_outlined, size: 18),
label: const Text('体验示例课表'),
),
),
],
],
);
}
}
@@ -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,
),
),
],
),
),
),
),
),
);
}
}