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,134 @@
//! 空态 EmptyState(`research/12b` §15):图标块 + 标题 + 说明 + 可选动作。
//!
//! ## ⚠️ 这是**项目自定**组件,官方 Material 3 没有它
//!
//! `research/14`(官方组件清单)里**没有** EmptyState 这一项,Compose `material3` 也没有
//! 对应 token。所以本文件的数值**全部是 `[估]`**(`research/12b §15` + `11b R14` + M53 的空态锚点),
//! 不得对外声称「按官方规格实现」:
//!
//! | # | 项 | 值 | 等级 |
//! |---|---|---|---|
//! | 15.1 | 图标块 | **96×96**,圆角 `Large(16)`,图标 **32** | `[估]`(圆角/图标档位是真令牌,96 是项目值) |
//! | 15.2 | 标题 | **HeadlineSmall 24/32**(空态是唯一允许 Headline 级的场景) | `[规格]` `11b R14` |
//! | 15.3 | 副文案 | **BodySmall 12/16** `OnSurfaceVariant`,最多 2 行 | `[规格]` |
//! | 15.4 | 垂直留白 | 内容在可用区居中(上下留白差 ≤8) | `[估]` |
//! | 15.5 | 快捷入口 | ≤ **3** 个 | `[估]`(本组件只给 1 个动作,见下) |
//! | 15.6 | CTA | 走标准 Button(40 高 / full 圆角),实心 `Primary` 占「每屏一个」名额 | `[源]`(按钮家族) |
//!
//! ## 为什么 CTA 直接用 `buttons::filled`
//!
//! 15.6 说 CTA 就是标准按钮。复用按钮家族有两个好处:空态的 CTA 与全站按钮同日升级;
//! 也顺带证明「组件库能跨家族组合」——本文件因此是唯一 import 兄弟家族的地方。
use iced::widget::{column, container, text};
use iced::{Alignment, Background, Border, Element, Length, Padding};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::icons::IconKind;
use unlucky_design::typography::TypeRole;
use crate::icons;
use crate::theme::Ui;
use super::super::buttons::{self, Button};
use super::super::layout;
use super::panel::EmptyStateGeometry;
/// 一个空态的装配描述(**不持有任何状态所有权**)。
#[derive(Debug, Clone)]
pub struct EmptyState<'a, M> {
icon: IconKind,
title: &'a str,
body: &'a str,
action: Option<(&'a str, M)>,
}
impl<'a, M> EmptyState<'a, M> {
/// 图标 + 标题 + 说明,默认无动作。
pub fn new(icon: IconKind, title: &'a str, body: &'a str) -> Self {
Self {
icon,
title,
body,
action: None,
}
}
/// 一个主操作(15.6:标准 Filled 按钮)。
pub fn action(mut self, label: &'a str, on_press: M) -> Self {
self.action = Some((label, on_press));
self
}
}
/// 画一个空态(内容整体在可用区里水平 + 垂直居中)。
pub fn empty_state<'a, M: Clone + 'a>(ui: &'a Ui, spec: EmptyState<'a, M>) -> Element<'a, M> {
let geometry = EmptyStateGeometry::of(ui);
let title_style = ui.style(TypeRole::HeadlineSmall);
let body_style = ui.style(TypeRole::BodySmall);
// Q9:图标必须落在固定尺寸的盒子里,否则 fill 约束会让 usvg 按 Contain 撑满 96 块。
let glyph = layout::centered_fixed(
geometry.icon,
geometry.icon,
icons::view::<M>(spec.icon, ui.color(Role::OnSurfaceVariant)),
);
let block_radius = iced::border::Radius::new(geometry.block_radius);
// 块底色 `[估]`:取材质层 `Card`(`SurfaceContainerLow`)——空态块是「内容卡片」层级,
// 不是浮层也不是画布;官方没有这个组件,所以这条是我们自己的口径。
let block_color = ui.layer(MaterialLayer::Card);
let block = container(glyph)
.width(Length::Fixed(geometry.block))
.height(Length::Fixed(geometry.block))
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.style(move |_theme| container::Style {
background: Some(Background::Color(block_color)),
border: Border {
radius: block_radius,
..Border::default()
},
..container::Style::default()
});
let mut content = column![]
.align_x(Alignment::Center)
.spacing(geometry.gap_body)
.push(block)
.push(gap(geometry.gap_title))
.push(
text(spec.title)
.size(title_style.size_px)
.font(ui.font_of(title_style))
.color(ui.color(Role::OnSurface)),
)
.push(
text(spec.body)
.size(body_style.size_px)
.font(ui.font_of(body_style))
.color(ui.color(Role::OnSurfaceVariant)),
);
if let Some((label, message)) = spec.action {
content = content
.push(gap(geometry.gap_action))
.push(buttons::filled(ui, Button::new(label, message)));
}
container(content)
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(Padding::from(geometry.gap_action))
.into()
}
/// 固定高度的透明留白(列间距在 `spacing` 之外还要分档时用)。
fn gap<'a, M: 'a>(height: f32) -> Element<'a, M> {
iced::widget::Space::new()
.width(Length::Fill)
.height(Length::Fixed(height))
.into()
}