Make compact Dock persistent and support week swipes
This commit is contained in:
@@ -59,31 +59,9 @@ class _Shell extends StatefulWidget {
|
||||
|
||||
class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
||||
int _tab = 0;
|
||||
bool _dockHidden = false;
|
||||
double _scrollTravel = 0;
|
||||
void _selectTab(int index) {
|
||||
if (index == _tab) return;
|
||||
setState(() {
|
||||
_tab = index;
|
||||
_dockHidden = false;
|
||||
_scrollTravel = 0;
|
||||
});
|
||||
}
|
||||
|
||||
bool _scroll(ScrollNotification notification) {
|
||||
if (_tab == 2 || notification.metrics.axis != Axis.vertical) return false;
|
||||
if (notification is ScrollUpdateNotification &&
|
||||
notification.dragDetails != null) {
|
||||
final delta = notification.scrollDelta ?? 0;
|
||||
if (delta.sign != _scrollTravel.sign) _scrollTravel = 0;
|
||||
_scrollTravel += delta;
|
||||
final hide = notification.metrics.pixels > 0 && _scrollTravel > 24;
|
||||
final reveal = notification.metrics.pixels <= 0 || _scrollTravel < -16;
|
||||
if ((hide && !_dockHidden) || (reveal && _dockHidden)) {
|
||||
setState(() => _dockHidden = hide);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
setState(() => _tab = index);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -274,35 +252,32 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _scroll,
|
||||
child: TabDeck(
|
||||
index: _tab,
|
||||
children: [
|
||||
TimetablePage(
|
||||
controller: widget.controller,
|
||||
onSync: _school,
|
||||
),
|
||||
ExamsPage(
|
||||
controller: widget.controller,
|
||||
onSync: () {
|
||||
_school(exams: true);
|
||||
},
|
||||
),
|
||||
ProfilePage(
|
||||
controller: widget.controller,
|
||||
onLogin: _school,
|
||||
onUpdateTerm: () => _school(chooseTerm: true),
|
||||
authStatus: widget.school?.statusLabel ?? '未登录',
|
||||
logoutLabel: widget.school?.logoutFailed == true
|
||||
? '重试退出学校登录'
|
||||
: '退出学校登录',
|
||||
onLogout: widget.school?.canLogout == true
|
||||
? _logout
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TabDeck(
|
||||
index: _tab,
|
||||
children: [
|
||||
TimetablePage(
|
||||
controller: widget.controller,
|
||||
onSync: _school,
|
||||
),
|
||||
ExamsPage(
|
||||
controller: widget.controller,
|
||||
onSync: () {
|
||||
_school(exams: true);
|
||||
},
|
||||
),
|
||||
ProfilePage(
|
||||
controller: widget.controller,
|
||||
onLogin: _school,
|
||||
onUpdateTerm: () => _school(chooseTerm: true),
|
||||
authStatus: widget.school?.statusLabel ?? '未登录',
|
||||
logoutLabel: widget.school?.logoutFailed == true
|
||||
? '重试退出学校登录'
|
||||
: '退出学校登录',
|
||||
onLogout: widget.school?.canLogout == true
|
||||
? _logout
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -313,25 +288,10 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver {
|
||||
right: 0,
|
||||
bottom: 16 + MediaQuery.paddingOf(context).bottom,
|
||||
child: Center(
|
||||
child: IgnorePointer(
|
||||
ignoring: _dockHidden,
|
||||
child: ExcludeSemantics(
|
||||
excluding: _dockHidden,
|
||||
child: AnimatedSlide(
|
||||
key: const Key('floating-dock'),
|
||||
offset: _dockHidden
|
||||
? const Offset(0, 1.8)
|
||||
: Offset.zero,
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 360),
|
||||
curve: Curves.easeInOutCubic,
|
||||
child: GlassDock(
|
||||
selectedIndex: _tab,
|
||||
onSelected: _selectTab,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: GlassDock(
|
||||
key: const Key('floating-dock'),
|
||||
selectedIndex: _tab,
|
||||
onSelected: _selectTab,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -17,6 +17,7 @@ class TimetablePage extends StatefulWidget {
|
||||
class _TimetablePageState extends State<TimetablePage> {
|
||||
int? _week;
|
||||
bool? _wasDemo;
|
||||
double _weekDrag = 0;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = widget.controller, term = c.visibleSettings.semester;
|
||||
@@ -33,214 +34,242 @@ class _TimetablePageState extends State<TimetablePage> {
|
||||
? 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(
|
||||
key: const PageStorageKey('timetable-scroll'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8,
|
||||
10,
|
||||
8,
|
||||
140 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'课表',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '刷新课表',
|
||||
onPressed: widget.onSync,
|
||||
icon: const Icon(Icons.sync_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
return GestureDetector(
|
||||
onHorizontalDragStart: term == null ? null : (_) => _weekDrag = 0,
|
||||
onHorizontalDragUpdate: term == null
|
||||
? null
|
||||
: (details) => _weekDrag += details.delta.dx,
|
||||
onHorizontalDragCancel: term == null ? null : () => _weekDrag = 0,
|
||||
onHorizontalDragEnd: term == null
|
||||
? null
|
||||
: (details) {
|
||||
final velocity = details.primaryVelocity ?? 0;
|
||||
if (_weekDrag.abs() < 48 && velocity.abs() < 400) return;
|
||||
final direction = _weekDrag.abs() >= 48 ? _weekDrag : velocity;
|
||||
final next = (week + (direction < 0 ? 1 : -1)).clamp(
|
||||
1,
|
||||
term.totalWeeks,
|
||||
);
|
||||
if (next != week) setState(() => _week = next);
|
||||
},
|
||||
child: ListView(
|
||||
key: const PageStorageKey('timetable-scroll'),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
8,
|
||||
10,
|
||||
8,
|
||||
140 + MediaQuery.paddingOf(context).bottom,
|
||||
),
|
||||
if (!c.isDemo && c.snapshot.schoolTerm != null)
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: Text(
|
||||
c.snapshot.schoolTerm!.label,
|
||||
style: TextStyle(fontSize: 12, color: ink.withValues(alpha: 0.5)),
|
||||
padding: EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'课表',
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '刷新课表',
|
||||
onPressed: widget.onSync,
|
||||
icon: const Icon(Icons.sync_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SurfaceCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: term != null && week > 1
|
||||
? () => setState(() => _week = week - 1)
|
||||
: null,
|
||||
icon: const Icon(Icons.chevron_left_rounded),
|
||||
if (!c.isDemo && c.snapshot.schoolTerm != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: Text(
|
||||
c.snapshot.schoolTerm!.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: ink.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
term == null ? '本周' : '第 $week 周',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SurfaceCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 0),
|
||||
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: 4),
|
||||
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(0xFFE2EEFF),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Text(
|
||||
'示例课表',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF356AA8),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${monday.month}.${monday.day.toString().padLeft(2, '0')} – ${sunday.month}.${sunday.day.toString().padLeft(2, '0')}',
|
||||
),
|
||||
const Text(
|
||||
'不会生成提醒或日历事件',
|
||||
style: TextStyle(fontSize: 10, color: Color(0xFF8193AB)),
|
||||
),
|
||||
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 if (c.visibleCourses.isNotEmpty) ...[
|
||||
const SizedBox(height: 36),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: SurfaceCard(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.event_available_rounded,
|
||||
size: 44,
|
||||
color: river,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const Text(
|
||||
'课表已导入,请设置学期开始日',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: ink.withValues(alpha: 0.5),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'${c.visibleCourses.length} 条课程安排已保存',
|
||||
style: const TextStyle(fontSize: 13, color: river),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'课程按教学周保存。设置真实的学期开始日后,\n才能显示每周课表并安排课前提醒。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, height: 1.8),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
SettingsPage(controller: c, focusSemester: true),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.edit_calendar_rounded, size: 18),
|
||||
label: const Text('设置学期开始日'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: 4),
|
||||
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(0xFFE2EEFF),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Text(
|
||||
'示例课表',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF356AA8),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'不会生成提醒或日历事件',
|
||||
style: TextStyle(fontSize: 10, color: Color(0xFF8193AB)),
|
||||
),
|
||||
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 if (c.visibleCourses.isNotEmpty) ...[
|
||||
const SizedBox(height: 36),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: SurfaceCard(
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.event_available_rounded,
|
||||
size: 44,
|
||||
color: river,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const Text(
|
||||
'课表已导入,请设置学期开始日',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'${c.visibleCourses.length} 条课程安排已保存',
|
||||
style: const TextStyle(fontSize: 13, color: river),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'课程按教学周保存。设置真实的学期开始日后,\n才能显示每周课表并安排课前提醒。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12, height: 1.8),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.icon(
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
SettingsPage(controller: c, focusSemester: true),
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.edit_calendar_rounded, size: 18),
|
||||
label: const Text('设置学期开始日'),
|
||||
),
|
||||
],
|
||||
] else ...[
|
||||
const SizedBox(height: 38),
|
||||
const Icon(
|
||||
Icons.calendar_month_outlined,
|
||||
size: 66,
|
||||
color: Color(0xFFB7CDF0),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 38),
|
||||
const Icon(
|
||||
Icons.calendar_month_outlined,
|
||||
size: 66,
|
||||
color: Color(0xFFB7CDF0),
|
||||
),
|
||||
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('体验示例课表'),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
Center(
|
||||
child: FilledButton.icon(
|
||||
onPressed: c.showDemo,
|
||||
icon: const Icon(Icons.auto_awesome_outlined, size: 18),
|
||||
label: const Text('体验示例课表'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class GlassDock extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
||||
static const _width = 264.0, _height = 68.0, _inset = 3.0, _travel = 86.0;
|
||||
static const _width = 264.0, _height = 58.0, _inset = 3.0, _travel = 86.0;
|
||||
static const _blue = Color(0xFF007AFF);
|
||||
late final AnimationController _motion = AnimationController.unbounded(
|
||||
vsync: this,
|
||||
@@ -294,7 +294,7 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
||||
duration: const Duration(milliseconds: 130),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 27,
|
||||
size: 25,
|
||||
color: index == _position.round().clamp(0, 2)
|
||||
? _blue
|
||||
: _blue.withValues(alpha: 0.48),
|
||||
@@ -310,7 +310,7 @@ class _GlassDockState extends State<GlassDock> with TickerProviderStateMixin {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
const SizedBox(height: 2),
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
|
||||
Reference in New Issue
Block a user