feat: bloom tap lens with dock follow and soft edge overflow
This commit is contained in:
@@ -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,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)。
|
||||
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
const appVersion = '0.7.8';
|
||||
const appBuildNumber = 31;
|
||||
const appBuildNumber = 32;
|
||||
const appFilingNumber = '蜀ICP备2025169276号-5A';
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user