diff --git a/README.md b/README.md index 54cf0ab..68bae86 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # TPSchedule -当前Android版本0.7.8+33:修复缓存仍在但系统触发信息丢失时跳过提醒重建的问题,保留启动/暖启动恢复前的诊断;Android 16深度Doze及rare分组测试已实际投递,vivo几小时长待机根因仍待新诊断确认。见[本次记录](docs/tpschedule-0.7.8-reminder-idle.md)。构建32:点按先放大再弹跳、底座方向跟随、边缘外溢及柔软光泽,见[本次记录](docs/tpschedule-0.7.8-dock-bloom.md)。构建31提供滑块移动中持续Q弹、前缘牵拉和弹性跟随,玻璃材质减薄,见[本次记录](docs/tpschedule-0.7.8-dock-drag.md)。构建30提供增强点按挤压回弹、整条Dock随拖动方向弹动、图标到位动画及乳白折射光泽,见[本次记录](docs/tpschedule-0.7.8-dock-spring.md)。构建29提供Dock点按/拖动弹性与Android平板横竖屏、分屏适配,见[本次验证记录](docs/tpschedule-0.7.8-dock-tablet.md)。构建28已提前建立通知类别、区分通知总开关和类别状态,见[通知排查记录](docs/tpschedule-0.7.8-notification-permissions.md)。用户手机已通过开启通知权限并重测确认恢复后台通知。Dock乳白透光层和边缘折射继续保留,见[材质优化记录](docs/tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈见[0.7.8功能记录](docs/tpschedule-0.7.8-validation.md)及[反馈服务说明](server/feedback/html-diagnostic-update.md),更新管理后台见[部署说明](server/updates/README.md)。 +当前Android版本0.7.8+34:未选中灰色、选中蓝色,增强乳白透射与滑块对图标本身的折射,见[本次记录](docs/tpschedule-0.7.8-dock-symbol-optics.md)。构建33:修复缓存仍在但系统触发信息丢失时跳过提醒重建的问题,保留启动/暖启动恢复前的诊断;Android 16深度Doze及rare分组测试已实际投递,vivo几小时长待机根因仍待新诊断确认。见[本次记录](docs/tpschedule-0.7.8-reminder-idle.md)。构建32:点按先放大再弹跳、底座方向跟随、边缘外溢及柔软光泽,见[本次记录](docs/tpschedule-0.7.8-dock-bloom.md)。构建31提供滑块移动中持续Q弹、前缘牵拉和弹性跟随,玻璃材质减薄,见[本次记录](docs/tpschedule-0.7.8-dock-drag.md)。构建30提供增强点按挤压回弹、整条Dock随拖动方向弹动、图标到位动画及乳白折射光泽,见[本次记录](docs/tpschedule-0.7.8-dock-spring.md)。构建29提供Dock点按/拖动弹性与Android平板横竖屏、分屏适配,见[本次验证记录](docs/tpschedule-0.7.8-dock-tablet.md)。构建28已提前建立通知类别、区分通知总开关和类别状态,见[通知排查记录](docs/tpschedule-0.7.8-notification-permissions.md)。用户手机已通过开启通知权限并重测确认恢复后台通知。Dock乳白透光层和边缘折射继续保留,见[材质优化记录](docs/tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈见[0.7.8功能记录](docs/tpschedule-0.7.8-validation.md)及[反馈服务说明](server/feedback/html-diagnostic-update.md),更新管理后台见[部署说明](server/updates/README.md)。 0.7.0补充:考试页查询类型与卡片保持16像素间距;反馈问题和建议可自选附带最近10分钟的登录/读取/提醒诊断日志。默认不发送,不含密码、Cookie或课表内容,单独作为邮件文本附件。服务端需按[日志附件更新说明](server/feedback/diagnostic-log-update.md)手动更新;真实学校登录及SMTP未由本次离线测试代替验证。玻璃取样偏移仍在调查,未纳入本次修复结论。 -学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.8+33,Android 7.0 及以上。 +学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.8+34,Android 7.0 及以上。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 diff --git a/docs/android-mvp-validation.md b/docs/android-mvp-validation.md index 5f45e83..ec9e5cb 100644 --- a/docs/android-mvp-validation.md +++ b/docs/android-mvp-validation.md @@ -1,6 +1,6 @@ # Android 首版验证记录 -当前版本为0.7.8+33,提醒恢复与待机诊断见[本次记录](tpschedule-0.7.8-reminder-idle.md)。构建32的点按放大、外溢及柔软材质见[本次记录](tpschedule-0.7.8-dock-bloom.md)。构建31的移动中Q弹、牵拉及减薄见[本次记录](tpschedule-0.7.8-dock-drag.md)。构建30的点按/方向弹动及乳白材质见[本次记录](tpschedule-0.7.8-dock-spring.md)。构建29的Dock弹性与Android平板验证见[本次记录](tpschedule-0.7.8-dock-tablet.md)。构建28的通知权限恢复、通道初始化及真实后台触发见[通知记录](tpschedule-0.7.8-notification-permissions.md)。Dock乳白透光材质见[优化记录](tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈及签名、备案信息见[0.7.8功能记录](tpschedule-0.7.8-validation.md)。应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。 +当前版本为0.7.8+34,灰蓝导航、乳白透射及图标折射见[本次记录](tpschedule-0.7.8-dock-symbol-optics.md)。构建33的提醒恢复与待机诊断见[本次记录](tpschedule-0.7.8-reminder-idle.md)。构建32的点按放大、外溢及柔软材质见[本次记录](tpschedule-0.7.8-dock-bloom.md)。构建31的移动中Q弹、牵拉及减薄见[本次记录](tpschedule-0.7.8-dock-drag.md)。构建30的点按/方向弹动及乳白材质见[本次记录](tpschedule-0.7.8-dock-spring.md)。构建29的Dock弹性与Android平板验证见[本次记录](tpschedule-0.7.8-dock-tablet.md)。构建28的通知权限恢复、通道初始化及真实后台触发见[通知记录](tpschedule-0.7.8-notification-permissions.md)。Dock乳白透光材质见[优化记录](tpschedule-0.7.8-dock-refinement.md)。本科/研究生HTML反馈及签名、备案信息见[0.7.8功能记录](tpschedule-0.7.8-validation.md)。应用更新检查与后台说明见[0.7.6记录](tpschedule-0.7.6-updates.md)。本科考试兼容的验证见[0.7.5记录](tpschedule-0.7.5-validation.md)。两例真实课表缺失字段问题待新页面定位。本科接入记录见[0.7.0记录](tpschedule-0.7.0-validation.md)。下文为首版历史记录。 日期:2026-10-13。测试环境:Flutter 3.47.7 / Dart 3.13.5 / JDK 17;Android 16 API 36,Pixel 7 模拟器,1080×2400。首版版本 0.1.0+1,包 ID com.example.hepan_everyday,最低 Android 7.0(API 24)。 diff --git a/docs/tpschedule-0.7.8-dock-symbol-optics.md b/docs/tpschedule-0.7.8-dock-symbol-optics.md new file mode 100644 index 0000000..130df33 --- /dev/null +++ b/docs/tpschedule-0.7.8-dock-symbol-optics.md @@ -0,0 +1,28 @@ +# 0.7.8+34:灰蓝导航与乳白透射 + +日期:2026-10-28。依据用户提供的平板应用商店截图及之前的拖动视频,增强乳白底座、实时透射和滑块经过图标时的折射感。 + +## 变化 + +- 未选中的图标、文字改为不透明中性灰,选中项为蓝色。颜色跟随实际选中的页面;拖动经过中间项时不会把尚未切换到的页面染蓝。保留白色光晕。 +- 底座白色渐变从 .29/.17/.23 调整为 .41/.25/.34,仍透出下方颜色和较大的形状。增加曲面边缘折射范围、环境色反射和白色掠射高光;保留薄边及柔软轮廓。 +- 在图标行上方增加一个不接收触摸的实时光学层,与滑块共用位置和尺寸。拖动、按住和点按回弹过程中,它采样已经绘制好的图标与文字,使滑块边缘实际弯折这些内容,而不只是缩放整个图标或改变背景。 +- 上层折射强度最多 .52,不再追加模糊。逐帧预览后下调了初始强度,避免小字在玻璃边缘被过度拉碎。力度随按压/点按动画渐退,静止时移除额外光学层,恢复锐利图标。 +- 既有点按膨胀、拖动持续 Q 弹、方向牵拉、图标到位动画、边缘外溢、减少动态效果及手机/平板尺寸继续保留。Dock 高度仍为 58。 + +## 验证 + +- Flutter 全量 329 项测试通过,静态分析无问题,正式 APK 构建通过。 +- 新增测试覆盖:只有选中项为蓝色;点按动画中和拖动未提交时仍与真实页面一致;上层光学层在图标之后绘制、不遮挡交互,并在停止后移除。 +- Impeller 像素测试用已绘制的蓝色日历形状作为输入,确认零模糊上层折射改变超过 20 个像素,同时保留超过 70% 的蓝色面积,证明内容确实参与透射。 +- 材质像素测试继续检查深色背景下的乳白抬亮、白底不变灰、灰色静止滑块、细节柔化、大色块保留和移动时采样对齐。 +- 对最新 Flutter 渲染的点按、按住、来回拖动和释放逐帧检查;安装包旁附动画与静态预览,属于渲染示意,并非真机性能测量。 +- 独立审查未发现需要修复的缺陷,31 项聚焦测试通过。未新增真机或实体平板性能结论。 + +不支持 shader image filter 的渲染器沿用 Gaussian 降级;新增零模糊图标层在这类设备上不产生折射,实际观感较弱。 + +## 交付 + +版本 0.7.8+34,沿用包名 com.tpschedule.app、原签名/公钥/私钥和备案号。包括构建 33 的提醒缓存恢复修复及恢复前诊断,详见[提醒记录](tpschedule-0.7.8-reminder-idle.md)。vivo 几小时长待机漏提醒的根因仍需新版真机诊断确认。 + +保留旧版本产物及 0.5.4 软著登记冻结材料;不需要为本次变更更新服务器代码。更新后台填写版本 0.7.8、构建号 34。 diff --git a/mobile/lib/app/app_version.dart b/mobile/lib/app/app_version.dart index c1c0b3f..4e4e7e8 100644 --- a/mobile/lib/app/app_version.dart +++ b/mobile/lib/app/app_version.dart @@ -1,3 +1,3 @@ const appVersion = '0.7.8'; -const appBuildNumber = 33; +const appBuildNumber = 34; const appFilingNumber = '蜀ICP备2025169276号-5A'; diff --git a/mobile/lib/widgets/glass_dock.dart b/mobile/lib/widgets/glass_dock.dart index 4f5719a..932f42e 100644 --- a/mobile/lib/widgets/glass_dock.dart +++ b/mobile/lib/widgets/glass_dock.dart @@ -24,6 +24,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { double _width = 264; double get _travel => (_width - _inset * 2) / 3; static const _blue = Color(0xFF007AFF); + static const _inactive = Color(0xFF7C8593); late final AnimationController _motion = AnimationController.unbounded( vsync: this, value: widget.selectedIndex.toDouble(), @@ -288,9 +289,9 @@ class _GlassDockState extends State with TickerProviderStateMixin { )!, ] : [ - Colors.white.withValues(alpha: 0.29), - const Color(0xFFFDFEFF).withValues(alpha: 0.17), - Colors.white.withValues(alpha: 0.23), + Colors.white.withValues(alpha: 0.41), + const Color(0xFFFDFEFF).withValues(alpha: 0.25), + Colors.white.withValues(alpha: 0.34), ], ), ), @@ -300,8 +301,8 @@ class _GlassDockState extends State with TickerProviderStateMixin { return ClipRRect( borderRadius: BorderRadius.circular(40), child: GlassRefraction( - strength: lens ? 0.78 + pressure * 0.70 : 0.98, - blurSigma: lens ? 1.6 - pressure * 0.6 : 1.5, + strength: lens ? 1.0 + pressure * 0.78 : 1.18, + blurSigma: lens ? 1.6 - pressure * 0.6 : 1.7, child: content, ), ); @@ -369,6 +370,14 @@ class _GlassDockState extends State with TickerProviderStateMixin { final bodyHeight = _height + press * 5 + pulse * 3 - sway.abs() * 0.8; final dockShift = sway * 8; final leadingPull = sway * 6; + final lensLeft = + (_inset + _position * _travel - (lensWidth - _travel) / 2).clamp( + -edgeAllowance - dockShift, + _width + edgeAllowance - dockShift - lensWidth, + ); + final symbolRefraction = _reduceMotion + ? 0.0 + : math.max(press.clamp(0.0, 1.0), _tapBloom); return SizedBox( width: _width, height: _height, @@ -403,17 +412,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { child: _glass(child: const SizedBox.expand()), ), Positioned( - left: - (_inset + - _position * _travel - - (lensWidth - _travel) / 2) - .clamp( - -edgeAllowance - dockShift, - _width + - edgeAllowance - - dockShift - - lensWidth, - ), + left: lensLeft, top: (_height - lensHeight) / 2, width: lensWidth, height: lensHeight, @@ -442,6 +441,27 @@ class _GlassDockState extends State with TickerProviderStateMixin { ], ), ), + if (symbolRefraction > 0.005) + Positioned( + key: const ValueKey('dock-symbol-refraction'), + left: lensLeft, + top: (_height - lensHeight) / 2, + width: lensWidth, + height: lensHeight, + // This optical pass samples the already-painted + // symbols too. It fades out with pressure; the + // stationary symbols remain sharp and tappable. + child: IgnorePointer( + child: ClipRRect( + borderRadius: BorderRadius.circular(40), + child: GlassRefraction( + strength: symbolRefraction * 0.52, + blurSigma: 0, + child: const SizedBox.expand(), + ), + ), + ), + ), ], ), ), @@ -453,144 +473,141 @@ class _GlassDockState extends State with TickerProviderStateMixin { ); }, ); - Widget _item(int index, IconData icon, String label, {String? caption}) => - Expanded( - child: Semantics( - label: label, - button: true, - selected: index == widget.selectedIndex, - excludeSemantics: true, - onTap: () => _select(index), - child: GestureDetector( - // Selection must not replace the symbol's active animation state. - key: ValueKey('dock-item-$index'), - behavior: HitTestBehavior.opaque, - onTapDown: index == widget.selectedIndex - ? (_) => _pressTo(_reduceMotion ? 0 : 1) - : null, - onTapCancel: () { - if (_drag == null) _pressTo(0); - }, - onTap: () => _select(index), - onHorizontalDragStart: index == widget.selectedIndex - ? (_) { - final start = _position.clamp(0.0, 2.0); - _dragFollow.value = start; - _dragTarget = start; - _dragVelocity = _motion.velocity; - _motion.stop(); - _wakeRipple(); - _pressTo(_reduceMotion ? 0 : 1); - setState(() { - _drag = start; - _dragSpeed = 0; - }); - } - : null, - onHorizontalDragUpdate: index == widget.selectedIndex - ? (details) => setState(() { - _dragSpeed = details.delta.dx / _travel * 60; - _wakeRipple(); - _swayTo((_dragSpeed / 8).clamp(-1.0, 1.0)); - _drag = (_drag! + details.delta.dx / _travel).clamp( - 0.0, - 2.0, - ); - _followDragTo(_drag!); - }) - : null, - onHorizontalDragEnd: index == widget.selectedIndex - ? (details) => _select( - (_drag ?? _position).round().clamp(0, 2), - elastic: true, - velocity: _dragVelocity, - ) - : null, - onHorizontalDragCancel: index == widget.selectedIndex - ? () { - if (!mounted) return; - final position = _position; - setState(() => _drag = null); - _motion.value = position; - _moveTo(widget.selectedIndex, velocity: _dragVelocity); - _pressTo(0); - _swayTo(0); - } - : null, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedScale( - scale: index == _position.round().clamp(0, 2) - ? 1 + _pressure * 0.08 - : 1, - duration: const Duration(milliseconds: 130), - child: _DockSymbol( - active: (_position - index).abs() < 0.28, - reduceMotion: _reduceMotion, - child: Icon( - icon, - key: index == widget.selectedIndex - ? const Key('dock-cursor') - : null, - size: 25, - color: index == _position.round().clamp(0, 2) - ? _blue - : _blue.withValues(alpha: 0.72), - shadows: [ - Shadow( - color: Colors.white.withValues( - alpha: index == _position.round().clamp(0, 2) - ? 0.60 - : 0.38, - ), - blurRadius: 8, - ), - Shadow( - color: Colors.white.withValues( - alpha: index == _position.round().clamp(0, 2) - ? 0.95 - : 0.85, - ), - blurRadius: 2.8, - ), - Shadow( - color: Colors.white.withValues( - alpha: index == _position.round().clamp(0, 2) - ? 0.34 - : 0.22, - ), - blurRadius: 1.4, - ), - ], - ), - ), - ), - const SizedBox(height: 2), - Flexible( - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - caption ?? label, - maxLines: 1, - style: TextStyle( - fontSize: 11, - height: 1.1, - fontWeight: FontWeight.w600, - color: index == _position.round().clamp(0, 2) - ? _blue - : _blue.withValues(alpha: 0.62), + Widget _item( + int index, + IconData icon, + String label, { + String? caption, + }) => Expanded( + child: Semantics( + label: label, + button: true, + selected: index == widget.selectedIndex, + excludeSemantics: true, + onTap: () => _select(index), + child: GestureDetector( + // Selection must not replace the symbol's active animation state. + key: ValueKey('dock-item-$index'), + behavior: HitTestBehavior.opaque, + onTapDown: index == widget.selectedIndex + ? (_) => _pressTo(_reduceMotion ? 0 : 1) + : null, + onTapCancel: () { + if (_drag == null) _pressTo(0); + }, + onTap: () => _select(index), + onHorizontalDragStart: index == widget.selectedIndex + ? (_) { + final start = _position.clamp(0.0, 2.0); + _dragFollow.value = start; + _dragTarget = start; + _dragVelocity = _motion.velocity; + _motion.stop(); + _wakeRipple(); + _pressTo(_reduceMotion ? 0 : 1); + setState(() { + _drag = start; + _dragSpeed = 0; + }); + } + : null, + onHorizontalDragUpdate: index == widget.selectedIndex + ? (details) => setState(() { + _dragSpeed = details.delta.dx / _travel * 60; + _wakeRipple(); + _swayTo((_dragSpeed / 8).clamp(-1.0, 1.0)); + _drag = (_drag! + details.delta.dx / _travel).clamp(0.0, 2.0); + _followDragTo(_drag!); + }) + : null, + onHorizontalDragEnd: index == widget.selectedIndex + ? (details) => _select( + (_drag ?? _position).round().clamp(0, 2), + elastic: true, + velocity: _dragVelocity, + ) + : null, + onHorizontalDragCancel: index == widget.selectedIndex + ? () { + if (!mounted) return; + final position = _position; + setState(() => _drag = null); + _motion.value = position; + _moveTo(widget.selectedIndex, velocity: _dragVelocity); + _pressTo(0); + _swayTo(0); + } + : null, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedScale( + scale: index == _position.round().clamp(0, 2) + ? 1 + _pressure * 0.08 + : 1, + duration: const Duration(milliseconds: 130), + child: _DockSymbol( + active: (_position - index).abs() < 0.28, + reduceMotion: _reduceMotion, + child: Icon( + icon, + key: index == widget.selectedIndex + ? const Key('dock-cursor') + : null, + size: 25, + color: index == widget.selectedIndex ? _blue : _inactive, + shadows: [ + Shadow( + color: Colors.white.withValues( + alpha: index == _position.round().clamp(0, 2) + ? 0.60 + : 0.38, ), + blurRadius: 8, ), + Shadow( + color: Colors.white.withValues( + alpha: index == _position.round().clamp(0, 2) + ? 0.95 + : 0.85, + ), + blurRadius: 2.8, + ), + Shadow( + color: Colors.white.withValues( + alpha: index == _position.round().clamp(0, 2) + ? 0.34 + : 0.22, + ), + blurRadius: 1.4, + ), + ], + ), + ), + ), + const SizedBox(height: 2), + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + caption ?? label, + maxLines: 1, + style: TextStyle( + fontSize: 11, + height: 1.1, + fontWeight: FontWeight.w600, + color: index == widget.selectedIndex ? _blue : _inactive, ), ), - ], + ), ), - ), + ], ), ), - ); + ), + ), + ); } /// Plays once on entry. Remaining over a symbol does not restart its animation. diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 3eea304..4a22c6a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday description: TPSchedule 学生课表 publish_to: none -version: 0.7.8+33 +version: 0.7.8+34 environment: sdk: ^3.13.5 dependencies: diff --git a/mobile/shaders/liquid_glass.frag b/mobile/shaders/liquid_glass.frag index c0342d4..2eea319 100644 --- a/mobile/shaders/liquid_glass.frag +++ b/mobile/shaders/liquid_glass.frag @@ -26,12 +26,12 @@ void main() { float distance = length(outward); vec2 normal = outward / max(distance, 0.001); float depth = radius - distance; - float rim = 1.0 - smoothstep(0.0, radius * 0.46, depth); + float rim = 1.0 - smoothstep(0.0, radius * 0.52, depth); // Curved glass bends rays most at its edge, with subtle centre magnification. vec2 ray = centre + local / (1.0 + u_strength * 0.075) - normal * rim * rim * radius * u_strength * 0.23; vec4 colour = texture(u_background, sampleUV(ray)); - float dispersion = u_strength * rim * 0.65; + float dispersion = u_strength * rim * 0.80; colour.r = texture(u_background, sampleUV(ray + normal * dispersion)).r; colour.b = texture(u_background, sampleUV(ray - normal * dispersion)).b; // The curved rim catches nearby background light as well as transmitting it. @@ -40,11 +40,11 @@ void main() { float edge_light = pow(rim, 4.0) * u_strength * facing; vec4 reflected = texture(u_background, sampleUV(point + normal * radius * 0.40)); - colour = mix(colour, reflected, min(edge_light * 0.18, 0.22)); + colour = mix(colour, reflected, min(edge_light * 0.22, 0.26)); // A grazing-angle white reflection gives the curved rim visible thickness; // the broad centre continues to transmit the live, lightly softened scene. - colour.rgb = mix(colour.rgb, vec3(colour.a), min(edge_light * 0.15, 0.18)); - float reflection = edge_light * 0.075; + colour.rgb = mix(colour.rgb, vec3(colour.a), min(edge_light * 0.19, 0.22)); + float reflection = edge_light * 0.10; colour.rgb = min(colour.rgb + reflection * colour.a, vec3(colour.a)); frag_color = colour; } diff --git a/mobile/test/widgets/dock_motion_test.dart b/mobile/test/widgets/dock_motion_test.dart index 63eff26..6b75877 100644 --- a/mobile/test/widgets/dock_motion_test.dart +++ b/mobile/test/widgets/dock_motion_test.dart @@ -11,6 +11,36 @@ import 'navigation_test.dart' show uiController; void main() { setUpAll(tzdata.initializeTimeZones); + testWidgets( + 'moving lens refracts symbols above the icon row and releases cleanly', + (tester) async { + await tester.pumpWidget(_dock()); + await tester.pumpAndSettle(); + final optical = find.byKey(const ValueKey('dock-symbol-refraction')); + expect(optical, findsNothing); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('dock-cursor'))), + ); + await gesture.moveBy(const Offset(24, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(54, 0)); + await tester.pump(const Duration(milliseconds: 120)); + expect(optical, findsOneWidget); + final filter = tester.widget( + find.descendant(of: optical, matching: find.byType(GlassRefraction)), + ); + expect(filter.strength, greaterThan(0.35)); + expect(filter.blurSigma, 0); // Keep the transmitted symbols legible. + final stack = tester.widget( + find.ancestor(of: optical, matching: find.byType(Stack)).first, + ); + expect(stack.children.last.key, const ValueKey('dock-symbol-refraction')); + await gesture.up(); + await tester.pumpAndSettle(); + expect(optical, findsNothing); + expect(tester.takeException(), isNull); + }, + ); testWidgets('edge overflow respects the actual dock parent and safe insets', ( tester, ) async { diff --git a/mobile/test/widgets/glass_dock_test.dart b/mobile/test/widgets/glass_dock_test.dart index ef8d1ae..a0570dc 100644 --- a/mobile/test/widgets/glass_dock_test.dart +++ b/mobile/test/widgets/glass_dock_test.dart @@ -9,6 +9,58 @@ import 'navigation_test.dart' show uiController; void main() { setUpAll(tzdata.initializeTimeZones); + testWidgets('only the selected tab is blue, including during glass travel', ( + tester, + ) async { + var selected = 0; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) => Scaffold( + body: Center( + child: GlassDock( + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ), + ); + Color colour(IconData icon) => + tester.widget(find.byIcon(icon)).color!; + void expectGrey(IconData icon) { + final value = colour(icon); + expect((value.r - value.b).abs(), lessThan(0.12)); + expect(value.a, 1); + } + + expect(colour(Icons.calendar_month_rounded), const Color(0xFF007AFF)); + expectGrey(Icons.assignment_rounded); + expectGrey(Icons.person_rounded); + await tester.tap(find.byIcon(Icons.person_rounded)); + await tester.pump(); + // Colour follows the selected page, not whichever icon the lens crosses. + expect(colour(Icons.person_rounded), const Color(0xFF007AFF)); + expectGrey(Icons.calendar_month_rounded); + await tester.pump(const Duration(milliseconds: 100)); + expectGrey(Icons.assignment_rounded); + await tester.pumpAndSettle(); + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('dock-cursor'))), + ); + await gesture.moveBy(const Offset(-20, 0)); + await tester.pump(); + await gesture.moveBy(const Offset(-90, 0)); + await tester.pump(const Duration(milliseconds: 150)); + expect(colour(Icons.person_rounded), const Color(0xFF007AFF)); + expectGrey(Icons.assignment_rounded); + await gesture.up(); + await tester.pumpAndSettle(); + expect(selected, 1); + expect(colour(Icons.assignment_rounded), const Color(0xFF007AFF)); + expectGrey(Icons.person_rounded); + }); testWidgets('dock drags switch tabs while body swipes only change week', ( tester, ) async { diff --git a/mobile/test/widgets/glass_material_test.dart b/mobile/test/widgets/glass_material_test.dart index 9bd0556..bece913 100644 --- a/mobile/test/widgets/glass_material_test.dart +++ b/mobile/test/widgets/glass_material_test.dart @@ -9,6 +9,42 @@ import 'package:hepan_everyday/widgets/glass_dock.dart'; import 'package:hepan_everyday/widgets/glass_refraction.dart'; void main() { + testWidgets( + 'zero-blur glass bends an already-painted symbol without erasing it', + (tester) async { + final plain = await _render( + tester, + const GlassRefraction( + strength: 0, + blurSigma: 0, + child: SizedBox.expand(), + ), + opticalSymbol: true, + ); + final bent = await _render( + tester, + const GlassRefraction( + strength: 0.52, + blurSigma: 0, + child: SizedBox.expand(), + ), + opticalSymbol: true, + ); + var changed = 0, plainBlue = 0, bentBlue = 0; + for (var y = 16; y < 50; y++) { + for (var x = 5; x < 52; x++) { + final before = _channel(plain, x, y, 0); + final after = _channel(bent, x, y, 0); + if ((before - after).abs() > 20) changed++; + if (_channel(plain, x, y, 2) - before > 100) plainBlue++; + if (_channel(bent, x, y, 2) - after > 100) bentBlue++; + } + } + expect(changed, greaterThan(20)); + expect(bentBlue, greaterThan(plainBlue * 0.7)); + }, + skip: !ui.ImageFilter.isShaderFilterSupported, + ); testWidgets( 'Dock has a milky white body while transmitting a dark backdrop', (tester) async { @@ -23,8 +59,8 @@ void main() { // while its original cool colour remains visible through the material. final red = _channel(pixels, 160, 43, 0); final green = _channel(pixels, 160, 43, 1); - expect(red, greaterThan(50)); - expect(red, lessThan(80)); + expect(red, greaterThan(75)); + expect(red, lessThan(112)); expect(green - red, closeTo(13, 6)); }, ); @@ -190,6 +226,7 @@ Future _render( Offset offset = Offset.zero, Color backdropColor = const Color(0xFF14B4E6), bool held = false, + bool opticalSymbol = false, }) async { tester.view.physicalSize = const Size(800, 600); tester.view.devicePixelRatio = 1; @@ -217,6 +254,7 @@ Future _render( gradient, stripeWidth, backdropColor, + opticalSymbol, ), ), ), @@ -303,14 +341,40 @@ Future _render( } class _Backdrop extends CustomPainter { - const _Backdrop(this.stripes, this.gradient, this.stripeWidth, this.colour); + const _Backdrop( + this.stripes, + this.gradient, + this.stripeWidth, + this.colour, + this.opticalSymbol, + ); final bool stripes; final bool gradient; final double stripeWidth; final Color colour; + final bool opticalSymbol; @override void paint(Canvas canvas, Size size) { + if (opticalSymbol) { + canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white); + canvas.drawRRect( + RRect.fromRectAndRadius( + const Rect.fromLTWH(10, 20, 28, 25), + const Radius.circular(3), + ), + Paint()..color = const Color(0xFF007AFF), + ); + for (var row = 0; row < 2; row++) { + for (var col = 0; col < 3; col++) { + canvas.drawRect( + Rect.fromLTWH(15 + col * 7, 26 + row * 8, 4, 4), + Paint()..color = Colors.white, + ); + } + } + return; + } if (gradient) { for (var x = 0; x < size.width; x++) { canvas.drawRect(