Make compact Dock persistent and support week swipes

This commit is contained in:
Codex
2026-10-14 09:49:53 +08:00
parent fe23c65fd9
commit caf7abe0c0
11 changed files with 474 additions and 363 deletions
+31 -71
View File
@@ -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,
),
),
),
+219 -190
View File
@@ -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('体验示例课表'),
),
),
],
],
],
),
);
}
}
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -1,7 +1,7 @@
name: hepan_everyday
description: TPSchedule 学生课表
publish_to: none
version: 0.5.3+11
version: 0.5.4+12
environment:
sdk: ^3.13.5
dependencies:
+36 -37
View File
@@ -54,7 +54,7 @@ void main() {
await tester.pumpAndSettle();
final glass = find.byType(GlassRefraction);
expect(glass, findsNWidgets(2));
expect(tester.getSize(glass.first), const Size(264, 68));
expect(tester.getSize(glass.first), const Size(264, 58));
expect(
tester.widget<GlassRefraction>(glass.first).strength,
greaterThan(0),
@@ -101,42 +101,41 @@ void main() {
expect(tester.getSize(lens).width, closeTo(resting.width, 0.5));
expect(tester.getSize(lens).height, closeTo(resting.height, 0.5));
});
testWidgets(
'Dock stays visible while sliding down instead of abruptly fading',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final c = uiController();
await c.initialize();
await c.showDemo();
await tester.pumpWidget(HepanApp(controller: c));
await tester.pumpAndSettle();
final dock = find.byType(GlassDock);
final before = tester.getRect(dock);
final gesture = await tester.startGesture(
tester.getCenter(find.text('周三')),
);
await gesture.moveBy(const Offset(0, -80));
await tester.pump();
await tester.pump(const Duration(milliseconds: 120));
final fading = find.ancestor(
of: dock,
matching: find.byType(FadeTransition),
);
final opacity = fading.evaluate().isEmpty
? 1.0
: tester.widget<FadeTransition>(fading.first).opacity.value;
expect(opacity, greaterThan(0.95));
expect(tester.getRect(dock).top, greaterThan(before.top));
expect(tester.getRect(dock).top, lessThan(844));
await gesture.up();
await tester.pumpAndSettle();
expect(dock.hitTestable(), findsNothing);
c.dispose();
},
);
testWidgets('Dock stays stationary and tappable while scrolling timetable', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final c = uiController();
await c.initialize();
await c.showDemo();
await tester.pumpWidget(HepanApp(controller: c));
await tester.pumpAndSettle();
final dock = find.byType(GlassDock);
final before = tester.getRect(dock);
final gesture = await tester.startGesture(
tester.getCenter(find.text('周三')),
);
await gesture.moveBy(const Offset(0, -80));
await tester.pump();
await tester.pump(const Duration(milliseconds: 120));
final fading = find.ancestor(
of: dock,
matching: find.byType(FadeTransition),
);
final opacity = fading.evaluate().isEmpty
? 1.0
: tester.widget<FadeTransition>(fading.first).opacity.value;
expect(opacity, greaterThan(0.95));
expect(tester.getRect(dock), before);
expect(tester.getRect(dock).top, lessThan(844));
await gesture.up();
await tester.pumpAndSettle();
expect(dock.hitTestable(), findsOneWidget);
c.dispose();
});
}
Widget _dock({bool reduceMotion = false}) {
+40 -40
View File
@@ -9,46 +9,46 @@ import 'navigation_test.dart' show uiController;
void main() {
setUpAll(tzdata.initializeTimeZones);
testWidgets(
'glass cursor drags in both directions, taps work, body swipes do not navigate',
(tester) async {
final c = uiController();
await c.initialize();
await c.showDemo();
await tester.pumpWidget(HepanApp(controller: c));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(TimetablePage),
matching: find.text('课表'),
),
findsOneWidget,
);
expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(800));
await tester.drag(find.text('周三'), const Offset(-250, 0));
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsNothing);
await tester.drag(
find.byKey(const Key('dock-cursor')),
const Offset(185, 0),
);
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsOneWidget);
await tester.drag(find.text('连接学校课表'), const Offset(250, 0));
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsOneWidget);
await tester.drag(
find.byKey(const Key('dock-cursor')),
const Offset(-185, 0),
);
await tester.pumpAndSettle();
expect(find.text('示例课表'), findsOneWidget);
await tester.tap(find.text('我的'));
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsOneWidget);
c.dispose();
},
);
testWidgets('dock drags switch tabs while body swipes only change week', (
tester,
) async {
final c = uiController();
await c.initialize();
await c.showDemo();
await tester.pumpWidget(HepanApp(controller: c));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(TimetablePage),
matching: find.text('课表'),
),
findsOneWidget,
);
expect(tester.getRect(find.text('周日')).right, lessThanOrEqualTo(800));
await tester.drag(find.text('周三'), const Offset(-250, 0));
await tester.pumpAndSettle();
expect(find.text('第 2 周'), findsOneWidget);
expect(find.text('登录学校账户'), findsNothing);
await tester.drag(
find.byKey(const Key('dock-cursor')),
const Offset(185, 0),
);
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsOneWidget);
await tester.drag(find.text('连接学校课表'), const Offset(250, 0));
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsOneWidget);
await tester.drag(
find.byKey(const Key('dock-cursor')),
const Offset(-185, 0),
);
await tester.pumpAndSettle();
expect(find.text('示例课表'), findsOneWidget);
await tester.tap(find.text('我的'));
await tester.pumpAndSettle();
expect(find.text('登录学校账户'), findsOneWidget);
c.dispose();
});
testWidgets('cancelled cursor drag keeps selected tab', (tester) async {
int selected = 0;
await tester.pumpWidget(
+18 -15
View File
@@ -19,10 +19,10 @@ void main() {
preview: true,
);
// Sample beside the visible label, away from icon glow and glass rims.
final selected = _channel(resting, 22, 52, 0);
final base = _channel(resting, 160, 52, 0);
final selected = _channel(resting, 16, 43, 0);
final base = _channel(resting, 160, 43, 0);
expect(base - selected, greaterThan(20));
expect((_channel(resting, 22, 52, 2) - selected).abs(), lessThan(12));
expect((_channel(resting, 16, 43, 2) - selected).abs(), lessThan(12));
final held = await _render(
tester,
GlassDock(selectedIndex: 0, onSelected: (_) {}),
@@ -30,7 +30,7 @@ void main() {
held: true,
preview: true,
);
expect(_channel(held, 22, 52, 0) - selected, greaterThan(18));
expect(_channel(held, 16, 43, 0) - selected, greaterThan(18));
});
testWidgets(
'glass softens fine background detail without whitening its colour',
@@ -61,12 +61,14 @@ void main() {
);
// Below the symbol and away from the rim, the selected lens should retain
// this saturated cyan backdrop while applying the new resting grey tint.
final red = _channel(pixels, 22, 52, 0);
final green = _channel(pixels, 22, 52, 1);
final blue = _channel(pixels, 22, 52, 2);
final red = _channel(pixels, 16, 40, 0);
final green = _channel(pixels, 16, 40, 1);
final blue = _channel(pixels, 16, 40, 2);
expect(red, lessThan(85));
expect(green - red, greaterThan(95));
expect(blue - red, greaterThan(140));
// The strongest resting tint (34%) and base veil (5%) retain about 63%
// of the source's 210-level blue/red contrast, including the shorter rim.
expect(blue - red, greaterThan(130));
},
);
@@ -81,7 +83,7 @@ void main() {
);
for (final start in [32, 160]) {
final reds = [
for (var x = start; x < start + 20; x++) _channel(pixels, x, 60, 0),
for (var x = start; x < start + 20; x++) _channel(pixels, x, 50, 0),
];
final contrast =
reds.reduce((a, b) => a > b ? a : b) -
@@ -104,7 +106,7 @@ void main() {
);
for (final start in [24, 160]) {
final reds = [
for (var x = start; x < start + 40; x++) _channel(pixels, x, 60, 0),
for (var x = start; x < start + 40; x++) _channel(pixels, x, 50, 0),
];
final contrast =
reds.reduce((a, b) => a > b ? a : b) -
@@ -173,6 +175,7 @@ Future<ByteData> _render(
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final key = GlobalKey(), glassKey = GlobalKey();
final height = glass is GlassDock ? 58 : 68;
await tester.pumpWidget(
RepaintBoundary(
key: key,
@@ -183,7 +186,7 @@ Future<ByteData> _render(
child: SizedBox(
key: glassKey,
width: 264,
height: 68,
height: height.toDouble(),
child: Stack(
children: [
Positioned.fill(
@@ -235,11 +238,11 @@ Future<ByteData> _render(
ui.Canvas(recorder).drawImageRect(
image,
region,
const Rect.fromLTWH(0, 0, 792, 204),
Rect.fromLTWH(0, 0, 792, height * 3.0),
Paint()..filterQuality = FilterQuality.high,
);
final picture = recorder.endRecording();
final previewImage = await picture.toImage(792, 204);
final previewImage = await picture.toImage(792, height * 3);
final png = (await previewImage.toByteData(
format: ui.ImageByteFormat.png,
))!;
@@ -254,8 +257,8 @@ Future<ByteData> _render(
previewImage.dispose();
picture.dispose();
}
final cropped = ByteData(264 * 68 * 4);
for (var y = 0; y < 68; y++) {
final cropped = ByteData(264 * height * 4);
for (var y = 0; y < height; y++) {
for (var x = 0; x < 264; x++) {
final source =
((region.top.round() + y) * image.width + region.left.round() + x) *
@@ -8,9 +8,7 @@ import 'navigation_test.dart' show uiController;
void main() {
setUpAll(tzdata.initializeTimeZones);
testWidgets('three icon tabs and vertical scroll hide and restore Dock', (
tester,
) async {
testWidgets('three tabs remain reachable while pages scroll', (tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -30,7 +28,7 @@ void main() {
expect(find.bySemanticsLabel('考试'), findsOneWidget);
await tester.drag(find.text('周三'), const Offset(0, -200));
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('考试').hitTestable(), findsNothing);
expect(find.bySemanticsLabel('考试').hitTestable(), findsOneWidget);
await tester.drag(find.byType(ListView).first, const Offset(0, 100));
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('考试').hitTestable(), findsOneWidget);
+101
View File
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:timezone/data/latest.dart' as tzdata;
import 'package:hepan_everyday/app/app_controller.dart';
import 'package:hepan_everyday/domain/app_snapshot.dart';
import 'package:hepan_everyday/domain/app_settings.dart';
import 'package:hepan_everyday/domain/schedule_engine.dart';
import 'package:hepan_everyday/features/timetable/timetable_page.dart';
import '../support/fakes.dart';
AppController _controller({bool hasSemester = true}) {
Course lesson(int week) => Course(
id: 'week-$week',
name: '第$week周课程',
teacher: '测试教师',
room: 'A101',
weekday: 3,
startPeriod: 1,
endPeriod: 2,
weeks: {week},
source: CourseSource.file,
);
return AppController(
MemoryStore(
AppSnapshot(
courses: [lesson(1), lesson(2), lesson(3)],
settings: AppSettings(
semester: hasSemester
? Semester(startDate: DateTime(2026, 10, 12), totalWeeks: 3)
: null,
),
),
),
FakeNotifications(),
FakeCalendar(),
() => DateTime.utc(2026, 10, 14),
);
}
Future<void> _open(WidgetTester tester, AppController controller) async {
await controller.initialize();
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: TimetablePage(controller: controller)),
),
);
await tester.pumpAndSettle();
}
void main() {
setUpAll(tzdata.initializeTimeZones);
testWidgets(
'horizontal course swipe changes week and course taps still open',
(tester) async {
await _open(tester, _controller());
expect(find.text('第 1 周'), findsOneWidget);
expect(find.text('第1周课程'), findsOneWidget);
await tester.drag(find.text('第1周课程'), const Offset(-180, 0));
await tester.pumpAndSettle();
expect(find.text('第 2 周'), findsOneWidget);
expect(find.text('第1周课程'), findsNothing);
expect(find.text('第2周课程'), findsOneWidget);
await tester.tap(find.text('第2周课程'));
await tester.pumpAndSettle();
expect(find.text('知道了'), findsOneWidget);
await tester.tap(find.text('知道了'));
await tester.pumpAndSettle();
await tester.drag(find.text('周三'), const Offset(180, 0));
await tester.pumpAndSettle();
expect(find.text('第 1 周'), findsOneWidget);
},
);
testWidgets('one swipe advances one week and stops at semester boundaries', (
tester,
) async {
await _open(tester, _controller());
await tester.drag(find.text('周三'), const Offset(300, 0));
await tester.pumpAndSettle();
expect(find.text('第 1 周'), findsOneWidget);
for (final week in [2, 3, 3]) {
await tester.drag(find.text('周三'), const Offset(-300, 0));
await tester.pumpAndSettle();
expect(find.text('第 $week 周'), findsOneWidget);
}
await tester.tap(find.text('本周'));
await tester.pumpAndSettle();
expect(find.text('第 1 周'), findsOneWidget);
});
testWidgets('swiping an imported timetable without a semester keeps prompt', (
tester,
) async {
await _open(tester, _controller(hasSemester: false));
await tester.drag(find.byType(TimetablePage), const Offset(-180, 0));
await tester.pumpAndSettle();
expect(find.text('课表已导入,请设置学期开始日'), findsOneWidget);
expect(find.text('第 2 周'), findsNothing);
expect(tester.takeException(), isNull);
});
}