diff --git a/specs/028-stepper-demo-alignment/acceptance.md b/specs/028-stepper-demo-alignment/acceptance.md new file mode 100644 index 000000000..69e9451eb --- /dev/null +++ b/specs/028-stepper-demo-alignment/acceptance.md @@ -0,0 +1,41 @@ +# 验收记录 + +## 2026-09-09 本地修复 + +- PR 基线:[GitHub #1064](https://github.com/Tencent/tdesign-flutter/pull/1064),head `5bdc6b41fc47b9115856013ca8b9ae4159431a50`,base `ccace5c61383dc2c4fd5392f41222e65b54d8010`。 +- [Figma 展示目标](https://www.figma.com/design/mdBVCCVGERhxoZLle2eLT0?node-id=28591-40152):376 × 960,删除中间 99 示例,禁用值为 0;示例使用完整宽度的容器底色、16px 内边距,基础与禁用左对齐。 +- 小程序参考版本:`fe14543572bc7233226b2f08db6fc1424cd81ed2`。[公开运行页](https://tdesign.tencent.com/miniprogram/live/m2w/program/miniprogram/#!pages/stepper/stepper.html) 仍有 99,本次按指定设计稿处理。 +- 保留现有七个构造参数、枚举和默认值。修复草稿边界与 Theme 插值,不新增 Controller、disabled、integer 或 disableInput。 + +## 实际验证 + +| 检查 | Flutter 3.32.0 / macOS | Flutter 3.47.0 / macOS | +| --- | --- | --- | +| 组件回归 | 39 项通过 | 39 项通过 | +| 完整 Demo 回归 | 7 项通过 | 7 项通过 | +| 生成器及回归工具自测 | 18 项通过 | 18 项通过 | +| 严格 flutter analyze | 0 error / 0 warning | 0 error / 0 warning | + +- 四个生产文件合计行覆盖率:`330/335 = 98.51%`,组件测试全部通过。 +- 行为覆盖:两端边界草稿、父组件接受/拒绝、非法草稿、即时按钮边界更新;主题双向插值、端点、实例尺寸、继承主题、TD token、copyWith、嵌套插值及 AnimatedTheme。 +- 实际打开五个代码面板,验证显示内容与生成资产一致;将五份生成资产原样写为独立 Dart 文件,在最小宿主编译并操作全部十个实例,1 项测试通过。 +- iPhone 16 / iOS 18.2 模拟器运行当前提交:实际点击基础、边界、三种样式和三种尺寸实例;从 0 输入合法草稿后点击减号得到 4,从 999 输入 995 后点击加号得到 996;禁用实例保持 0 且两侧按钮不可用。明暗主题切换后已修改值保持。 +- 手机验收发现开启顶部代码模式会把已操作值重置。根因是 `CodeWrapper` 在显示遮罩时临时插入 `Stack`,改变 StatefulWidget 父节点结构。修复为始终保留同一 `Stack` 子树;再次上机确认基础值 3 → 4 后,开启代码模式、打开并关闭代码面板、切换主题,值均保持 4。双 SDK Demo 回归覆盖同一路径。 +- 示例生成器 `--check` 通过;Stepper API 文档从源码重新生成。 +- 新增组件契约测试、生成器测试和四张视觉回归的 CI 入口登记;GitHub/CNB 工具入口同步。 +- 合并 `origin/develop@335b30bc` 后,用最终合并提交重新构建 iOS 应用并复跑:基础 3 → 4、最小值 0 → 1、最大值 999 → 998,三种样式与三种尺寸均各自 3 → 4;打开和关闭代码面板、查看 API 文档、切换主题后上述值全部保留。 + +## Linux Golden + +- 环境:已有缓存镜像 `tdesign-flutter-golden-cache:3.32.0`,Linux amd64、Flutter 3.32.0、Dart 3.8.0。离线依赖使用 Git `--no-hardlinks` 适配;未修改主工作区依赖或 lockfile。 +- Demo 宽 375、DPR 1,预载中文与图标字体;完整页面高 854。先检查实际明暗图片,再更新两张 Demo 基线并精确复跑。 +- 原组件基线在同一环境已有字体绘制差异:亮色 848 像素(1.82%)、暗色 835 像素(1.80%)。使用 PR head 生产源码复跑后,实际图片与修复后的图片逐字节相同,确认本次修复未改变静态视觉。 + - 亮色 SHA-256:`755a5ee4676911e93f45280d0edb3f052c3ee7cf56840df2d0775ec541dd0dd8`。 + - 暗色 SHA-256:`0bc5e0b70c02f557cac4f7e57b3f8280495b951ee0b1ff4bbd6e5077f0c00ee1`。 +- 校正两张组件基线并纳入视觉回归入口。在 example 目录运行 `flutter test --no-pub test/stepper_demo_golden_test.dart ../test/components/stepper/t_stepper_golden_test.dart`,四项通过,精确 diff 为 0;未放宽比较器容差。 + +## 验证边界与交付 + +- 第二套本地 SDK 是 3.47.0。首次修复已推送到 `36620b81`;手机验收修复和最新 `develop` 合并已在本地完成,推送后须等待远端 actual latest 与新 head 的 CNB review,不能沿用旧 head 的结果。 +- iOS 18.2 模拟器已验证实际点击、文本输入、明暗主题和代码面板;Android/iOS 真机 IME 与系统字体仍未验证,Linux Golden 不证明这些路径。 +- 公开签名、枚举和默认值保持兼容,行为变化属于既有契约修复,无迁移要求。 diff --git a/specs/028-stepper-demo-alignment/evidence/miniprogram-top.png b/specs/028-stepper-demo-alignment/evidence/miniprogram-top.png new file mode 100644 index 000000000..d5e55aeea Binary files /dev/null and b/specs/028-stepper-demo-alignment/evidence/miniprogram-top.png differ diff --git a/specs/028-stepper-demo-alignment/plan.md b/specs/028-stepper-demo-alignment/plan.md new file mode 100644 index 000000000..08811978e --- /dev/null +++ b/specs/028-stepper-demo-alignment/plan.md @@ -0,0 +1,8 @@ +# Plan + +1. 基于 #1064 head `5bdc6b41` 修复,保留用户当前 develop 工作区。 +2. 将运行示例提取为独立 Widget;生成器提取 Widget、必要的直接关联 State 和导入,面板显式绑定类名。 +3. 按 Figma 去掉 99、修正禁用初值和容器布局;保持现有尺寸和公开 API。 +4. 统一草稿与按钮边界判断;主题插值保留端点配置,在内部样式解析处使用真实上下文默认值。 +5. 补组件、完整 Demo、代码面板及生成器回归;代码模式保持运行示例子树稳定;验证 95% 生产覆盖率和双 SDK 严格 analyze。 +6. Flutter 3.32 Linux 更新并立即复跑明暗 Golden,记录实际差异与未覆盖项。 diff --git a/specs/028-stepper-demo-alignment/spec.md b/specs/028-stepper-demo-alignment/spec.md new file mode 100644 index 000000000..4a69d661b --- /dev/null +++ b/specs/028-stepper-demo-alignment/spec.md @@ -0,0 +1,21 @@ +# Stepper 公开 Demo 对齐 + +## 目标 + +以 Figma `mdBVCCVGERhxoZLle2eLT0 / 28591:40152` 为展示目标,修复基础、边界、禁用、形态与尺寸示例,并建立独立行为与视觉回归。小程序仅作为 API 和交互参考;设计稿已删除的中间边界示例不再保留。 + +## API Review + +- `value + onChanged` 为严格受控模式;`onChanged == null` 表示整组禁用,不引入 `defaultValue` 或重复的 `disabled`。 +- `min/max/step` 负责数值约束,`TStepperVariant` 与 `TStepperSize` 负责互斥视觉语义。 +- `variant/size` 的实例值、组件 Theme 与默认值优先级已收敛,无需复制小程序字符串 API。 +- 保持现有公开构造参数、枚举和默认值,不新增小程序专属参数。修复编辑草稿与按钮可用状态、nullable Theme 插值;属于既有契约修复,不删除能力或修改公开签名。 + +## 行为契约 + +- Demo 保持三个分组、五个代码入口,共十个步进器;边界只展示 0 / 999,禁用值为 0。基础与禁用左对齐,各块使用完整宽度、组件容器底色和 16px 内边距。 +- 最小/最大状态分别由受控值等于边界表达,禁用由空 callback 表达。 +- 所有可交互实例由各自 StatefulWidget 示例持有状态,主题切换、代码模式切换和页面重建不重置编辑后的值。 +- 五个面板展示实际运行的示例 Widget、对应 State 和依赖导入;页面壳不进入片段。生成器沿用 ExampleCode 注解,支持独立 Widget 及同文件直接关联 State 的提取,不添加示例框架参数。 +- 编辑期间按钮启停和步进运算使用同一个有效草稿值;合法草稿即使从边界开始编辑,也可向两个方向步进。父组件仍为唯一提交值源,拒绝请求时回退,单次操作最多通知一次。 +- Theme 双侧 null 保持继承;单侧 null 按组件当前生效的尺寸、Flutter 显式主题和 TD token 解析后插值,不当作 0 或透明色。插值端点保持原始配置,动态主题和实例尺寸覆盖路径均须验证。 diff --git a/specs/028-stepper-demo-alignment/tasks.md b/specs/028-stepper-demo-alignment/tasks.md new file mode 100644 index 000000000..fed45b1d4 --- /dev/null +++ b/specs/028-stepper-demo-alignment/tasks.md @@ -0,0 +1,12 @@ +# Tasks + +- [x] 固定 #1064 head、Figma 节点和小程序参考版本,完成 API Review。 +- [x] 修正边界数量、禁用初值、容器和对齐方式。 +- [x] 五份生成片段包含独立 Widget、必要 State 和导入;验证实际代码面板及最小宿主编译、交互。 +- [x] 在 iPhone 16 / iOS 18.2 模拟器完成点击验收,修复开启代码模式会重置运行示例状态的问题并补回归。 +- [x] 统一草稿步进与按钮边界判断,覆盖父组件接受和拒绝请求。 +- [x] 修正 nullable Theme 插值,覆盖尺寸、继承、token、copyWith、嵌套插值和 AnimatedTheme。 +- [x] 完成本地 Flutter 3.32.0 / 3.47.0 回归、严格 analyze、98.51% 生产覆盖率及 CI 工具自测;合并最新 `develop` 后再次通过。 +- [x] 检查并更新 Linux Demo 和组件明暗 Golden;四张图精确复跑通过,登记视觉回归入口。 +- [x] 提交、推送并同步 GitHub/CNB PR 标题与描述;触发 CNB 定向 review。 +- [ ] 推送手机验收修复后验证远端实际 latest 并重新触发 CNB review;Android/iOS 真机键盘、IME 和系统字体仍待验证。 diff --git a/tdesign-component/example/assets/api/stepper_api.md b/tdesign-component/example/assets/api/stepper_api.md index 4c772ee9b..d272e55ed 100644 --- a/tdesign-component/example/assets/api/stepper_api.md +++ b/tdesign-component/example/assets/api/stepper_api.md @@ -9,7 +9,7 @@ | min | num | 0 | 最小值,必须小于或等于 `max`。 | | onChanged | ValueChanged? | - | 数值变化请求。 点击按钮、提交有效输入或输入框失焦时触发;一次操作最多触发一次。 为 null 时整组禁用。 | | size | TStepperSize? | - | 组件尺寸。 为空时依次使用 `TStepperThemeData.size` 和 `TStepperSize.medium`。 | -| step | num | 1 | 加减按钮使用的步长,必须大于 0。 输入提交不要求是步长的整数倍,但会限制在 `min` 与 `max` 之间。 | +| step | num | 1 | 加减按钮使用的步长,必须大于 0。 输入提交不要求是步长的整数倍,但会限制在 `min` 与 `max` 之间。 编辑时以合法输入草稿作为步进起点,并据此判断按钮是否达到边界。 | | value | num | - | 唯一受控数值,必须位于 `min` 与 `max` 之间。 父组件需要在 `onChanged` 后以新值重建组件,否则输入内容会恢复。 | | variant | TStepperVariant? | - | 组件形态。 为空时依次使用 `TStepperThemeData.variant` 和 `TStepperVariant.normal`。 | diff --git a/tdesign-component/example/assets/code/stepper.StepperBaseExample.txt b/tdesign-component/example/assets/code/stepper.StepperBaseExample.txt new file mode 100644 index 000000000..04bbe8a21 --- /dev/null +++ b/tdesign-component/example/assets/code/stepper.StepperBaseExample.txt @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +/// 在已配置 TDesign 主题的应用中使用 `StepperBaseExample()`。 +class StepperBaseExample extends StatefulWidget { + const StepperBaseExample({super.key}); + + @override + State createState() => _StepperBaseExampleState(); +} + +class _StepperBaseExampleState extends State { + num _base = 3; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + TStepper( + key: const ValueKey('stepper-base'), + value: _base, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _base = value), + ), + ], + ); + } +} diff --git a/tdesign-component/example/assets/code/stepper.StepperBoundsExample.txt b/tdesign-component/example/assets/code/stepper.StepperBoundsExample.txt new file mode 100644 index 000000000..073692624 --- /dev/null +++ b/tdesign-component/example/assets/code/stepper.StepperBoundsExample.txt @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +/// 在已配置 TDesign 主题的应用中使用 `StepperBoundsExample()`。 +class StepperBoundsExample extends StatefulWidget { + const StepperBoundsExample({super.key}); + + @override + State createState() => _StepperBoundsExampleState(); +} + +class _StepperBoundsExampleState extends State { + num _minimum = 0; + num _maximum = 999; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + TStepper( + key: const ValueKey('stepper-minimum'), + value: _minimum, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _minimum = value), + ), + const SizedBox(width: 32), + TStepper( + key: const ValueKey('stepper-maximum'), + value: _maximum, + max: 999, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _maximum = value), + ), + ], + ); + } +} diff --git a/tdesign-component/example/assets/code/stepper.StepperDisabledExample.txt b/tdesign-component/example/assets/code/stepper.StepperDisabledExample.txt new file mode 100644 index 000000000..6f6e370c8 --- /dev/null +++ b/tdesign-component/example/assets/code/stepper.StepperDisabledExample.txt @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +/// 在已配置 TDesign 主题的应用中使用 `StepperDisabledExample()`。 +class StepperDisabledExample extends StatelessWidget { + const StepperDisabledExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + TStepper( + key: ValueKey('stepper-disabled'), + value: 0, + variant: TStepperVariant.filled, + ), + ], + ); + } +} diff --git a/tdesign-component/example/assets/code/stepper.StepperSizesExample.txt b/tdesign-component/example/assets/code/stepper.StepperSizesExample.txt new file mode 100644 index 000000000..30ad53e69 --- /dev/null +++ b/tdesign-component/example/assets/code/stepper.StepperSizesExample.txt @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +/// 在已配置 TDesign 主题的应用中使用 `StepperSizesExample()`。 +class StepperSizesExample extends StatefulWidget { + const StepperSizesExample({super.key}); + + @override + State createState() => _StepperSizesExampleState(); +} + +class _StepperSizesExampleState extends State { + num _large = 3; + num _medium = 3; + num _small = 3; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TStepper( + key: const ValueKey('stepper-sizes'), + value: _large, + size: TStepperSize.large, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _large = value), + ), + TStepper( + key: const ValueKey('stepper-medium'), + value: _medium, + size: TStepperSize.medium, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _medium = value), + ), + TStepper( + key: const ValueKey('stepper-small'), + value: _small, + size: TStepperSize.small, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _small = value), + ), + ], + ); + } +} diff --git a/tdesign-component/example/assets/code/stepper.StepperVariantsExample.txt b/tdesign-component/example/assets/code/stepper.StepperVariantsExample.txt new file mode 100644 index 000000000..7e9387981 --- /dev/null +++ b/tdesign-component/example/assets/code/stepper.StepperVariantsExample.txt @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +/// 在已配置 TDesign 主题的应用中使用 `StepperVariantsExample()`。 +class StepperVariantsExample extends StatefulWidget { + const StepperVariantsExample({super.key}); + + @override + State createState() => _StepperVariantsExampleState(); +} + +class _StepperVariantsExampleState extends State { + num _filled = 3; + num _outline = 3; + num _normal = 3; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TStepper( + key: const ValueKey('stepper-variants'), + value: _filled, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _filled = value), + ), + TStepper( + key: const ValueKey('stepper-outline'), + value: _outline, + variant: TStepperVariant.outline, + onChanged: (value) => setState(() => _outline = value), + ), + TStepper( + key: const ValueKey('stepper-normal'), + value: _normal, + variant: TStepperVariant.normal, + onChanged: (value) => setState(() => _normal = value), + ), + ], + ); + } +} diff --git a/tdesign-component/example/assets/code/stepper._buildBase.txt b/tdesign-component/example/assets/code/stepper._buildBase.txt deleted file mode 100644 index 62a0fd0d6..000000000 --- a/tdesign-component/example/assets/code/stepper._buildBase.txt +++ /dev/null @@ -1,6 +0,0 @@ -Widget _buildBase(BuildContext context) => _buildRow(context, [ - TStepper( - value: _baseValue, - onChanged: (value) => setState(() => _baseValue = value), - ), - ]); diff --git a/tdesign-component/example/assets/code/stepper._buildSizes.txt b/tdesign-component/example/assets/code/stepper._buildSizes.txt deleted file mode 100644 index 69bb40614..000000000 --- a/tdesign-component/example/assets/code/stepper._buildSizes.txt +++ /dev/null @@ -1,20 +0,0 @@ -Widget _buildSizes(BuildContext context) => _buildRow(context, [ - TStepper( - value: _smallValue, - size: TStepperSize.small, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _smallValue = value), - ), - TStepper( - value: _mediumValue, - size: TStepperSize.medium, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _mediumValue = value), - ), - TStepper( - value: _largeValue, - size: TStepperSize.large, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _largeValue = value), - ), - ]); diff --git a/tdesign-component/example/assets/code/stepper._buildStates.txt b/tdesign-component/example/assets/code/stepper._buildStates.txt deleted file mode 100644 index 888379033..000000000 --- a/tdesign-component/example/assets/code/stepper._buildStates.txt +++ /dev/null @@ -1,20 +0,0 @@ -Widget _buildStates(BuildContext context) => _buildRow(context, [ - TStepper( - value: _minValue, - min: 0, - max: 10, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _minValue = value), - ), - TStepper( - value: _maxValue, - min: 0, - max: 10, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _maxValue = value), - ), - const TStepper( - value: 5, - variant: TStepperVariant.filled, - ), - ]); diff --git a/tdesign-component/example/assets/code/stepper._buildSteps.txt b/tdesign-component/example/assets/code/stepper._buildSteps.txt deleted file mode 100644 index 962596520..000000000 --- a/tdesign-component/example/assets/code/stepper._buildSteps.txt +++ /dev/null @@ -1,18 +0,0 @@ -Widget _buildSteps(BuildContext context) => _buildRow(context, [ - TStepper( - value: _integerStepValue, - min: 0, - max: 20, - step: 2, - variant: TStepperVariant.outline, - onChanged: (value) => setState(() => _integerStepValue = value), - ), - TStepper( - value: _decimalStepValue, - min: 0, - max: 1, - step: 0.1, - variant: TStepperVariant.outline, - onChanged: (value) => setState(() => _decimalStepValue = value), - ), - ]); diff --git a/tdesign-component/example/assets/code/stepper._buildTheme.txt b/tdesign-component/example/assets/code/stepper._buildTheme.txt deleted file mode 100644 index 9060b912b..000000000 --- a/tdesign-component/example/assets/code/stepper._buildTheme.txt +++ /dev/null @@ -1,19 +0,0 @@ -Widget _buildTheme(BuildContext context) { - return Theme( - data: Theme.of(context).mergeExtension( - TStepperThemeData( - size: TStepperSize.large, - variant: TStepperVariant.filled, - inputWidth: 72, - foregroundColor: context.tTheme.successNormalColor, - backgroundColor: context.tTheme.successLightColor, - ), - ), - child: _buildRow(context, [ - TStepper( - value: _themedValue, - onChanged: (value) => setState(() => _themedValue = value), - ), - ]), - ); - } diff --git a/tdesign-component/example/assets/code/stepper._buildVariants.txt b/tdesign-component/example/assets/code/stepper._buildVariants.txt deleted file mode 100644 index 98121acf3..000000000 --- a/tdesign-component/example/assets/code/stepper._buildVariants.txt +++ /dev/null @@ -1,17 +0,0 @@ -Widget _buildVariants(BuildContext context) => _buildRow(context, [ - TStepper( - value: _normalValue, - variant: TStepperVariant.normal, - onChanged: (value) => setState(() => _normalValue = value), - ), - TStepper( - value: _filledValue, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _filledValue = value), - ), - TStepper( - value: _outlineValue, - variant: TStepperVariant.outline, - onChanged: (value) => setState(() => _outlineValue = value), - ), - ]); diff --git a/tdesign-component/example/lib/annotation/example_code.dart b/tdesign-component/example/lib/annotation/example_code.dart index c2b110fd6..42e283862 100644 --- a/tdesign-component/example/lib/annotation/example_code.dart +++ b/tdesign-component/example/lib/annotation/example_code.dart @@ -1,4 +1,7 @@ -/// Marks a method whose source is exported for the example code viewer. +/// Marks a function, method, or standalone example Widget for the code viewer. +/// +/// An annotated class includes its imports and directly associated same-file +/// `State` class. Other helpers must be self-contained in these classes. class ExampleCode { /// The generated snippet group. It must match the page's example code group. final String group; diff --git a/tdesign-component/example/lib/base/example_widget.dart b/tdesign-component/example/lib/base/example_widget.dart index a2cce50be..232ffe453 100644 --- a/tdesign-component/example/lib/base/example_widget.dart +++ b/tdesign-component/example/lib/base/example_widget.dart @@ -759,10 +759,11 @@ class _CodeWrapperState extends State { if (widget.isCenter) { child = Center(child: child); } - if (apiVisible) { - child = Stack( - children: [ - child, + return Stack( + fit: StackFit.passthrough, + children: [ + child, + if (apiVisible) Positioned( top: 0, bottom: 0, @@ -777,10 +778,8 @@ class _CodeWrapperState extends State { ), ), ), - ], - ); - } - return child; + ], + ); } String _getCodeAssetsPath() { diff --git a/tdesign-component/example/lib/page/stepper/stepper_examples.dart b/tdesign-component/example/lib/page/stepper/stepper_examples.dart new file mode 100644 index 000000000..31aa7f9bd --- /dev/null +++ b/tdesign-component/example/lib/page/stepper/stepper_examples.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +import '../../annotation/example_code.dart'; + +/// 在已配置 TDesign 主题的应用中使用 `StepperBaseExample()`。 +@ExampleCode(group: 'stepper') +class StepperBaseExample extends StatefulWidget { + const StepperBaseExample({super.key}); + + @override + State createState() => _StepperBaseExampleState(); +} + +class _StepperBaseExampleState extends State { + num _base = 3; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + TStepper( + key: const ValueKey('stepper-base'), + value: _base, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _base = value), + ), + ], + ); + } +} + +/// 在已配置 TDesign 主题的应用中使用 `StepperBoundsExample()`。 +@ExampleCode(group: 'stepper') +class StepperBoundsExample extends StatefulWidget { + const StepperBoundsExample({super.key}); + + @override + State createState() => _StepperBoundsExampleState(); +} + +class _StepperBoundsExampleState extends State { + num _minimum = 0; + num _maximum = 999; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + TStepper( + key: const ValueKey('stepper-minimum'), + value: _minimum, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _minimum = value), + ), + const SizedBox(width: 32), + TStepper( + key: const ValueKey('stepper-maximum'), + value: _maximum, + max: 999, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _maximum = value), + ), + ], + ); + } +} + +/// 在已配置 TDesign 主题的应用中使用 `StepperDisabledExample()`。 +@ExampleCode(group: 'stepper') +class StepperDisabledExample extends StatelessWidget { + const StepperDisabledExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + TStepper( + key: ValueKey('stepper-disabled'), + value: 0, + variant: TStepperVariant.filled, + ), + ], + ); + } +} + +/// 在已配置 TDesign 主题的应用中使用 `StepperVariantsExample()`。 +@ExampleCode(group: 'stepper') +class StepperVariantsExample extends StatefulWidget { + const StepperVariantsExample({super.key}); + + @override + State createState() => _StepperVariantsExampleState(); +} + +class _StepperVariantsExampleState extends State { + num _filled = 3; + num _outline = 3; + num _normal = 3; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TStepper( + key: const ValueKey('stepper-variants'), + value: _filled, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _filled = value), + ), + TStepper( + key: const ValueKey('stepper-outline'), + value: _outline, + variant: TStepperVariant.outline, + onChanged: (value) => setState(() => _outline = value), + ), + TStepper( + key: const ValueKey('stepper-normal'), + value: _normal, + variant: TStepperVariant.normal, + onChanged: (value) => setState(() => _normal = value), + ), + ], + ); + } +} + +/// 在已配置 TDesign 主题的应用中使用 `StepperSizesExample()`。 +@ExampleCode(group: 'stepper') +class StepperSizesExample extends StatefulWidget { + const StepperSizesExample({super.key}); + + @override + State createState() => _StepperSizesExampleState(); +} + +class _StepperSizesExampleState extends State { + num _large = 3; + num _medium = 3; + num _small = 3; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TStepper( + key: const ValueKey('stepper-sizes'), + value: _large, + size: TStepperSize.large, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _large = value), + ), + TStepper( + key: const ValueKey('stepper-medium'), + value: _medium, + size: TStepperSize.medium, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _medium = value), + ), + TStepper( + key: const ValueKey('stepper-small'), + value: _small, + size: TStepperSize.small, + variant: TStepperVariant.filled, + onChanged: (value) => setState(() => _small = value), + ), + ], + ); + } +} diff --git a/tdesign-component/example/lib/page/t_stepper_page.dart b/tdesign-component/example/lib/page/t_stepper_page.dart index 295e9472c..aa373b669 100644 --- a/tdesign-component/example/lib/page/t_stepper_page.dart +++ b/tdesign-component/example/lib/page/t_stepper_page.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; -import '../annotation/example_code.dart'; import '../base/example_widget.dart'; +import 'stepper/stepper_examples.dart'; class TStepperPage extends StatefulWidget { const TStepperPage({super.key}); @@ -12,180 +12,64 @@ class TStepperPage extends StatefulWidget { } class _TStepperPageState extends State { - num _baseValue = 1; - num _normalValue = 2; - num _filledValue = 3; - num _outlineValue = 4; - num _smallValue = 1; - num _mediumValue = 2; - num _largeValue = 3; - num _minValue = 0; - num _maxValue = 10; - num _integerStepValue = 2; - num _decimalStepValue = 0.2; - num _themedValue = 5; - @override - Widget build(BuildContext context) { - return ExamplePage( - title: tTitle(), - desc: '用于数量的增减。', - exampleCodeGroup: 'stepper', - children: [ - ExampleModule( - title: '组件类型', - children: [ - ExampleItem(desc: '基础步进器', builder: _buildBase), - ExampleItem(desc: '步进器形态', builder: _buildVariants), - ], - ), - ExampleModule( - title: '组件尺寸', - children: [ - ExampleItem(desc: '小、中、大', builder: _buildSizes), - ], - ), - ExampleModule( - title: '组件状态', - children: [ - ExampleItem(desc: '边界与整组禁用', builder: _buildStates), - ExampleItem(desc: '整数与小数步长', builder: _buildSteps), - ], - ), - ExampleModule( - title: '主题', - children: [ - ExampleItem(desc: '局部 ThemeData', builder: _buildTheme), - ], - ), - ], - test: const [], - ); - } - - @ExampleCode(group: 'stepper') - Widget _buildBase(BuildContext context) => _buildRow(context, [ - TStepper( - value: _baseValue, - onChanged: (value) => setState(() => _baseValue = value), - ), - ]); - - @ExampleCode(group: 'stepper') - Widget _buildVariants(BuildContext context) => _buildRow(context, [ - TStepper( - value: _normalValue, - variant: TStepperVariant.normal, - onChanged: (value) => setState(() => _normalValue = value), - ), - TStepper( - value: _filledValue, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _filledValue = value), - ), - TStepper( - value: _outlineValue, - variant: TStepperVariant.outline, - onChanged: (value) => setState(() => _outlineValue = value), - ), - ]); - - @ExampleCode(group: 'stepper') - Widget _buildSizes(BuildContext context) => _buildRow(context, [ - TStepper( - value: _smallValue, - size: TStepperSize.small, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _smallValue = value), - ), - TStepper( - value: _mediumValue, - size: TStepperSize.medium, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _mediumValue = value), - ), - TStepper( - value: _largeValue, - size: TStepperSize.large, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _largeValue = value), - ), - ]); - - @ExampleCode(group: 'stepper') - Widget _buildStates(BuildContext context) => _buildRow(context, [ - TStepper( - value: _minValue, - min: 0, - max: 10, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _minValue = value), - ), - TStepper( - value: _maxValue, - min: 0, - max: 10, - variant: TStepperVariant.filled, - onChanged: (value) => setState(() => _maxValue = value), - ), - const TStepper( - value: 5, - variant: TStepperVariant.filled, - ), - ]); - - @ExampleCode(group: 'stepper') - Widget _buildSteps(BuildContext context) => _buildRow(context, [ - TStepper( - value: _integerStepValue, - min: 0, - max: 20, - step: 2, - variant: TStepperVariant.outline, - onChanged: (value) => setState(() => _integerStepValue = value), - ), - TStepper( - value: _decimalStepValue, - min: 0, - max: 1, - step: 0.1, - variant: TStepperVariant.outline, - onChanged: (value) => setState(() => _decimalStepValue = value), - ), - ]); - - @ExampleCode(group: 'stepper') - Widget _buildTheme(BuildContext context) { - return Theme( - data: Theme.of(context).mergeExtension( - TStepperThemeData( - size: TStepperSize.large, - variant: TStepperVariant.filled, - inputWidth: 72, - foregroundColor: context.tTheme.successNormalColor, - backgroundColor: context.tTheme.successLightColor, - ), + Widget build(BuildContext context) => ExamplePage( + title: tTitle(), + desc: '用于数量的增减。', + exampleCodeGroup: 'stepper', + compactDemo: true, + showTestModule: false, + children: [ + ExampleModule( + title: '组件类型', + children: [ + ExampleItem( + desc: '基础步进器', + center: false, + methodName: 'StepperBaseExample', + builder: (_) => _container(const StepperBaseExample()), + ), + ], ), - child: _buildRow(context, [ - TStepper( - value: _themedValue, - onChanged: (value) => setState(() => _themedValue = value), - ), - ]), - ); - } - - Widget _buildRow(BuildContext context, List children) { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - color: context.tTheme.bgColorContainer, - child: Wrap( - spacing: 16, - runSpacing: 16, - crossAxisAlignment: WrapCrossAlignment.center, - children: children, + ExampleModule( + title: '组件状态', + children: [ + ExampleItem( + desc: '最大最小状态', + center: false, + methodName: 'StepperBoundsExample', + builder: (_) => _container(const StepperBoundsExample()), + ), + ExampleItem( + desc: '禁用状态', + center: false, + methodName: 'StepperDisabledExample', + builder: (_) => _container(const StepperDisabledExample()), + ), + ], ), - ); - } + ExampleModule( + title: '组件样式', + children: [ + ExampleItem( + desc: '步进器样式', + center: false, + methodName: 'StepperVariantsExample', + builder: (_) => _container(const StepperVariantsExample()), + ), + ExampleItem( + desc: '步进器尺寸', + center: false, + methodName: 'StepperSizesExample', + builder: (_) => _container(const StepperSizesExample()), + ), + ], + ), + ], + ); + + Widget _container(Widget child) => ColoredBox( + color: context.tTheme.bgColorContainer, + child: Padding(padding: const EdgeInsets.all(16), child: child), + ); } diff --git a/tdesign-component/example/test/demo_page_test_utils.dart b/tdesign-component/example/test/demo_page_test_utils.dart index 46f7e132a..02ad6f233 100644 --- a/tdesign-component/example/test/demo_page_test_utils.dart +++ b/tdesign-component/example/test/demo_page_test_utils.dart @@ -174,6 +174,15 @@ Future _loadGoldenFonts(DemoPageTestSpec spec) async { ), ); } + if (spec.supplementalCjkFontFamily case final family?) { + final supplementalCjkFont = FontLoader(family) + ..addFont( + File( + spec.supplementalCjkFontPath!, + ).readAsBytes().then(ByteData.sublistView), + ); + loaders.add(supplementalCjkFont.load()); + } await Future.wait(loaders); } diff --git a/tdesign-component/example/test/fonts/README.md b/tdesign-component/example/test/fonts/README.md index 1e6897e52..e27ed2865 100644 --- a/tdesign-component/example/test/fonts/README.md +++ b/tdesign-component/example/test/fonts/README.md @@ -16,6 +16,10 @@ NoticeBar 新增整页 Golden 的独立字体子集,字符清单见 新增文案的补充 fallback。它排在原字体之后,避免扩充原字体改变 Button、Checkbox、 Upload 等既有 Golden 的字形选择与像素基线。 +`StepperGoldenCJK-Regular.otf` 仅补充 Stepper 整页 Golden 的“步”字,字符清单见 +`stepper_demo_glyphs.txt`,不会改变既有组件的字形选择。子集 SHA-256 为 +`6df8e74aa398c55ff2140025fd79c31300653bc02ddf31d35ec94f9c5ae3d0d0`。 + `StepsGoldenCJK-Regular.otf` 仅补充 Steps 整页 Golden 所需字形,字符清单见 `steps_demo_glyphs.txt`。子集 SHA-256 为 `1c4a9391deda7834d5f3357e20d8bfcdd97be685e021ed31d2bde4166cdf9524`;源文件为 @@ -92,6 +96,7 @@ HarfBuzz 11.4.5,子集 SHA-256 为 Textarea、Upload、PullDownRefresh、Toast 或 SwipeCell Demo 页面文案后,更新原字符清单; 更新 Loading、Message、Popover 或 Popup 页面文案时,更新补充字符清单;更新 ActionSheet、Dialog、DropdownMenu 或 NoticeBar 页面文案时,更新 feedback 字符清单。 +更新 Stepper 页面文案时,更新 Stepper 字符清单。 更新 Slider 页面文案时,更新 Slider 字符清单。 更新 Picker 页面文案时,更新 Picker 字符清单。 更新 Calendar 页面文案时,更新 Calendar 字符清单。 diff --git a/tdesign-component/example/test/fonts/StepperGoldenCJK-Regular.otf b/tdesign-component/example/test/fonts/StepperGoldenCJK-Regular.otf new file mode 100644 index 000000000..55cac4fc9 Binary files /dev/null and b/tdesign-component/example/test/fonts/StepperGoldenCJK-Regular.otf differ diff --git a/tdesign-component/example/test/fonts/stepper_demo_glyphs.txt b/tdesign-component/example/test/fonts/stepper_demo_glyphs.txt new file mode 100644 index 000000000..c81d8e6b0 --- /dev/null +++ b/tdesign-component/example/test/fonts/stepper_demo_glyphs.txt @@ -0,0 +1 @@ +步 diff --git a/tdesign-component/example/test/goldens/stepper_page_dark.png b/tdesign-component/example/test/goldens/stepper_page_dark.png new file mode 100644 index 000000000..9baba17a6 Binary files /dev/null and b/tdesign-component/example/test/goldens/stepper_page_dark.png differ diff --git a/tdesign-component/example/test/goldens/stepper_page_light.png b/tdesign-component/example/test/goldens/stepper_page_light.png new file mode 100644 index 000000000..428d1a91d Binary files /dev/null and b/tdesign-component/example/test/goldens/stepper_page_light.png differ diff --git a/tdesign-component/example/test/stepper_demo_golden_test.dart b/tdesign-component/example/test/stepper_demo_golden_test.dart new file mode 100644 index 000000000..635c56c0b --- /dev/null +++ b/tdesign-component/example/test/stepper_demo_golden_test.dart @@ -0,0 +1,4 @@ +import 'demo_page_test_utils.dart'; +import 'stepper_demo_test_spec.dart'; + +void main() => registerDemoGoldenTests(stepperDemoPageTestSpec); diff --git a/tdesign-component/example/test/stepper_demo_test.dart b/tdesign-component/example/test/stepper_demo_test.dart new file mode 100644 index 000000000..396063e17 --- /dev/null +++ b/tdesign-component/example/test/stepper_demo_test.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; +import 'package:tdesign_flutter_example/base/example_base.dart'; +import 'package:tdesign_flutter_example/page/t_stepper_page.dart'; +import 'package:tdesign_flutter_example/provider/theme_mode_provider.dart'; + +import 'demo_page_test_utils.dart'; +import 'stepper_demo_test_spec.dart'; + +void main() { + registerDemoStructureTests(stepperDemoPageTestSpec); + + testWidgets('Stepper Demo follows official scenario order', (tester) async { + await pumpFullDemoPage(tester, stepperDemoPageTestSpec, ThemeMode.light); + final keys = ['base', 'minimum', 'disabled', 'variants', 'sizes']; + final tops = keys + .map((id) => tester.getTopLeft(find.byKey(ValueKey('stepper-$id'))).dy) + .toList(); + expect(tops, orderedEquals([...tops]..sort())); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('bounds and disabled state use the public contract', ( + tester, + ) async { + await pumpFullDemoPage(tester, stepperDemoPageTestSpec, ThemeMode.light); + final minimum = tester.widget( + find.byKey(const ValueKey('stepper-minimum')), + ); + final maximum = tester.widget( + find.byKey(const ValueKey('stepper-maximum')), + ); + final disabled = tester.widget( + find.byKey(const ValueKey('stepper-disabled')), + ); + expect(minimum.value, minimum.min); + expect(maximum.value, maximum.max); + expect(disabled.onChanged, isNull); + expect(disabled.value, 0); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('Stepper Demo values remain controlled', (tester) async { + await pumpFullDemoPage(tester, stepperDemoPageTestSpec, ThemeMode.light); + final stepper = find.byKey(const ValueKey('stepper-base')); + expect(tester.widget(stepper).value, 3); + await tester.tap( + find.descendant(of: stepper, matching: find.bySemanticsLabel('增加')), + ); + await tester.pump(); + expect(tester.widget(stepper).value, 4); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('all design instances, styles and container bounds match', ( + tester, + ) async { + await pumpFullDemoPage(tester, stepperDemoPageTestSpec, ThemeMode.light); + final steppers = tester + .widgetList(find.byType(TStepper)) + .toList(); + expect(steppers.map((stepper) => stepper.value), [ + 3, + 0, + 999, + 0, + 3, + 3, + 3, + 3, + 3, + 3, + ]); + expect(steppers.map((stepper) => stepper.variant), [ + TStepperVariant.filled, + TStepperVariant.filled, + TStepperVariant.filled, + TStepperVariant.filled, + TStepperVariant.filled, + TStepperVariant.outline, + TStepperVariant.normal, + TStepperVariant.filled, + TStepperVariant.filled, + TStepperVariant.filled, + ]); + expect( + steppers.skip(7).map((stepper) => stepper.size), + TStepperSize.values.reversed, + ); + for (final key in ['base', 'minimum', 'disabled', 'variants', 'sizes']) { + final stepper = find.byKey(ValueKey('stepper-$key')); + expect(tester.getTopLeft(stepper).dx, 16); + final container = find + .ancestor(of: stepper, matching: find.byType(ColoredBox)) + .first; + expect(tester.getSize(container).width, 375); + expect( + tester.widget(container).color, + Theme.of( + tester.element(stepper), + ).extension()!.bgColorContainer, + ); + expect( + tester.getTopLeft(stepper).dy - tester.getTopLeft(container).dy, + 16, + ); + } + final minimum = find.byKey(const ValueKey('stepper-minimum')); + final maximum = find.byKey(const ValueKey('stepper-maximum')); + expect(tester.getTopLeft(maximum).dx - tester.getTopRight(minimum).dx, 32); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('theme rebuild preserves each example state', (tester) async { + await pumpFullDemoPage(tester, stepperDemoPageTestSpec, ThemeMode.light); + for (final key in ['base', 'minimum', 'variants', 'sizes']) { + final stepper = find.byKey(ValueKey('stepper-$key')); + await tester.tap( + find.descendant(of: stepper, matching: find.bySemanticsLabel('增加')), + ); + await tester.pump(); + } + await pumpFullDemoPage(tester, stepperDemoPageTestSpec, ThemeMode.dark); + for (final key in ['base', 'minimum', 'variants', 'sizes']) { + expect( + tester.widget(find.byKey(ValueKey('stepper-$key'))).value, + key == 'minimum' ? 1 : 4, + ); + } + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets( + 'all five real code panels load their complete generated example', + (tester) async { + tester.view.physicalSize = const Size(375, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final model = ExamplePageModel( + text: 'Stepper 步进器', + name: 'stepper', + pageBuilder: (_, __) => const TStepperPage(), + )..showAction = true; + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => ThemeModeProvider(), + child: MaterialApp( + theme: TThemeBuilder.light(TThemeData.defaultData()), + home: ExamplePageInheritedTheme( + model: model, + child: const TStepperPage(), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final base = find.byKey(const ValueKey('stepper-base')); + await tester.tap( + find.descendant(of: base, matching: find.bySemanticsLabel('增加')), + ); + await tester.pump(); + expect(tester.widget(base).value, 4); + await tester.tap(find.byIcon(TIcons.code)); + await tester.pumpAndSettle(); + expect(find.text('code'), findsNWidgets(5)); + expect(tester.widget(base).value, 4); + const names = [ + 'StepperBaseExample', + 'StepperBoundsExample', + 'StepperDisabledExample', + 'StepperVariantsExample', + 'StepperSizesExample', + ]; + for (var i = 0; i < names.length; i++) { + await tester.tap(find.text('code').at(i)); + await tester.pumpAndSettle(); + final expected = await rootBundle.loadString( + 'assets/code/stepper.${names[i]}.txt', + ); + final content = tester.widget(find.byType(Markdown)).data; + expect(content, contains(expected)); + Navigator.of(tester.element(find.byType(Markdown))).pop(); + await tester.pumpAndSettle(); + } + expect(tester.widget(base).value, 4); + await disposeDemoPage(tester); + }, + tags: 'demo', + ); +} diff --git a/tdesign-component/example/test/stepper_demo_test_spec.dart b/tdesign-component/example/test/stepper_demo_test_spec.dart new file mode 100644 index 000000000..75630772a --- /dev/null +++ b/tdesign-component/example/test/stepper_demo_test_spec.dart @@ -0,0 +1,26 @@ +import 'package:tdesign_flutter/tdesign_flutter.dart'; +import 'package:tdesign_flutter_example/page/t_stepper_page.dart'; + +import 'demo_page_test_utils.dart'; + +const stepperDemoPageTestSpec = DemoPageTestSpec( + name: 'stepper', + title: 'Stepper 步进器', + page: TStepperPage(), + componentType: TStepper, + expectedComponentCount: 10, + expectedTexts: [ + '用于数量的增减。', + '01 组件类型', + '基础步进器', + '02 组件状态', + '最大最小状态', + '禁用状态', + '03 组件样式', + '步进器样式', + '步进器尺寸', + ], + useAlignmentCjkFont: true, + supplementalCjkFontFamily: 'TDesign Stepper Golden CJK', + supplementalCjkFontPath: 'test/fonts/StepperGoldenCJK-Regular.otf', +); diff --git a/tdesign-component/lib/src/components/stepper/t_stepper.dart b/tdesign-component/lib/src/components/stepper/t_stepper.dart index 440812ae9..7a4bec8af 100644 --- a/tdesign-component/lib/src/components/stepper/t_stepper.dart +++ b/tdesign-component/lib/src/components/stepper/t_stepper.dart @@ -7,7 +7,9 @@ import 'package:tdesign_flutter_icons/tdesign_flutter_icons.dart' show TIcons; import '../../theme/t_colors.dart'; import '../../theme/t_radius.dart'; import '../../theme/t_theme.dart'; +import 't_stepper_defaults.dart'; import 't_stepper_theme_data.dart'; +import 't_stepper_theme_interpolation.dart'; import 't_stepper_types.dart'; export 't_stepper_types.dart'; @@ -43,6 +45,7 @@ class TStepper extends StatefulWidget { /// 加减按钮使用的步长,必须大于 0。 /// /// 输入提交不要求是步长的整数倍,但会限制在 [min] 与 [max] 之间。 + /// 编辑时以合法输入草稿作为步进起点,并据此判断按钮是否达到边界。 this.step = 1, /// 组件尺寸。 @@ -75,6 +78,8 @@ class TStepper extends StatefulWidget { final num max; /// 加减按钮使用的正数步长;直接输入不要求是步长的整数倍。 + /// + /// 编辑时合法草稿同时决定步进起点与按钮的边界状态。 final num step; /// 组件尺寸;为空时依次使用组件主题和 [TStepperSize.medium]。 @@ -93,6 +98,9 @@ class _TStepperState extends State { bool _editing = false; bool get _disabled => widget.onChanged == null; + num get _stepBase => _editing + ? num.tryParse(_textController.text) ?? widget.value + : widget.value; num get _effectiveMin => widget.min.isNaN ? 0 : widget.min; num get _effectiveMax { final min = _effectiveMin; @@ -127,8 +135,8 @@ class _TStepperState extends State { @override Widget build(BuildContext context) { final style = _StepperStyle.resolve(context, widget); - final canDecrease = !_disabled && widget.value > _effectiveMin; - final canIncrease = !_disabled && widget.value < _effectiveMax; + final canDecrease = !_disabled && _stepBase > _effectiveMin; + final canIncrease = !_disabled && _stepBase < _effectiveMax; final spacing = style.variant == TStepperVariant.outline ? 0.0 : style.spacing; @@ -232,7 +240,7 @@ class _TStepperState extends State { : oldValue; }), ], - onChanged: (_) => _editing = true, + onChanged: (_) => setState(() => _editing = true), onSubmitted: (_) => _submitDraft(unfocus: true), onTapOutside: (_) => _submitDraft(unfocus: true), ), @@ -249,15 +257,14 @@ class _TStepperState extends State { } void _stepBy(num delta) { - final draft = num.tryParse(_textController.text); - final base = _editing && draft != null ? draft : widget.value; + final base = _stepBase; _requestChange(_normalizeStepResult(base + delta, base), unfocus: true); } void _submitDraft({required bool unfocus}) { final parsed = num.tryParse(_textController.text); if (parsed == null) { - _editing = false; + setState(() => _editing = false); _setText(widget.value); if (unfocus) { _focusNode.unfocus(); @@ -269,7 +276,7 @@ class _TStepperState extends State { void _requestChange(num next, {required bool unfocus}) { final clamped = next.clamp(_effectiveMin, _effectiveMax); - _editing = false; + setState(() => _editing = false); if (unfocus) { _focusNode.unfocus(); } @@ -417,6 +424,38 @@ class _StepperButton extends StatelessWidget { } class _StepperStyle { + static _StepperStyle _lerp(_StepperStyle a, _StepperStyle b, double t) { + return _StepperStyle( + variant: t < 0.5 ? a.variant : b.variant, + controlSize: a.controlSize + (b.controlSize - a.controlSize) * t, + inputWidth: a.inputWidth + (b.inputWidth - a.inputWidth) * t, + iconSize: a.iconSize + (b.iconSize - a.iconSize) * t, + spacing: a.spacing + (b.spacing - a.spacing) * t, + borderWidth: a.borderWidth + (b.borderWidth - a.borderWidth) * t, + borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t)!, + foregroundColor: Color.lerp(a.foregroundColor, b.foregroundColor, t)!, + disabledForegroundColor: Color.lerp( + a.disabledForegroundColor, + b.disabledForegroundColor, + t, + )!, + iconColor: Color.lerp(a.iconColor, b.iconColor, t)!, + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t)!, + disabledBackgroundColor: Color.lerp( + a.disabledBackgroundColor, + b.disabledBackgroundColor, + t, + )!, + borderColor: Color.lerp(a.borderColor, b.borderColor, t)!, + textStyle: TextStyle.lerp(a.textStyle, b.textStyle, t)!, + disabledTextStyle: TextStyle.lerp( + a.disabledTextStyle, + b.disabledTextStyle, + t, + )!, + ); + } + const _StepperStyle({ required this.variant, required this.controlSize, @@ -452,32 +491,31 @@ class _StepperStyle { final TextStyle disabledTextStyle; static _StepperStyle resolve(BuildContext context, TStepper widget) { + return _resolveTheme( + context, + widget, + Theme.of(context).extension(), + ); + } + + static _StepperStyle _resolveTheme( + BuildContext context, + TStepper widget, + TStepperThemeData? componentTheme, + ) { + if (componentTheme is StepperThemeInterpolation) { + return _lerp( + _resolveTheme(context, widget, componentTheme.begin), + _resolveTheme(context, widget, componentTheme.end), + componentTheme.progress, + ); + } final materialTheme = Theme.of(context); - final componentTheme = materialTheme.extension(); final token = context.tTheme; final size = widget.size ?? componentTheme?.size ?? TStepperSize.medium; final variant = widget.variant ?? componentTheme?.variant ?? TStepperVariant.normal; - final geometry = switch (size) { - TStepperSize.small => const ( - controlSize: 20.0, - inputWidth: 34.0, - iconSize: 12.0, - fontSize: 10.0, - ), - TStepperSize.medium => const ( - controlSize: 24.0, - inputWidth: 38.0, - iconSize: 16.0, - fontSize: 12.0, - ), - TStepperSize.large => const ( - controlSize: 26.0, - inputWidth: 45.0, - iconSize: 20.0, - fontSize: 16.0, - ), - }; + final geometry = stepperGeometry(size); final defaultTextStyle = context.tExplicitDefaultTextStyle; final materialTextStyle = materialTheme.tExplicitTextTheme?.bodySmall ?? const TextStyle(); @@ -512,11 +550,11 @@ class _StepperStyle { controlSize: componentTheme?.controlSize ?? geometry.controlSize, inputWidth: componentTheme?.inputWidth ?? geometry.inputWidth, iconSize: componentTheme?.iconSize ?? geometry.iconSize, - spacing: componentTheme?.spacing ?? 4, + spacing: componentTheme?.spacing ?? stepperSpacing, borderRadius: componentTheme?.borderRadius ?? BorderRadius.circular(token.radiusSmall), - borderWidth: componentTheme?.borderWidth ?? 1, + borderWidth: componentTheme?.borderWidth ?? stepperBorderWidth, foregroundColor: foregroundColor, disabledForegroundColor: disabledForegroundColor, iconColor: diff --git a/tdesign-component/lib/src/components/stepper/t_stepper_defaults.dart b/tdesign-component/lib/src/components/stepper/t_stepper_defaults.dart new file mode 100644 index 000000000..3d6d61a03 --- /dev/null +++ b/tdesign-component/lib/src/components/stepper/t_stepper_defaults.dart @@ -0,0 +1,29 @@ +import 't_stepper_types.dart'; + +// One geometry definition serves normal rendering and theme interpolation. +typedef StepperGeometry = ({ + double controlSize, + double inputWidth, + double iconSize, + double fontSize, +}); + +StepperGeometry stepperGeometry(TStepperSize? size) => switch (size) { + TStepperSize.small => ( + controlSize: 20, + inputWidth: 34, + iconSize: 12, + fontSize: 10, + ), + TStepperSize.large => ( + controlSize: 26, + inputWidth: 45, + iconSize: 20, + fontSize: 16, + ), + TStepperSize.medium || + null => (controlSize: 24, inputWidth: 38, iconSize: 16, fontSize: 12), +}; + +const stepperSpacing = 4.0; +const stepperBorderWidth = 1.0; diff --git a/tdesign-component/lib/src/components/stepper/t_stepper_theme_data.dart b/tdesign-component/lib/src/components/stepper/t_stepper_theme_data.dart index 220042c5e..ba8f26d6b 100644 --- a/tdesign-component/lib/src/components/stepper/t_stepper_theme_data.dart +++ b/tdesign-component/lib/src/components/stepper/t_stepper_theme_data.dart @@ -1,7 +1,6 @@ -import 'dart:ui' show lerpDouble; - import 'package:flutter/material.dart'; +import 't_stepper_theme_interpolation.dart'; import 't_stepper_types.dart'; /// `TStepper` 的组件级主题。 @@ -65,11 +64,11 @@ class TStepperThemeData extends ThemeExtension { /// 在继承 DefaultTextStyle 和 ThemeData.textTheme 后合并;非空字段可覆盖 /// 默认字号、行高及 [foregroundColor]。 this.textStyle, - }) : assert(inputWidth == null || inputWidth > 0), - assert(controlSize == null || controlSize > 0), - assert(iconSize == null || iconSize > 0), - assert(spacing == null || spacing >= 0), - assert(borderWidth == null || borderWidth >= 0); + }) : assert(inputWidth == null || inputWidth > 0), + assert(controlSize == null || controlSize > 0), + assert(iconSize == null || iconSize > 0), + assert(spacing == null || spacing >= 0), + assert(borderWidth == null || borderWidth >= 0); /// 默认尺寸;为空时使用中尺寸。 final TStepperSize? size; @@ -150,34 +149,20 @@ class TStepperThemeData extends ThemeExtension { ); } + /// 插值保留未指定字段的继承语义,由组件结合当前实例尺寸与主题解析。 + /// + /// 两端均未指定的字段仍为 null;端点返回原始配置。 @override TStepperThemeData lerp(ThemeExtension? other, double t) { if (other is! TStepperThemeData) { return this; } - return TStepperThemeData( - size: t < 0.5 ? size : other.size, - variant: t < 0.5 ? variant : other.variant, - inputWidth: lerpDouble(inputWidth, other.inputWidth, t), - controlSize: lerpDouble(controlSize, other.controlSize, t), - iconSize: lerpDouble(iconSize, other.iconSize, t), - spacing: lerpDouble(spacing, other.spacing, t), - borderRadius: BorderRadius.lerp(borderRadius, other.borderRadius, t), - borderWidth: lerpDouble(borderWidth, other.borderWidth, t), - foregroundColor: Color.lerp(foregroundColor, other.foregroundColor, t), - disabledForegroundColor: Color.lerp( - disabledForegroundColor, - other.disabledForegroundColor, - t, - ), - backgroundColor: Color.lerp(backgroundColor, other.backgroundColor, t), - disabledBackgroundColor: Color.lerp( - disabledBackgroundColor, - other.disabledBackgroundColor, - t, - ), - borderColor: Color.lerp(borderColor, other.borderColor, t), - textStyle: TextStyle.lerp(textStyle, other.textStyle, t), - ); + if (t == 0) { + return this; + } + if (t == 1) { + return other; + } + return StepperThemeInterpolation(this, other, t); } } diff --git a/tdesign-component/lib/src/components/stepper/t_stepper_theme_interpolation.dart b/tdesign-component/lib/src/components/stepper/t_stepper_theme_interpolation.dart new file mode 100644 index 000000000..2c6168054 --- /dev/null +++ b/tdesign-component/lib/src/components/stepper/t_stepper_theme_interpolation.dart @@ -0,0 +1,161 @@ +import 'dart:ui' show lerpDouble; + +import 'package:flutter/material.dart'; + +import 't_stepper_defaults.dart'; +import 't_stepper_theme_data.dart'; +import 't_stepper_types.dart'; + +// Internal interpolation data, deliberately not exported from the package. +// Unset visual fields depend on the consuming widget's size, inherited styles, +// and tokens. Resolve both endpoints there instead of promoting placeholder +// colors or geometry into explicit component overrides. +class StepperThemeInterpolation extends TStepperThemeData { + StepperThemeInterpolation(this.begin, this.end, this.progress) + : super( + size: progress < 0.5 ? begin.size : end.size, + variant: progress < 0.5 ? begin.variant : end.variant, + inputWidth: _number( + begin.inputWidth, + end.inputWidth, + stepperGeometry(begin.size).inputWidth, + stepperGeometry(end.size).inputWidth, + progress, + ), + controlSize: _number( + begin.controlSize, + end.controlSize, + stepperGeometry(begin.size).controlSize, + stepperGeometry(end.size).controlSize, + progress, + ), + iconSize: _number( + begin.iconSize, + end.iconSize, + stepperGeometry(begin.size).iconSize, + stepperGeometry(end.size).iconSize, + progress, + ), + spacing: _number( + begin.spacing, + end.spacing, + stepperSpacing, + stepperSpacing, + progress, + ), + borderRadius: _nullable( + begin.borderRadius, + end.borderRadius, + progress, + BorderRadius.lerp, + ), + borderWidth: _number( + begin.borderWidth, + end.borderWidth, + stepperBorderWidth, + stepperBorderWidth, + progress, + ), + foregroundColor: _nullable( + begin.foregroundColor, + end.foregroundColor, + progress, + Color.lerp, + ), + disabledForegroundColor: _nullable( + begin.disabledForegroundColor, + end.disabledForegroundColor, + progress, + Color.lerp, + ), + backgroundColor: _nullable( + begin.backgroundColor, + end.backgroundColor, + progress, + Color.lerp, + ), + disabledBackgroundColor: _nullable( + begin.disabledBackgroundColor, + end.disabledBackgroundColor, + progress, + Color.lerp, + ), + borderColor: _nullable( + begin.borderColor, + end.borderColor, + progress, + Color.lerp, + ), + textStyle: _nullable( + begin.textStyle, + end.textStyle, + progress, + TextStyle.lerp, + ), + ); + + final TStepperThemeData begin; + final TStepperThemeData end; + final double progress; + + @override + TStepperThemeData copyWith({ + TStepperSize? size, + TStepperVariant? variant, + double? inputWidth, + double? controlSize, + double? iconSize, + double? spacing, + BorderRadius? borderRadius, + double? borderWidth, + Color? foregroundColor, + Color? disabledForegroundColor, + Color? backgroundColor, + Color? disabledBackgroundColor, + Color? borderColor, + TextStyle? textStyle, + }) { + TStepperThemeData copy(TStepperThemeData theme) => theme.copyWith( + size: size, + variant: variant, + inputWidth: inputWidth, + controlSize: controlSize, + iconSize: iconSize, + spacing: spacing, + borderRadius: borderRadius, + borderWidth: borderWidth, + foregroundColor: foregroundColor, + disabledForegroundColor: disabledForegroundColor, + backgroundColor: backgroundColor, + disabledBackgroundColor: disabledBackgroundColor, + borderColor: borderColor, + textStyle: textStyle, + ); + return copy(begin).lerp(copy(end), progress); + } + + static double? _number( + double? a, + double? b, + double defaultA, + double defaultB, + double t, + ) { + if (a == null && b == null) { + return null; + } + return lerpDouble(a ?? defaultA, b ?? defaultB, t); + } + + static T? _nullable( + T? a, + T? b, + double t, + T? Function(T?, T?, double) lerp, + ) { + if (a == null || b == null) { + return t < 0.5 ? a : b; + } + return lerp(a, b, t); + } +} diff --git a/tdesign-component/test/components/stepper/goldens/t_stepper_dark.png b/tdesign-component/test/components/stepper/goldens/t_stepper_dark.png index be65c91f5..cbfaa9345 100644 Binary files a/tdesign-component/test/components/stepper/goldens/t_stepper_dark.png and b/tdesign-component/test/components/stepper/goldens/t_stepper_dark.png differ diff --git a/tdesign-component/test/components/stepper/goldens/t_stepper_light.png b/tdesign-component/test/components/stepper/goldens/t_stepper_light.png index fc66304ae..a8211f480 100644 Binary files a/tdesign-component/test/components/stepper/goldens/t_stepper_light.png and b/tdesign-component/test/components/stepper/goldens/t_stepper_light.png differ diff --git a/tdesign-component/test/components/stepper/t_stepper_contract_test.dart b/tdesign-component/test/components/stepper/t_stepper_contract_test.dart new file mode 100644 index 000000000..bf8e786ae --- /dev/null +++ b/tdesign-component/test/components/stepper/t_stepper_contract_test.dart @@ -0,0 +1,331 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +void main() { + Widget host(Widget child, {TStepperThemeData? theme, TThemeData? token}) => + MaterialApp( + themeAnimationDuration: Duration.zero, + theme: TThemeBuilder.light( + token ?? TThemeData.defaultData(), + ).mergeExtension(theme ?? const TStepperThemeData()), + home: Scaffold(body: Center(child: child)), + ); + + EditableText input(WidgetTester tester) => + tester.widget(find.byType(EditableText)); + + Size size(WidgetTester tester) => tester.getSize( + find.descendant(of: find.byType(TStepper), matching: find.byType(Row)), + ); + + BoxDecoration inputDecoration(WidgetTester tester) => + tester + .widget( + find.ancestor( + of: find.byType(EditableText), + matching: find.byType(DecoratedBox), + ), + ) + .decoration + as BoxDecoration; + + for (final entry in [(0, TIcons.minus, 4), (10, TIcons.plus, 6)]) { + for (final accepts in [true, false]) { + testWidgets('draft at ${entry.$1}, parent accepts: $accepts', ( + tester, + ) async { + num value = entry.$1; + final requests = []; + await tester.pumpWidget( + host( + StatefulBuilder( + builder: (context, setState) => TStepper( + value: value, + max: 10, + onChanged: (next) { + requests.add(next); + if (accepts) { + setState(() => value = next); + } + }, + ), + ), + ), + ); + await tester.enterText(find.byType(EditableText), '5'); + await tester.pump(); + await tester.tap(find.byIcon(entry.$2)); + await tester.pump(); + expect(requests, [entry.$3]); + expect( + input(tester).controller.text, + '${accepts ? entry.$3 : entry.$1}', + ); + if (!accepts) { + await tester.tap(find.byIcon(entry.$2)); + await tester.pump(); + expect(requests, hasLength(1)); + } + }); + } + } + + testWidgets('draft boundaries disable the matching action immediately', ( + tester, + ) async { + final requests = []; + await tester.pumpWidget( + host(TStepper(value: 5, max: 10, onChanged: requests.add)), + ); + await tester.enterText(find.byType(EditableText), '0'); + await tester.pump(); + await tester.tap(find.byIcon(TIcons.minus)); + expect(requests, isEmpty); + await tester.tap(find.byIcon(TIcons.plus)); + await tester.pump(); + expect(requests, [1]); + await tester.enterText(find.byType(EditableText), '10'); + await tester.pump(); + await tester.tap(find.byIcon(TIcons.plus)); + expect(requests, [1]); + await tester.tap(find.byIcon(TIcons.minus)); + await tester.pump(); + expect(requests, [1, 9]); + }); + + testWidgets('invalid draft falls back to the controlled button bounds', ( + tester, + ) async { + final requests = []; + await tester.pumpWidget( + host(TStepper(value: 0, max: 10, onChanged: requests.add)), + ); + await tester.enterText(find.byType(EditableText), '-'); + await tester.pump(); + await tester.tap(find.byIcon(TIcons.minus)); + expect(requests, isEmpty); + await tester.tap(find.byIcon(TIcons.plus)); + await tester.pump(); + expect(requests, [1]); + expect(input(tester).controller.text, '0'); + }); + + test('theme interpolation keeps exact endpoints and unset fields', () { + const a = TStepperThemeData(); + const b = TStepperThemeData(controlSize: 40); + expect(a.lerp(b, 0), same(a)); + expect(a.lerp(b, 1), same(b)); + final unset = a.lerp(a, 0.5); + expect(unset.size, isNull); + expect(unset.variant, isNull); + expect(unset.inputWidth, isNull); + expect(unset.controlSize, isNull); + expect(unset.iconSize, isNull); + expect(unset.spacing, isNull); + expect(unset.borderWidth, isNull); + expect(unset.borderRadius, isNull); + expect(unset.foregroundColor, isNull); + expect(unset.disabledForegroundColor, isNull); + expect(unset.backgroundColor, isNull); + expect(unset.disabledBackgroundColor, isNull); + expect(unset.borderColor, isNull); + expect(unset.textStyle, isNull); + }); + + for (final entry in [ + (TStepperSize.small, 20.0, 34.0, 12.0), + (TStepperSize.medium, 24.0, 38.0, 16.0), + (TStepperSize.large, 26.0, 45.0, 20.0), + ]) { + test('nullable geometry uses ${entry.$1} defaults in both directions', () { + final a = TStepperThemeData(size: entry.$1); + final b = TStepperThemeData( + size: entry.$1, + controlSize: 40, + inputWidth: 80, + iconSize: 24, + spacing: 8, + borderWidth: 3, + ); + for (final t in [0.25, 0.5, 0.75]) { + final forward = a.lerp(b, t); + final reverse = b.lerp(a, 1 - t); + expect(forward.controlSize, entry.$2 + (40 - entry.$2) * t); + expect(reverse.controlSize, forward.controlSize); + expect(forward.inputWidth, entry.$3 + (80 - entry.$3) * t); + expect(reverse.inputWidth, forward.inputWidth); + expect(forward.iconSize, entry.$4 + (24 - entry.$4) * t); + expect(reverse.iconSize, forward.iconSize); + expect(forward.spacing, 4 + 4 * t); + expect(reverse.spacing, forward.spacing); + expect(forward.borderWidth, 1 + 2 * t); + expect(reverse.borderWidth, forward.borderWidth); + } + }); + } + + testWidgets( + 'rendered interpolation uses the instance size and can be copied', + (tester) async { + const a = TStepperThemeData(size: TStepperSize.large); + const b = TStepperThemeData( + controlSize: 40, + inputWidth: 80, + iconSize: 24, + spacing: 8, + ); + final middle = a.lerp(b, 0.5).copyWith(foregroundColor: Colors.red); + await tester.pumpWidget( + host( + TStepper(value: 3, size: TStepperSize.small, onChanged: (_) {}), + theme: middle, + ), + ); + expect(size(tester), const Size(129, 30)); + expect(tester.widget(find.byIcon(TIcons.plus)).size, 18); + expect( + tester.widget(find.byIcon(TIcons.plus)).color, + const Color(0xfff44336), + ); + expect(input(tester).style.color, const Color(0xfff44336)); + }, + ); + + testWidgets('nullable visual styles interpolate from effective tokens', ( + tester, + ) async { + final token = TThemeData.defaultData().copyWithTThemeData( + 'stepper-lerp', + colorMap: { + 'textColorPrimary': Colors.red, + 'textDisabledColor': Colors.green, + 'bgColorSecondaryContainer': Colors.yellow, + 'bgColorComponentDisabled': Colors.orange, + 'componentBorderColor': Colors.blue, + }, + ); + const a = TStepperThemeData(); + const b = TStepperThemeData( + foregroundColor: Colors.black, + disabledForegroundColor: Colors.black, + backgroundColor: Colors.black, + disabledBackgroundColor: Colors.black, + borderColor: Colors.black, + borderRadius: BorderRadius.all(Radius.circular(10)), + textStyle: TextStyle(fontSize: 20, height: 1.5), + ); + for (final reverse in [false, true]) { + final theme = reverse ? b.lerp(a, 0.5) : a.lerp(b, 0.5); + for (final disabled in [false, true]) { + await tester.pumpWidget( + host( + TStepper( + value: 3, + variant: TStepperVariant.filled, + onChanged: disabled ? null : (_) {}, + ), + theme: theme, + token: token, + ), + ); + expect( + input(tester).style.color, + Color.lerp(disabled ? Colors.green : Colors.red, Colors.black, 0.5), + ); + expect(input(tester).style.fontSize, 16); + expect(input(tester).style.height, 1.25); + expect( + inputDecoration(tester).color, + Color.lerp( + disabled ? Colors.orange : Colors.yellow, + Colors.black, + 0.5, + ), + ); + expect( + inputDecoration(tester).borderRadius, + BorderRadius.circular((token.radiusSmall + 10) / 2), + ); + } + await tester.pumpWidget( + host( + TStepper( + value: 3, + variant: TStepperVariant.outline, + onChanged: (_) {}, + ), + theme: theme, + token: token, + ), + ); + expect( + (inputDecoration(tester).border! as Border).top.color, + Color.lerp(Colors.blue, Colors.black, 0.5), + ); + } + }); + + testWidgets('inherited text and icon defaults survive nested interpolation', ( + tester, + ) async { + const a = TStepperThemeData(); + const b = TStepperThemeData(controlSize: 40, foregroundColor: Colors.black); + final theme = a + .lerp(b, 0.5) + .lerp( + const TStepperThemeData( + controlSize: 48, + foregroundColor: Colors.white, + ), + 0.5, + ); + await tester.pumpWidget( + host( + DefaultTextStyle( + style: const TextStyle(color: Colors.red), + child: IconTheme( + data: const IconThemeData(color: Colors.green), + child: TStepper(value: 3, onChanged: (_) {}), + ), + ), + theme: theme, + ), + ); + expect(size(tester).height, 40); + expect( + input(tester).style.color, + Color.lerp(Color.lerp(Colors.red, Colors.black, 0.5), Colors.white, 0.5), + ); + expect( + tester.widget(find.byIcon(TIcons.plus)).color, + Color.lerp( + Color.lerp(Colors.green, Colors.black, 0.5), + Colors.white, + 0.5, + ), + ); + }); + + testWidgets('AnimatedTheme renders valid endpoint and midpoint geometry', ( + tester, + ) async { + Widget scene(TStepperThemeData theme) => MaterialApp( + themeAnimationDuration: const Duration(milliseconds: 200), + themeAnimationCurve: Curves.linear, + theme: TThemeBuilder.light( + TThemeData.defaultData(), + ).mergeExtension(theme), + home: Scaffold(body: TStepper(value: 3, onChanged: (_) {})), + ); + await tester.pumpWidget(scene(const TStepperThemeData())); + expect(size(tester).height, 24); + await tester.pumpWidget(scene(const TStepperThemeData(controlSize: 40))); + expect(size(tester).height, 24); + await tester.pump(const Duration(milliseconds: 100)); + expect(size(tester).height, 32); + await tester.pumpAndSettle(); + expect(size(tester).height, 40); + expect(tester.takeException(), isNull); + }); +} diff --git a/tdesign-component/test/tool/generate_example_code_test.dart b/tdesign-component/test/tool/generate_example_code_test.dart index 7d190cf8a..8abebab3b 100644 --- a/tdesign-component/test/tool/generate_example_code_test.dart +++ b/tdesign-component/test/tool/generate_example_code_test.dart @@ -69,6 +69,42 @@ void demo() {} expect(createGenerator().run(check: true).isUpToDate, isTrue); }); + test('standalone widget includes imports and only its associated State', () { + writeSource('stateful', ''' +import 'package:flutter/material.dart'; +import '../annotation/example_code.dart'; + +@ExampleCode(group: 'counter') +class Counter extends StatefulWidget { + const Counter({super.key}); + @override + State createState() => _CounterState(); +} +class _CounterState extends State { + int value = 3; + @override + Widget build(BuildContext context) => TextButton( + onPressed: () => setState(() => value++), + child: Text(value.toString()), + ); +} +class _OtherState extends State {} +'''); + createGenerator().run(); + final snippet = File( + '${outputDirectory.path}/counter.Counter.txt', + ).readAsStringSync(); + expect(snippet, startsWith("import 'package:flutter/material.dart';")); + expect(snippet, contains('class Counter extends StatefulWidget')); + expect(snippet, contains('class _CounterState extends State')); + expect(snippet, contains('int value = 3;')); + expect(snippet, contains('setState(() => value++)')); + expect(snippet, isNot(contains('_OtherState'))); + expect(snippet, isNot(contains('example_code.dart'))); + expect(snippet, isNot(contains('@ExampleCode'))); + expect(createGenerator().run(check: true).isUpToDate, isTrue); + }); + test('verbose mode reports changes and writes files correctly', () { writeSource('examples', ''' @ExampleCode(group: 'button') diff --git a/tdesign-component/tool/component_test_manifest.dart b/tdesign-component/tool/component_test_manifest.dart index 8bc241062..c471eefc7 100644 --- a/tdesign-component/tool/component_test_manifest.dart +++ b/tdesign-component/tool/component_test_manifest.dart @@ -633,6 +633,25 @@ const componentTestManifests = [ ), ], ), + ComponentTestManifest( + name: 'stepper', + coverageTargets: ['lib/src/components/stepper/'], + componentTests: [ + 'test/components/stepper/t_stepper_test.dart', + 'test/components/stepper/t_stepper_contract_test.dart', + ], + exampleTests: ['test/stepper_demo_test.dart'], + visualTests: [ + VisualTestManifest( + name: 'Stepper Demo and component', + workingDirectory: 'example', + testFiles: [ + 'test/stepper_demo_golden_test.dart', + '../test/components/stepper/t_stepper_golden_test.dart', + ], + ), + ], + ), ComponentTestManifest( name: 'switch', coverageTargets: ['lib/src/components/switch/'], diff --git a/tdesign-component/tool/generate_example_code.dart b/tdesign-component/tool/generate_example_code.dart index 2d8f29277..e45af5c2d 100644 --- a/tdesign-component/tool/generate_example_code.dart +++ b/tdesign-component/tool/generate_example_code.dart @@ -126,11 +126,13 @@ class _ExampleCodeCollector extends RecursiveAstVisitor { late File _file; late String _source; + late CompilationUnit _unit; void collect(File file) { _file = file; _source = file.readAsStringSync(); - parseString(content: _source, path: file.path).unit.accept(this); + _unit = parseString(content: _source, path: file.path).unit; + _unit.accept(this); } void throwIfInvalid() { @@ -139,6 +141,12 @@ class _ExampleCodeCollector extends RecursiveAstVisitor { } } + @override + void visitClassDeclaration(ClassDeclaration node) { + _collect(node, node.name.lexeme); + super.visitClassDeclaration(node); + } + @override void visitFunctionDeclaration(FunctionDeclaration node) { _collect(node, node.name.lexeme); @@ -170,13 +178,32 @@ class _ExampleCodeCollector extends RecursiveAstVisitor { } if (group == null || !_groupPattern.hasMatch(group)) { _errors.add( - '${_file.path}: $methodName must declare a literal, non-empty ' - '$_annotationName group containing only letters, digits, "_" or "-".'); + '${_file.path}: $methodName must declare a literal, non-empty ' + '$_annotationName group containing only letters, digits, "_" or "-".', + ); return; } final fileName = '$group.$methodName.txt'; - final source = _sourceWithoutMarker(node, annotation); + var source = _sourceWithoutMarker(node, annotation); + if (node is ClassDeclaration) { + final imports = _unit.directives.whereType().where( + (directive) => + !(directive.uri.stringValue?.endsWith('example_code.dart') ?? + false), + ); + final states = _unit.declarations.whereType().where( + (declaration) => + declaration.extendsClause?.superclass.toSource() == + 'State<${node.name.lexeme}>', + ); + source = [ + if (imports.isNotEmpty) + imports.map((item) => item.toSource()).join('\n'), + source, + for (final state in states) _source.substring(state.offset, state.end), + ].join('\n\n'); + } if (outputs.containsKey(fileName)) { _errors.add('${_file.path}: duplicate generated snippet $fileName.'); return;