From caf7abe0c01b3be9e7afed96f5c03f7cd59eaf55 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 14 Oct 2026 09:49:53 +0800 Subject: [PATCH] Make compact Dock persistent and support week swipes --- README.md | 6 +- docs/tpschedule-0.5.4-validation.md | 19 + mobile/lib/app/hepan_app.dart | 102 ++--- .../features/timetable/timetable_page.dart | 409 ++++++++++-------- mobile/lib/widgets/glass_dock.dart | 6 +- mobile/pubspec.yaml | 2 +- mobile/test/widgets/dock_motion_test.dart | 73 ++-- mobile/test/widgets/glass_dock_test.dart | 80 ++-- mobile/test/widgets/glass_material_test.dart | 33 +- .../widgets/rschedule_navigation_test.dart | 6 +- mobile/test/widgets/week_swipe_test.dart | 101 +++++ 11 files changed, 474 insertions(+), 363 deletions(-) create mode 100644 docs/tpschedule-0.5.4-validation.md create mode 100644 mobile/test/widgets/week_swipe_test.dart diff --git a/README.md b/README.md index 2576ca0..56763cb 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # TPSchedule -学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.5.3+11,Android 7.0 及以上。 +学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.5.4+12,Android 7.0 及以上。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 -三个图标标签页:课表、考试、个人设置。界面统一蓝色,三个页面标题对齐。透明蓝色液态玻璃风格悬浮 Dock 缩窄至264,选中滑块为灰色玻璃,图标为实心蓝色,静止边距为3。支持点击图标或拖动选中滑块切换;按压和拖动时放大、松手弹簧回弹,支持的渲染模式启用实时背景折射。页面切换采用短距离移动与淡入淡出,并保留每页滚动位置。页面本身不支持左右滑动切换。课表和考试页面向下滚动时 Dock 用360ms过渡滑出屏幕底部,向上滚动恢复;个人设置页面保持显示。系统“减少动画”开启时减少切页与回弹动画。 +三个标签页:课表、考试、个人设置。界面统一蓝色,三个页面标题对齐。透明蓝色液态玻璃风格悬浮 Dock 为264×58,选中滑块为灰色玻璃,图标为实心蓝色,静止边距为3。支持点击图标或拖动选中滑块切换;按压和拖动时放大、松手弹簧回弹,支持的渲染模式启用实时背景折射。页面切换采用短距离移动与淡入淡出,并保留每页滚动位置。Dock在三个页面常驻,上下滚动均保持可见;课表左右滑动切换周次,不切换标签页。系统“减少动画”开启时减少切页与回弹动画。 课表周一至周日固定七列同屏,无横向滚动。同节数课程条等长;课程名可省略,老师和教室完整换行,点击查看全部信息。课程条默认长度125%,在「个人设置 → 课表设置 → 课程条长度」调节80%–200%。课前提醒、手机日历及学期设置继续保留。 @@ -12,6 +12,8 @@ 0.5.3为蓝色图标增加轻微柔光,下方显示“课表、考试、我的”。文字可随系统字号放大,并在Dock固定尺寸内自动适配。灰色静止滑块、按压放大和回弹保持。详见[0.5.3验证记录](docs/tpschedule-0.5.3-validation.md)。 +0.5.4主要缩小Dock高度,由68降至58,宽度保持264。Dock改为常驻。课表向左滑动下一周、向右滑动上一周,一次手势只换一周,限制在设置的学期周数内;箭头和“本周”按钮保留。未设置学期开始日时仍提示设置。详见[0.5.4验证记录](docs/tpschedule-0.5.4-validation.md)。 + ## 安装与学校登录 安装 `dist/TPSchedule.apk`(release运行模式),或使用 `dist/TPSchedule-debug.apk` 测试。Android包名已按用户确认改为 `com.tpschedule.app`,显示名称为TPSchedule。它与旧 `com.example.hepan_everyday` 是两个独立应用,不能覆盖安装旧RSchedule;旧应用与数据保留,新应用需要重新导入课程并设置日期。 diff --git a/docs/tpschedule-0.5.4-validation.md b/docs/tpschedule-0.5.4-validation.md new file mode 100644 index 0000000..eacd2ad --- /dev/null +++ b/docs/tpschedule-0.5.4-validation.md @@ -0,0 +1,19 @@ +# TPSchedule Android 0.5.4+12:常驻 Dock 与滑动换周 + +日期:2026-10-14。 + +Dock宽度保持264,高度由68降至58,约减少15%。图标由27调整为25,图标与文字间距由3调整为2;“课表、考试、我的”、蓝色微光、灰色静止滑块、按压放大与弹簧回位保留。静止触摸区高度52。 + +移除课表和考试滚动时隐藏Dock的监听与过渡容器,三个页面的Dock保持常驻。课表横向拖动释放后切换周次:向左下一周、向右上一周;48像素位移或400像素/秒速度触发,一次手势只换一周,限制在1至学期总周数。上下滚动、课程详情点击、箭头和“本周”按钮保留。未设置学期日期时保持设置提示。页面横向滑动不会切换标签页。 + +实现前的新测试复现旧行为:课表左右滑动不换周,滚动时Dock隐藏。实现后145项完整Flutter测试通过,使用--enable-impeller;静态分析无问题。新测试覆盖不同周课程、左右换周、首尾边界、一次换一周、返回本周、课程点击与未设置学期。既有测试覆盖Dock拖动、点击、尺寸、滚动常驻、系统字号和材质。 + +玻璃测试按58高度裁剪,预览导出保持3倍等比例尺寸,颜色采样避开图标与名称。蓝红对比下限按既有最强34%灰色覆盖与5%底座覆盖计算:原始210级对比保留约63%,下限130;产品材质参数未更改。精细纹理柔化与较大背景轮廓保留检查继续通过。 + +独立源码审查确认手势、边界、触摸尺寸与运动参数无功能问题,指出预览导出高度仍为旧值,已修正。 + +包名com.tpschedule.app及签名配置保留,私钥不更换或导出。 + +Android API36模拟器覆盖安装通过,原课程保留。实际左右滑动确认第1周→第2周→第1周;向下滚动后Dock三个触摸区域仍在原位置且可点击,点击“我的”进入TPSchedule个人页。课表、下一周、滚动与个人页截图位于dist/screenshots。模拟器使用软件兼容渲染;支持shader的折射效果由像素测试验证。 + +发布与调试包均构建成功。发布包versionName0.5.4/versionCode12,签名指纹检查通过,与0.5.3证书DER和公钥完全一致。发布APK SHA256:6707D68DBDAA352E4629994104C1D38450D48F9FB51B2458000B1EBA778AA3E2。 diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart index caa2922..fdab182 100644 --- a/mobile/lib/app/hepan_app.dart +++ b/mobile/lib/app/hepan_app.dart @@ -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( - 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, ), ), ), diff --git a/mobile/lib/features/timetable/timetable_page.dart b/mobile/lib/features/timetable/timetable_page.dart index b40e26e..385c659 100644 --- a/mobile/lib/features/timetable/timetable_page.dart +++ b/mobile/lib/features/timetable/timetable_page.dart @@ -17,6 +17,7 @@ class TimetablePage extends StatefulWidget { class _TimetablePageState extends State { 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 { ? 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( + 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( - 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('体验示例课表'), - ), - ), + ], ], - ], + ), ); } } diff --git a/mobile/lib/widgets/glass_dock.dart b/mobile/lib/widgets/glass_dock.dart index 37c0eff..8b0f105 100644 --- a/mobile/lib/widgets/glass_dock.dart +++ b/mobile/lib/widgets/glass_dock.dart @@ -19,7 +19,7 @@ class GlassDock extends StatefulWidget { } class _GlassDockState extends State 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 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 with TickerProviderStateMixin { ], ), ), - const SizedBox(height: 3), + const SizedBox(height: 2), Flexible( child: FittedBox( fit: BoxFit.scaleDown, diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f94c2ff..70ac8b0 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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: diff --git a/mobile/test/widgets/dock_motion_test.dart b/mobile/test/widgets/dock_motion_test.dart index 3dd6459..1744488 100644 --- a/mobile/test/widgets/dock_motion_test.dart +++ b/mobile/test/widgets/dock_motion_test.dart @@ -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(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(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(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}) { diff --git a/mobile/test/widgets/glass_dock_test.dart b/mobile/test/widgets/glass_dock_test.dart index 8eff167..6198458 100644 --- a/mobile/test/widgets/glass_dock_test.dart +++ b/mobile/test/widgets/glass_dock_test.dart @@ -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( diff --git a/mobile/test/widgets/glass_material_test.dart b/mobile/test/widgets/glass_material_test.dart index 0140c38..4820f0c 100644 --- a/mobile/test/widgets/glass_material_test.dart +++ b/mobile/test/widgets/glass_material_test.dart @@ -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 _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 _render( child: SizedBox( key: glassKey, width: 264, - height: 68, + height: height.toDouble(), child: Stack( children: [ Positioned.fill( @@ -235,11 +238,11 @@ Future _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 _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) * diff --git a/mobile/test/widgets/rschedule_navigation_test.dart b/mobile/test/widgets/rschedule_navigation_test.dart index 03660fe..fff3a4a 100644 --- a/mobile/test/widgets/rschedule_navigation_test.dart +++ b/mobile/test/widgets/rschedule_navigation_test.dart @@ -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); diff --git a/mobile/test/widgets/week_swipe_test.dart b/mobile/test/widgets/week_swipe_test.dart new file mode 100644 index 0000000..de9b398 --- /dev/null +++ b/mobile/test/widgets/week_swipe_test.dart @@ -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 _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); + }); +}