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