feat: bloom tap lens with dock follow and soft edge overflow

This commit is contained in:
david1465833828
2026-10-28 12:47:47 +08:00
parent c5cfbced5c
commit 93bcfd534c
7 changed files with 140 additions and 26 deletions
+2 -2
View File
@@ -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+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。
+1 -1
View File
@@ -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)。
+14
View File
@@ -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下载链接。
+1 -1
View File
@@ -1,3 +1,3 @@
const appVersion = '0.7.8';
const appBuildNumber = 31;
const appBuildNumber = 32;
const appFilingNumber = '蜀ICP备2025169276号-5A';
+46 -17
View File
@@ -34,7 +34,7 @@ class _GlassDockState extends State<GlassDock> 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<GlassDock> 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<GlassDock> 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<GlassDock> 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<GlassDock> 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<GlassDock> 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<GlassDock> 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<GlassDock> 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<GlassDock> 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<GlassDock> 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,
+1 -1
View File
@@ -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:
+75 -4
View File
@@ -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));