feat(iced): 反馈类家族四件(Progress / Snackbar / Dialog / EmptyState)

D39 五家族并行的反馈类线(M78 / COMPFEED)。全部对消息类型泛型、不 import app::Message,
数值来自令牌或登记为 G17(令牌层没有反馈类的 ComponentMetrics 与骨架位)。

- Progress:linear(确定态 FillPortion 占比 + 官方 1750ms 双段不定态 keyframes)与
  circular(40/4 环,弧长 = 数值×360°,不定态走官方 6000ms 弧长+旋转 keyframes)。
  环形用几何 SVG(canvas feature 没开),组件不计时,相位由调用方给。
- Snackbar:48/68 两种高、上限宽 600(A1 ContainerMaxWidth)、12 外边距、贴顶/贴底、
  action(InversePrimary)+ 关闭键(InverseOnSurface);**组件不计时**,超时归调用方。
  iced 的 Length 没有 min-width ⇒ 344 的最小宽由宿主保证(登记 G17-d,与按钮 G15 同墙)。
- Dialog:scrim(黑@0.32 预合成实色,D27)+ R28 + SurfaceContainerHigh + pad 24 +
  标题/正文/动作三区 + 无动作/单动作/双动作;官方 280..560 区间内取 480。
- EmptyState:**官方没有这个组件**,96 图标块/32 图标/HeadlineSmall 全部标 [估]。
- action.rs:Dialog/Snackbar 共用的文字/图标动作(内容角色作参数,状态层仍走 interaction)。
- gallery/feedback.rs:状态矩阵;家族内再切一段 ULL_FEEDBACK_PART(all|progress|snackbar|
  dialog|empty),开关本身住在 feedback/gallery_parts.rs,不动 gallery/mod.rs。
- feedback/probe.rs:对着真截图量像素(渲染层断言),四类几何各逐项钉住。
- feedback/tests.rs:几何/节奏/角色/scrim 的纯函数断言。

gates: fmt --all --check · clippy -p unlucky-app-iced --all-targets -D warnings ·
test -p unlucky-app-iced(144 通过 / 0 失败)
This commit is contained in:
AskaEth committed 2026-11-02 15:56:42 +08:00
1 parent 27f0d2a551
commit 36f58d7904
14 files changed
+2900 -39

No files matched your search

@@ -0,0 +1,76 @@
//! 不定态的**节奏**:把官方的 keyframes 摊成纯函数 `phase → 进度`。
//!
//! ## 为什么组件不计时
//!
//! 契约 §2:组件不持有时钟/状态所有权。`indeterminate` 的相位由**调用方**给(`phase` 0..1,
//! 由页面的 `Subscription` 每帧/每 tick 推进)。好处不只「符合契约」:截图 harness 也才能拍到
//! 确定的中间帧——否则每张图都是一次抓阄。
//!
//! ## 数据来源(A1,`javap` 读本机 AAR 得到,不是凭记忆)
//!
//! ```text
//! ~/.gradle/caches/.../material3-android/1.4.0/.../material3.aar → classes.jar
//! javap -p -c -cp classes.jar androidx.compose.material3.ProgressIndicatorKt
//! ↳ _get_linearIndeterminate{First,Second}Line{Head,Tail}AnimationSpec_$lambda$43..46
//! ```
//!
//! Compose 的 `KeyframesSpec.at(value, time).using(easing)` 是「**从该点出发的那一段**用这条缓动」,
//! 所以 [`KeyPoint::easing`] 的语义就是段起点的缓动;第一点之前 / 最后一点之后一律**保持端点值**
//! (Compose 对未覆盖的区间就是保持,这也是「段跑到头以后停在满格」的来源)。
use unlucky_design::motion::Easing;
/// 一个关键帧:`ms` 时刻的值 + **从它出发那一段**的缓动。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct KeyPoint {
/// 相对周期起点的毫秒数。
pub ms: u32,
/// 该时刻的进度(0..1,环形是「弧长占比」,旋转是「度数 / 360」)。
pub value: f32,
/// 从本点出发到下一个点的那一段使用的缓动。
pub easing: Easing,
}
/// 一条 keyframes 曲线(一个周期内可采样任意相位)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Keyframes {
/// 周期长度(毫秒)。`phase` 按它折算成时间,再对周期取模(相位可越界/可循环)。
pub cycle_ms: u32,
/// 关键帧(按 `ms` 升序)。
pub points: &'static [KeyPoint],
}
impl Keyframes {
/// 采样:`phase`(任意实数,按周期取模)→ 进度。
///
/// 空列表给 0;`cycle_ms == 0` 时直接给首点的值(没有可循环的时间轴)。
pub fn sample(&self, phase: f32) -> f32 {
let Some(first) = self.points.first() else {
return 0.0;
};
if self.cycle_ms == 0 || !phase.is_finite() {
return first.value;
}
let cycle = self.cycle_ms as f32;
let ms = phase.rem_euclid(1.0) * cycle;
if ms <= first.ms as f32 {
return first.value;
}
for pair in self.points.windows(2) {
let (from, to) = (pair[0], pair[1]);
if ms > to.ms as f32 {
continue;
}
let span = to.ms as f32 - from.ms as f32;
if span <= 0.0 {
return to.value;
}
let t = (ms - from.ms as f32) / span;
return from.value + (to.value - from.value) * from.easing.sample(t);
}
self.points[self.points.len() - 1].value
}
}