diff --git a/README.md b/README.md index ba457bc..8111718 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # TPSchedule -当前Android版本0.7.8+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+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+31,Android 7.0 及以上。 +学生课表 APP,Flutter 实现,支持电子科技大学研究生和本科生系统。Android 0.7.8+32,Android 7.0 及以上。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 diff --git a/docs/android-mvp-validation.md b/docs/android-mvp-validation.md index b50398c..6bac188 100644 --- a/docs/android-mvp-validation.md +++ b/docs/android-mvp-validation.md @@ -1,6 +1,6 @@ # Android 首版验证记录 -当前版本为0.7.8+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+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-bloom.md b/docs/tpschedule-0.7.8-dock-bloom.md new file mode 100644 index 0000000..547d081 --- /dev/null +++ b/docs/tpschedule-0.7.8-dock-bloom.md @@ -0,0 +1,14 @@ +# 0.7.8+32 点按放大、外溢与柔软玻璃 + +根据用户要求,点按像拖动一样先放大再弹跳,Dock随滑块运动,边缘滑块允许越出Dock;随后追加外壳需要更柔软的液态感。沿用用户授权直接执行的方式。 + +- 点按形变延长为620ms,增加宽高共同放大的脉冲,后半平滑衰减并挤压回弹;测试要求横向峰值大于静止的1.4倍,纵向大于1.25倍。点按弹簧阻尼20,连续点击延续已有尺寸与速度。 +- 点按期间底座弹簧根据滑块速度改变目标,产生有先后顺序的方向跟随;脉冲结束且两轴稳定后停表。原持续拖动时钟和有限形变波保留。 +- 放大时滑块可越出底座的左右、上下边缘,使用实际布局尺寸采样玻璃背景。溢出范围按真实父布局及安全区约束限制,屏幕/窄容器边缘保留8逻辑像素。 +- 点按放大时光学状态渐变为较通透的玻璃,落位后恢复浅灰选中状态。柔化边缘光晕,降低硬描边和峰值白光,并稍增强外壳的呼吸形变;乳白层和轻薄高度沿用31。 + +先强化点按放大断言、新增底座方向跟随与窄屏外溢测试,确认旧版本失败,再实现;另新增带左侧安全留边的父布局约束测试,确认仅按整屏计算会越界,改按更窄的父约束后通过。 + +验证包括322项Flutter测试、静态分析、正式构建;原点按反向连续性、图标动画/释放衔接、减少动画、拖动、平板与分屏仍覆盖。检查右侧点按外溢、移动与收回的逐帧渲染预览。预览为Flutter渲染示意,本轮未在实体平板上测性能。 + +版本0.7.8、构建32。包名、签名和私钥保持;保留旧APK、旧源码及0.5.4登记冻结资料。服务器无需改代码;发布时填版本0.7.8、构建号32及对应APK的HTTPS下载链接。 diff --git a/mobile/lib/app/app_version.dart b/mobile/lib/app/app_version.dart index a75de63..4d9cbea 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 = 31; +const appBuildNumber = 32; const appFilingNumber = '蜀ICP备2025169276号-5A'; diff --git a/mobile/lib/widgets/glass_dock.dart b/mobile/lib/widgets/glass_dock.dart index 472a525..4f5719a 100644 --- a/mobile/lib/widgets/glass_dock.dart +++ b/mobile/lib/widgets/glass_dock.dart @@ -34,7 +34,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { ); late final AnimationController _tap = AnimationController( vsync: this, - duration: const Duration(milliseconds: 560), + duration: const Duration(milliseconds: 620), ); late final AnimationController _sway = AnimationController.unbounded( vsync: this, @@ -67,6 +67,11 @@ class _GlassDockState extends State with TickerProviderStateMixin { 0.25, ); _elasticLastTick = elapsed; + if (_drag == null) { + _swayTarget = _tap.isAnimating + ? (_motion.velocity / 14).clamp(-0.85, 0.85) + : 0; + } final sway = _advanceSpring( _sway.value, _swayVelocity, @@ -87,7 +92,8 @@ class _GlassDockState extends State with TickerProviderStateMixin { _dragVelocity = drag.$2; _sway.value = sway.$1; _dragFollow.value = drag.$1; - if ((_sway.value - _swayTarget).abs() < 0.0005 && + if (!_tap.isAnimating && + (_sway.value - _swayTarget).abs() < 0.0005 && _swayVelocity.abs() < 0.005 && (_dragFollow.value - _dragTarget).abs() < 0.0005 && _dragVelocity.abs() < 0.005) { @@ -136,6 +142,15 @@ class _GlassDockState extends State with TickerProviderStateMixin { } double get _pressure => _press.value.clamp(0.0, 1.18); + double get _tapBloom { + if (_reduceMotion) return 0; + final t = _tap.value; + final tail = t <= 0.5 + ? 1.0 + : Curves.easeInOut.transform((2 * (1 - t)).clamp(0.0, 1.0)); + return math.sin(math.pi * t) * tail; + } + void _pressTo(double target) { if (_reduceMotion) { _press.value = target; @@ -196,7 +211,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { _motion.animateWith( SpringSimulation( springyTap - ? const SpringDescription(mass: 1, stiffness: 350, damping: 22) + ? const SpringDescription(mass: 1, stiffness: 350, damping: 20) : const SpringDescription(mass: 1, stiffness: 360, damping: 26), _position, index.toDouble(), @@ -246,7 +261,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { } Widget _glass({required Widget child, bool lens = false}) { - final pressure = _pressure.clamp(0.0, 1.0); + final pressure = math.max(_pressure.clamp(0.0, 1.0), _tapBloom * 0.85); final content = CustomPaint( foregroundPainter: _GlassRimPainter(lens: lens, pressure: pressure), child: DecoratedBox( @@ -299,6 +314,12 @@ class _GlassDockState extends State with TickerProviderStateMixin { constraints.maxWidth, MediaQuery.sizeOf(context).width >= 600 ? 300.0 : 264.0, ); + final viewport = MediaQuery.sizeOf(context).width; + final availableWidth = math.min( + viewport > 0 ? viewport : constraints.maxWidth, + constraints.maxWidth, + ); + final edgeAllowance = math.max(0.0, (availableWidth - _width) / 2 - 8); return AnimatedBuilder( animation: Listenable.merge([ _motion, @@ -316,19 +337,21 @@ class _GlassDockState extends State with TickerProviderStateMixin { final tapWave = _reduceMotion ? 0.0 : math.sin(2 * math.pi * _tap.value); - final tapStretch = tapWave >= 0 ? tapWave * 0.24 : tapWave * 0.065; + final tapStretch = tapWave >= 0 ? tapWave * 0.24 : tapWave * 0.08; final tapRise = tapWave >= 0 ? -tapWave * 0.045 : -tapWave * 0.15; // Pointer movement wakes a finite ripple; pauses do not start idle loops. final dragWave = _reduceMotion ? 0.0 : math.sin(2 * math.pi * _ripple.value) * press.clamp(0.0, 1.0); final dragStretch = dragWave * (dragWave >= 0 ? 0.10 : 0.06); + final tapInflate = _tapBloom * 0.30; final speed = _reduceMotion ? 0.0 : (_drag != null ? _dragSpeed : _motion.velocity).abs(); final stretch = (speed / 80).clamp(0.0, 0.08) + tapStretch + + tapInflate + press * 0.30 + dragStretch; final lensWidth = _travel * (1 + stretch); @@ -336,14 +359,14 @@ class _GlassDockState extends State with TickerProviderStateMixin { (_height - _inset * 2) * (1 + press * 0.31 + - tapRise - + tapRise + + tapInflate - dragWave * 0.08 - (speed / 180).clamp(0.0, 0.035)); // Resize real glass bounds: sampling stays aligned without scaling text. final sway = _reduceMotion ? 0.0 : _sway.value.clamp(-1.2, 1.2); - final bodyGrowth = press * 12 + pulse * 3 + sway.abs() * 4; - final bodyHeight = - _height + press * 5 + pulse * 1.5 - sway.abs() * 0.8; + final bodyGrowth = press * 12 + pulse * 7 + sway.abs() * 4; + final bodyHeight = _height + press * 5 + pulse * 3 - sway.abs() * 0.8; final dockShift = sway * 8; final leadingPull = sway * 6; return SizedBox( @@ -384,7 +407,13 @@ class _GlassDockState extends State with TickerProviderStateMixin { (_inset + _position * _travel - (lensWidth - _travel) / 2) - .clamp(1.0, _width - lensWidth - 1), + .clamp( + -edgeAllowance - dockShift, + _width + + edgeAllowance - + dockShift - + lensWidth, + ), top: (_height - lensHeight) / 2, width: lensWidth, height: lensHeight, @@ -639,8 +668,8 @@ class _GlassRimPainter extends CustomPainter { outer, Paint() ..style = PaintingStyle.stroke - ..strokeWidth = lens ? 1.4 : 1.8 - ..maskFilter = MaskFilter.blur(BlurStyle.normal, lens ? 0.9 : 0.65) + ..strokeWidth = lens ? 1.2 : 1.6 + ..maskFilter = MaskFilter.blur(BlurStyle.normal, lens ? 1.05 : 1.0) ..shader = ui.Gradient.linear( bounds.topLeft, bounds.bottomRight, @@ -656,16 +685,16 @@ class _GlassRimPainter extends CustomPainter { outer, Paint() ..style = PaintingStyle.stroke - ..strokeWidth = lens ? 1.0 : 0.85 + ..strokeWidth = lens ? 0.85 : 0.65 ..shader = ui.Gradient.sweep( bounds.center, [ - Colors.white.withValues(alpha: 0.96 * opacity), + Colors.white.withValues(alpha: 0.84 * opacity), edge.withValues(alpha: (lens ? 0.30 : 0.15) * opacity), Colors.white.withValues(alpha: 0.10 * opacity), - Colors.white.withValues(alpha: 0.94 * opacity), + Colors.white.withValues(alpha: 0.82 * opacity), edge.withValues(alpha: 0.12 * opacity), - Colors.white.withValues(alpha: 0.96 * opacity), + Colors.white.withValues(alpha: 0.84 * opacity), ], [0.0, 0.18, 0.38, 0.60, 0.80, 1.0], TileMode.clamp, @@ -677,7 +706,7 @@ class _GlassRimPainter extends CustomPainter { inner, Paint() ..style = PaintingStyle.stroke - ..strokeWidth = 0.45 + ..strokeWidth = 0.35 ..shader = ui.Gradient.linear( bounds.topLeft, bounds.bottomRight, diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index ea02ede..188db16 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+31 +version: 0.7.8+32 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 39f143f..63eff26 100644 --- a/mobile/test/widgets/dock_motion_test.dart +++ b/mobile/test/widgets/dock_motion_test.dart @@ -11,6 +11,77 @@ import 'navigation_test.dart' show uiController; void main() { setUpAll(tzdata.initializeTimeZones); + testWidgets('edge overflow respects the actual dock parent and safe insets', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var selected = 0; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(320, 800)), + child: StatefulBuilder( + builder: (context, setState) => Scaffold( + body: Padding( + padding: const EdgeInsets.only(left: 30), + child: Center( + child: GlassDock( + selectedIndex: selected, + onSelected: (i) => setState(() => selected = i), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.person_rounded)); + await tester.pump(); + for (var i = 0; i < 70; i++) { + await tester.pump(const Duration(milliseconds: 16)); + expect( + tester.getRect(find.byType(GlassRefraction).last).right, + lessThanOrEqualTo(312.5), + ); + } + await tester.pumpAndSettle(); + }); + testWidgets( + 'tap pulls the dock along and enlarged edge lens can overflow its body', + (tester) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(_dock()); + await tester.pumpAndSettle(); + final dock = find.byType(GlassDock), + lens = find.byType(GlassRefraction).last; + final icon = find.byIcon(Icons.calendar_month_rounded); + final x = tester.getCenter(icon).dx; + var pulled = x, overflow = 0.0; + await tester.tap(find.byIcon(Icons.person_rounded)); + await tester.pump(); + for (var i = 0; i < 70; i++) { + await tester.pump(const Duration(milliseconds: 16)); + pulled = math.max(pulled, tester.getCenter(icon).dx); + final r = tester.getRect(lens); + overflow = math.max(overflow, r.right - tester.getRect(dock).right); + expect(r.left, greaterThanOrEqualTo(7.5)); + expect(r.right, lessThanOrEqualTo(312.5)); + } + expect(pulled, greaterThan(x + 3)); + expect(overflow, greaterThan(5)); + await tester.pumpAndSettle(); + expect(tester.getCenter(icon).dx, closeTo(x, 0.1)); + expect(tester.getRect(lens).right, lessThan(tester.getRect(dock).right)); + }, + ); testWidgets('drag tugs the leading edge and lens follows with elastic lag', ( tester, ) async { @@ -79,12 +150,12 @@ void main() { narrowest = narrowest < rect.width ? narrowest : rect.width; tallest = tallest > rect.height ? tallest : rect.height; furthest = furthest > rect.center.dx ? furthest : rect.center.dx; - expect(rect.width, lessThan(resting.width * 1.4)); - expect(rect.center.dx, lessThan(target + 14)); + expect(rect.width, lessThan(resting.width * 1.7)); + expect(rect.center.dx, lessThan(target + 22)); } - expect(widest, greaterThan(resting.width * 1.20)); + expect(widest, greaterThan(resting.width * 1.40)); expect(narrowest, lessThan(resting.width * 0.97)); - expect(tallest, greaterThan(resting.height * 1.10)); + expect(tallest, greaterThan(resting.height * 1.25)); expect(furthest, greaterThan(target + 0.1)); expect(tester.getCenter(lens).dx, closeTo(target, 0.1)); expect(tester.getSize(lens).width, closeTo(resting.width, 0.1));