diff --git a/specs/027-slider-demo-alignment/acceptance.md b/specs/027-slider-demo-alignment/acceptance.md new file mode 100644 index 000000000..07e019f9f --- /dev/null +++ b/specs/027-slider-demo-alignment/acceptance.md @@ -0,0 +1,18 @@ +# 验收记录 + +- Flutter 基线:`d2ff7a0aa5317c0f5412848610f2a1ae05b6dfa7` +- 小程序基线:`fe14543572bc7233226b2f08db6fc1424cd81ed2` +- 公开运行页:`https://tdesign.tencent.com/miniprogram/live/m2w/program/miniprogram/#!pages/slider/slider.html` +- 设计基线:Figma `mdBVCCVGERhxoZLle2eLT0`,节点 `24386:5254`;走查标注要求 Gy4 未激活轨道、Gy1 thumb 描边、数值与 thumb 对齐,垂直示例从顶部 0 向下递增。 +- API Review:单/双值类型、受控值和禁用入口保持收敛;不新增垂直或胶囊 API。修复 `showThumbValue` 静态展示、显式 `ColorScheme` 优先级、默认 thumb、禁用轨道及禁用数值视觉。 + +## 验证结果 + +- Flutter 3.32.0 / 3.47.0:严格 `flutter analyze` 零告警;组件与清单测试各 25 项、Demo 测试各 5 项通过。 +- 生产源码覆盖率:`209/209 = 100.00%`。 +- 固定 Linux + Flutter 3.32.0:组件 Golden 1 项、Demo 明暗 Golden 2 项通过;更新后均立即无更新复跑。 +- 示例代码生成检查通过;GitHub/CNB 共用的组件、Demo 与视觉测试清单自测通过。 + +## 未验证项 + +- Android/iOS 系统字体逐像素差异不由 Linux Golden 证明。 diff --git a/specs/027-slider-demo-alignment/evidence/miniprogram-top.png b/specs/027-slider-demo-alignment/evidence/miniprogram-top.png new file mode 100644 index 000000000..bdf7bba07 Binary files /dev/null and b/specs/027-slider-demo-alignment/evidence/miniprogram-top.png differ diff --git a/specs/027-slider-demo-alignment/plan.md b/specs/027-slider-demo-alignment/plan.md new file mode 100644 index 000000000..84a694655 --- /dev/null +++ b/specs/027-slider-demo-alignment/plan.md @@ -0,0 +1,6 @@ +# Plan + +1. 固定 Flutter 与小程序基线并截图。 +2. Review 公开 API、静态数值语义和字段级主题优先级,优先 Flutter 受控与主题组合模式。 +3. 修复组件默认视觉与主题回退,按 19 个实例重排 Demo,并补行为测试、组件覆盖率门禁、明暗 Golden。 +4. 在 Flutter 3.32.0 与 latest 复验后独立提交 PR。 diff --git a/specs/027-slider-demo-alignment/spec.md b/specs/027-slider-demo-alignment/spec.md new file mode 100644 index 000000000..3a62a21ff --- /dev/null +++ b/specs/027-slider-demo-alignment/spec.md @@ -0,0 +1,21 @@ +# Slider 公开 Demo 对齐 + +## 目标 + +按小程序公开 Demo 的可见矩阵,对齐单/双游标、数值、非零起点、刻度、禁用、胶囊与垂直示例,并建立独立行为与视觉回归。 + +## API Review + +- `TSlider` / `TRangeSlider` 以值类型区分单值与区间,避免 `range` 布尔参数改变 value 类型。 +- `value + onChanged` 遵循 Flutter 受控模式,`onChanged == null` 表示禁用,不引入 `defaultValue`。 +- 数值和刻度由 `showThumbValue` / `showScaleValue` 与 formatter 负责;`showThumbValue` 持续显示拇指数值,非零起点沿用 `min/max/divisions`。 +- 垂直方向由 `RotatedBox` 组合,胶囊外观由 `SliderTheme` 组合;二者不新增与 Flutter 框架重复的 API。 +- 视觉字段按局部 `SliderTheme` > 显式 `ColorScheme` > TDesign token 解析;token 默认提供 Gy1 描边 thumb、品牌色轨道、Gy4 未激活轨道、区分前后轨道的禁用色,并以禁用文字 token 绘制禁用态常驻数值。 + +## 行为契约 + +- Demo 的 8 个公开区块与内部 19 个 Slider 实例在数量、顺序、类型和默认值上与固定小程序基线一致。 +- 所有可交互实例均由页面持有状态;禁用实例 callback 为 null。 +- 垂直组合仅旋转轨道和 thumb,数值与刻度文字保持正常阅读方向;最小值位于顶部,数值向下递增,与设计稿一致。 +- `TRangeSlider` 在构建时拒绝落在 `min/max` 外的范围值。 +- 不新增公开 API;修正既有 `showThumbValue` 语义和默认视觉属于缺陷修复。 diff --git a/specs/027-slider-demo-alignment/tasks.md b/specs/027-slider-demo-alignment/tasks.md new file mode 100644 index 000000000..d05f6b99c --- /dev/null +++ b/specs/027-slider-demo-alignment/tasks.md @@ -0,0 +1,7 @@ +# Tasks + +- [x] DONE 固定跨端基线并完成 API Review +- [x] DONE 对齐公开 Demo 的 8 个区块与 19 个组件实例 +- [x] DONE 修复静态数值、Theme 优先级、thumb 与禁用视觉 +- [x] DONE 更新功能测试、Golden、覆盖率并完成双版本验证 +- [ ] DOING 提交、推送并远端复核 PR diff --git a/tdesign-component/example/assets/api/slider_api.md b/tdesign-component/example/assets/api/slider_api.md index a6d48275b..5ec4a9abb 100644 --- a/tdesign-component/example/assets/api/slider_api.md +++ b/tdesign-component/example/assets/api/slider_api.md @@ -13,7 +13,7 @@ | onChangeStart | ValueChanged? | - | 开始拖动时触发。 | | scaleFormatter | TSliderThumbFormatter? | - | 刻度值格式化回调。 | | showScaleValue | bool | false | 是否显示刻度值。 | -| showThumbValue | bool | false | 是否显示拇指上方数值。 | +| showThumbValue | bool | false | 是否持续显示拇指上方数值。 | | thumbFormatter | TSliderThumbFormatter? | - | 拇指上方数值格式化回调。 | | value | double | - | 受控滑块值。 | @@ -32,7 +32,7 @@ | onChangeStart | ValueChanged? | - | 开始拖动时触发。 | | scaleFormatter | TSliderThumbFormatter? | - | 刻度值格式化回调。 | | showScaleValue | bool | false | 是否显示刻度值。 | -| showThumbValue | bool | false | 是否显示拇指上方数值。 | +| showThumbValue | bool | false | 是否持续显示拇指上方数值。 | | thumbFormatter | TSliderThumbFormatter? | - | 拇指上方数值格式化回调。 | | value | RangeValues | - | 受控范围值。 | diff --git a/tdesign-component/example/assets/code/slider._buildAdvanced.txt b/tdesign-component/example/assets/code/slider._buildAdvanced.txt deleted file mode 100644 index 9c14ea6af..000000000 --- a/tdesign-component/example/assets/code/slider._buildAdvanced.txt +++ /dev/null @@ -1,18 +0,0 @@ -Widget _buildAdvanced(BuildContext context) => _withValueLabel( - context, - label: singleEventText, - child: TSlider( - value: advancedValue, - min: 0, - max: 100, - divisions: 5, - showThumbValue: true, - thumbFormatter: _formatPercent, - showScaleValue: true, - scaleFormatter: _formatPercent, - onChanged: (next) => setState(() => advancedValue = next), - onChangeStart: (_) => - setState(() => singleEventText = 'onChangeStart'), - onChangeEnd: (_) => setState(() => singleEventText = 'onChangeEnd'), - ), - ); diff --git a/tdesign-component/example/assets/code/slider._buildAdvancedRange.txt b/tdesign-component/example/assets/code/slider._buildAdvancedRange.txt deleted file mode 100644 index 6b1b55aa9..000000000 --- a/tdesign-component/example/assets/code/slider._buildAdvancedRange.txt +++ /dev/null @@ -1,18 +0,0 @@ -Widget _buildAdvancedRange(BuildContext context) => _withValueLabel( - context, - label: rangeEventText, - child: TRangeSlider( - value: advancedRange, - min: 0, - max: 100, - divisions: 5, - showThumbValue: true, - thumbFormatter: _formatPercent, - showScaleValue: true, - scaleFormatter: _formatPercent, - onChanged: (next) => setState(() => advancedRange = next), - onChangeStart: (_) => - setState(() => rangeEventText = '范围 onChangeStart'), - onChangeEnd: (_) => setState(() => rangeEventText = '范围 onChangeEnd'), - ), - ); diff --git a/tdesign-component/example/assets/code/slider._buildCapsule.txt b/tdesign-component/example/assets/code/slider._buildCapsule.txt new file mode 100644 index 000000000..2c4bd26ce --- /dev/null +++ b/tdesign-component/example/assets/code/slider._buildCapsule.txt @@ -0,0 +1,71 @@ +Widget _buildCapsule(BuildContext context) { + final capsuleTheme = SliderTheme.of(context).copyWith( + trackHeight: 16, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), + rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 8), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 18), + ); + + Widget capsule(Widget slider) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: SliderTheme(data: capsuleTheme, child: slider), + ); + + return Column( + children: [ + capsule( + TSlider( + key: const ValueKey('slider-capsule'), + value: _capsule, + min: 0, + max: 100, + onChanged: (value) => setState(() => _capsule = value), + ), + ), + capsule( + TRangeSlider( + key: const ValueKey('slider-capsule-range'), + value: _capsuleRange, + min: 0, + max: 100, + onChanged: (value) => setState(() => _capsuleRange = value), + ), + ), + capsule( + TRangeSlider( + key: const ValueKey('slider-capsule-labeled-range'), + value: _capsuleLabeledRange, + min: 0, + max: 100, + showThumbValue: true, + thumbFormatter: _percent, + onChanged: (value) => setState(() => _capsuleLabeledRange = value), + ), + ), + capsule( + TSlider( + key: const ValueKey('slider-capsule-scale'), + value: _capsuleScale, + min: 0, + max: 100, + divisions: 5, + showScaleValue: true, + scaleFormatter: _integer, + onChanged: (value) => setState(() => _capsuleScale = value), + ), + ), + capsule( + TRangeSlider( + key: const ValueKey('slider-capsule-scale-range'), + value: _capsuleScaleRange, + min: 0, + max: 100, + divisions: 5, + showScaleValue: true, + scaleFormatter: _integer, + onChanged: (value) => setState(() => _capsuleScaleRange = value), + ), + ), + ], + ); + } diff --git a/tdesign-component/example/assets/code/slider._buildDisabled.txt b/tdesign-component/example/assets/code/slider._buildDisabled.txt index eacb1d064..56efb121f 100644 --- a/tdesign-component/example/assets/code/slider._buildDisabled.txt +++ b/tdesign-component/example/assets/code/slider._buildDisabled.txt @@ -1,11 +1,32 @@ Widget _buildDisabled(BuildContext context) => const Column( - mainAxisSize: MainAxisSize.min, + children: [ + TSlider(key: ValueKey('slider-disabled'), value: 35, min: 0, max: 100), + SizedBox(height: 16), + Row( children: [ - TSlider(value: 40, min: 0, max: 100), - TRangeSlider( - value: RangeValues(20, 70), - min: 0, - max: 100, + TText('0%'), + Expanded( + child: TRangeSlider( + key: ValueKey('slider-disabled-labeled-range'), + value: RangeValues(40, 60), + min: 0, + max: 100, + showThumbValue: true, + thumbFormatter: _percent, + ), ), + TText('100%'), ], - ); + ), + SizedBox(height: 16), + TRangeSlider( + key: ValueKey('slider-disabled-scale-range'), + value: RangeValues(20, 60), + min: 0, + max: 100, + divisions: 5, + showScaleValue: true, + scaleFormatter: _integer, + ), + ], + ); diff --git a/tdesign-component/example/assets/code/slider._buildLabeled.txt b/tdesign-component/example/assets/code/slider._buildLabeled.txt new file mode 100644 index 000000000..49292437e --- /dev/null +++ b/tdesign-component/example/assets/code/slider._buildLabeled.txt @@ -0,0 +1,36 @@ +Widget _buildLabeled(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _panel( + TSlider( + key: const ValueKey('slider-labeled'), + value: _labeled, + min: 0, + max: 100, + showThumbValue: true, + thumbFormatter: _percent, + onChanged: (value) => setState(() => _labeled = value), + ), + ), + _subsection(context, '带数值双游标滑块'), + Row( + children: [ + const TText('0%'), + Expanded( + child: TRangeSlider( + key: const ValueKey('slider-labeled-range'), + value: _labeledRange, + min: 0, + max: 100, + showThumbValue: true, + thumbFormatter: _percent, + onChanged: (value) => setState(() => _labeledRange = value), + ), + ), + const TText('100%'), + ], + ), + ], + ); + } diff --git a/tdesign-component/example/assets/code/slider._buildNonZero.txt b/tdesign-component/example/assets/code/slider._buildNonZero.txt new file mode 100644 index 000000000..c707661d0 --- /dev/null +++ b/tdesign-component/example/assets/code/slider._buildNonZero.txt @@ -0,0 +1,12 @@ +Widget _buildNonZero(BuildContext context) => _panel( + TSlider( + key: const ValueKey('slider-non-zero'), + value: _nonZero, + min: 20, + max: 100, + divisions: 4, + showScaleValue: true, + scaleFormatter: _integer, + onChanged: (value) => setState(() => _nonZero = value), + ), + ); diff --git a/tdesign-component/example/assets/code/slider._buildRange.txt b/tdesign-component/example/assets/code/slider._buildRange.txt index 68101eee2..12d02e9c3 100644 --- a/tdesign-component/example/assets/code/slider._buildRange.txt +++ b/tdesign-component/example/assets/code/slider._buildRange.txt @@ -1,12 +1,9 @@ -Widget _buildRange(BuildContext context) => _withValueLabel( - context, - label: - '当前范围:${rangeValue.start.toStringAsFixed(0)} - ${rangeValue.end.toStringAsFixed(0)}', - child: TRangeSlider( - value: rangeValue, - min: 0, - max: 100, - divisions: 100, - onChanged: (next) => setState(() => rangeValue = next), - ), - ); +Widget _buildRange(BuildContext context) => _panel( + TRangeSlider( + key: const ValueKey('slider-range'), + value: _range, + min: 0, + max: 100, + onChanged: (value) => setState(() => _range = value), + ), + ); diff --git a/tdesign-component/example/assets/code/slider._buildScale.txt b/tdesign-component/example/assets/code/slider._buildScale.txt new file mode 100644 index 000000000..79dac9472 --- /dev/null +++ b/tdesign-component/example/assets/code/slider._buildScale.txt @@ -0,0 +1,32 @@ +Widget _buildScale(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _panel( + TSlider( + key: const ValueKey('slider-scale'), + value: _scale, + min: 0, + max: 100, + divisions: 5, + showScaleValue: true, + scaleFormatter: _integer, + onChanged: (value) => setState(() => _scale = value), + ), + ), + _subsection(context, '带刻度双游标滑块'), + _panel( + TRangeSlider( + key: const ValueKey('slider-scale-range'), + value: _scaleRange, + min: 0, + max: 100, + divisions: 5, + showScaleValue: true, + scaleFormatter: _integer, + onChanged: (value) => setState(() => _scaleRange = value), + ), + ), + ], + ); + } diff --git a/tdesign-component/example/assets/code/slider._buildSingle.txt b/tdesign-component/example/assets/code/slider._buildSingle.txt index c5d309cea..32e0492f1 100644 --- a/tdesign-component/example/assets/code/slider._buildSingle.txt +++ b/tdesign-component/example/assets/code/slider._buildSingle.txt @@ -1,10 +1,9 @@ -Widget _buildSingle(BuildContext context) => _withValueLabel( - context, - label: '当前值:${singleValue.toStringAsFixed(0)}', - child: TSlider( - value: singleValue, - min: 0, - max: 100, - onChanged: (next) => setState(() => singleValue = next), - ), - ); +Widget _buildSingle(BuildContext context) => _panel( + TSlider( + key: const ValueKey('slider-single'), + value: _single, + min: 0, + max: 100, + onChanged: (value) => setState(() => _single = value), + ), + ); diff --git a/tdesign-component/example/assets/code/slider._buildTheme.txt b/tdesign-component/example/assets/code/slider._buildTheme.txt deleted file mode 100644 index 6f5a00ffc..000000000 --- a/tdesign-component/example/assets/code/slider._buildTheme.txt +++ /dev/null @@ -1,27 +0,0 @@ -Widget _buildTheme(BuildContext context) { - final decoration = TSliderThemeData( - decoration: BoxDecoration( - color: context.tTheme.bgColorSecondaryContainer, - borderRadius: BorderRadius.circular(context.tTheme.radiusDefault), - ), - ); - return Theme( - data: Theme.of(context).mergeExtension(decoration), - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - activeTrackColor: context.tTheme.successNormalColor, - thumbColor: context.tTheme.successNormalColor, - trackHeight: 6, - ), - child: TSlider( - value: themedValue, - min: 0, - max: 100, - divisions: 5, - showScaleValue: true, - scaleFormatter: _formatPercent, - onChanged: (next) => setState(() => themedValue = next), - ), - ), - ); - } diff --git a/tdesign-component/example/assets/code/slider._buildVertical.txt b/tdesign-component/example/assets/code/slider._buildVertical.txt new file mode 100644 index 000000000..eb4527e6e --- /dev/null +++ b/tdesign-component/example/assets/code/slider._buildVertical.txt @@ -0,0 +1,130 @@ +Widget _buildVertical(BuildContext context) { + final capsuleTheme = SliderTheme.of(context).copyWith( + trackHeight: 16, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), + rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 8), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 18), + ); + + Widget verticalSlider({ + required Widget slider, + String? thumbLabel, + double? normalizedValue, + bool showMarks = false, + Key? labelKey, + Key? marksKey, + }) { + return SizedBox( + height: 210, + child: Center( + child: SizedBox( + width: 100, + height: 200, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 8, + top: 0, + child: SizedBox( + width: 48, + height: 200, + child: RotatedBox(quarterTurns: 1, child: slider), + ), + ), + if (showMarks) + Positioned( + left: 58, + top: 24, + bottom: 24, + child: Column( + key: marksKey, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + TText('0'), + TText('20'), + TText('40'), + TText('60'), + TText('80'), + TText('100'), + ], + ), + ), + if (thumbLabel != null && normalizedValue != null) + Positioned( + left: 58, + top: 16 + normalizedValue * 152, + child: TText(thumbLabel, key: labelKey), + ), + ], + ), + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _subsection(context, '单游标垂直滑块', top: false), + verticalSlider( + slider: TSlider( + key: const ValueKey('slider-vertical'), + value: _vertical, + min: 0, + max: 100, + onChanged: (value) => setState(() => _vertical = value), + ), + thumbLabel: _percent(_vertical), + normalizedValue: _vertical / 100, + labelKey: const ValueKey('slider-vertical-label'), + ), + _subsection(context, '带刻度的双游标垂直滑块'), + verticalSlider( + slider: TRangeSlider( + key: const ValueKey('slider-vertical-scale-range'), + value: _verticalScaleRange, + min: 0, + max: 100, + divisions: 5, + onChanged: (value) => setState(() => _verticalScaleRange = value), + ), + showMarks: true, + marksKey: const ValueKey('slider-vertical-scale-labels'), + ), + _subsection(context, '胶囊型垂直滑块'), + verticalSlider( + slider: SliderTheme( + data: capsuleTheme, + child: TSlider( + key: const ValueKey('slider-vertical-capsule'), + value: _verticalCapsule, + min: 0, + max: 100, + onChanged: (value) => setState(() => _verticalCapsule = value), + ), + ), + thumbLabel: _percent(_verticalCapsule), + normalizedValue: _verticalCapsule / 100, + labelKey: const ValueKey('slider-vertical-capsule-label'), + ), + _subsection(context, '带刻度的胶囊型垂直滑块'), + verticalSlider( + slider: SliderTheme( + data: capsuleTheme, + child: TRangeSlider( + key: const ValueKey('slider-vertical-capsule-scale-range'), + value: _verticalCapsuleScaleRange, + min: 0, + max: 100, + divisions: 5, + onChanged: (value) => + setState(() => _verticalCapsuleScaleRange = value), + ), + ), + showMarks: true, + marksKey: const ValueKey('slider-vertical-capsule-scale-labels'), + ), + ], + ); + } diff --git a/tdesign-component/example/lib/page/t_slider_page.dart b/tdesign-component/example/lib/page/t_slider_page.dart index 65bdbfe17..fe147288f 100644 --- a/tdesign-component/example/lib/page/t_slider_page.dart +++ b/tdesign-component/example/lib/page/t_slider_page.dart @@ -4,7 +4,6 @@ import 'package:tdesign_flutter/tdesign_flutter.dart'; import '../annotation/example_code.dart'; import '../base/example_widget.dart'; -/// TSlider 示例页。 class TSliderPage extends StatefulWidget { const TSliderPage({super.key}); @@ -13,175 +12,412 @@ class TSliderPage extends StatefulWidget { } class _TSliderPageState extends State { - double singleValue = 35; - RangeValues rangeValue = const RangeValues(20, 70); - double advancedValue = 65; - RangeValues advancedRange = const RangeValues(25, 80); - double themedValue = 50; - String singleEventText = '拖动滑块查看 onChangeStart / onChangeEnd'; - String rangeEventText = '拖动范围滑块查看生命周期回调'; + double _single = 23; + RangeValues _range = const RangeValues(35, 65); + double _labeled = 35; + RangeValues _labeledRange = const RangeValues(40, 60); + double _nonZero = 30; + double _scale = 60; + RangeValues _scaleRange = const RangeValues(20, 60); + double _capsule = 30; + RangeValues _capsuleRange = const RangeValues(40, 60); + RangeValues _capsuleLabeledRange = const RangeValues(40, 60); + double _capsuleScale = 60; + RangeValues _capsuleScaleRange = const RangeValues(20, 80); + double _vertical = 35; + RangeValues _verticalScaleRange = const RangeValues(20, 60); + double _verticalCapsule = 35; + RangeValues _verticalCapsuleScaleRange = const RangeValues(20, 80); @override - Widget build(BuildContext context) { - return ExamplePage( - title: tTitle(), - desc: '用于选择横轴上的数值或区间。', - exampleCodeGroup: 'slider', + Widget build(BuildContext context) => ExamplePage( + title: tTitle(), + desc: '用于选择横轴上的数值、区间、档位。', + exampleCodeGroup: 'slider', + compactDemo: true, + showTestModule: false, + children: [ + ExampleModule( + title: '组件类型', + children: [ + ExampleItem(desc: '单游标滑块', builder: _buildSingle), + ExampleItem(desc: '双游标滑块', builder: _buildRange), + ExampleItem(desc: '带数值滑动选择器', builder: _buildLabeled), + ExampleItem(desc: '起始非零滑动选择器', builder: _buildNonZero), + ExampleItem(desc: '带刻度滑动选择器', builder: _buildScale), + ], + ), + ExampleModule( + title: '组件状态', + children: [ExampleItem(desc: '滑块禁用状态', builder: _buildDisabled)], + ), + ExampleModule( + title: '特殊样式', + children: [ExampleItem(desc: '胶囊型滑块', builder: _buildCapsule)], + ), + ExampleModule( + title: '垂直状态', + children: [ExampleItem(builder: _buildVertical)], + ), + ], + ); + + @ExampleCode(group: 'slider') + Widget _buildSingle(BuildContext context) => _panel( + TSlider( + key: const ValueKey('slider-single'), + value: _single, + min: 0, + max: 100, + onChanged: (value) => setState(() => _single = value), + ), + ); + + @ExampleCode(group: 'slider') + Widget _buildRange(BuildContext context) => _panel( + TRangeSlider( + key: const ValueKey('slider-range'), + value: _range, + min: 0, + max: 100, + onChanged: (value) => setState(() => _range = value), + ), + ); + + @ExampleCode(group: 'slider') + Widget _buildLabeled(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - ExampleModule( - title: '基础类型', - children: [ - ExampleItem(desc: '单值滑块', builder: _buildSingle), - ExampleItem(desc: '范围滑块(整数步进)', builder: _buildRange), - ], - ), - ExampleModule( - title: '能力组合', - children: [ - ExampleItem(desc: '单值:拇指值与刻度值', builder: _buildAdvanced), - ExampleItem(desc: '范围:拇指值与刻度值', builder: _buildAdvancedRange), - ], + _panel( + TSlider( + key: const ValueKey('slider-labeled'), + value: _labeled, + min: 0, + max: 100, + showThumbValue: true, + thumbFormatter: _percent, + onChanged: (value) => setState(() => _labeled = value), + ), ), - ExampleModule( - title: '状态与主题', + _subsection(context, '带数值双游标滑块'), + Row( children: [ - ExampleItem(desc: '禁用状态', builder: _buildDisabled), - ExampleItem(desc: '全局与局部主题', builder: _buildTheme), + const TText('0%'), + Expanded( + child: TRangeSlider( + key: const ValueKey('slider-labeled-range'), + value: _labeledRange, + min: 0, + max: 100, + showThumbValue: true, + thumbFormatter: _percent, + onChanged: (value) => setState(() => _labeledRange = value), + ), + ), + const TText('100%'), ], ), ], - test: const [], ); } @ExampleCode(group: 'slider') - Widget _buildSingle(BuildContext context) => _withValueLabel( - context, - label: '当前值:${singleValue.toStringAsFixed(0)}', - child: TSlider( - value: singleValue, - min: 0, - max: 100, - onChanged: (next) => setState(() => singleValue = next), - ), - ); - - @ExampleCode(group: 'slider') - Widget _buildRange(BuildContext context) => _withValueLabel( - context, - label: - '当前范围:${rangeValue.start.toStringAsFixed(0)} - ${rangeValue.end.toStringAsFixed(0)}', - child: TRangeSlider( - value: rangeValue, - min: 0, - max: 100, - divisions: 100, - onChanged: (next) => setState(() => rangeValue = next), - ), - ); + Widget _buildNonZero(BuildContext context) => _panel( + TSlider( + key: const ValueKey('slider-non-zero'), + value: _nonZero, + min: 20, + max: 100, + divisions: 4, + showScaleValue: true, + scaleFormatter: _integer, + onChanged: (value) => setState(() => _nonZero = value), + ), + ); @ExampleCode(group: 'slider') - Widget _buildAdvanced(BuildContext context) => _withValueLabel( - context, - label: singleEventText, - child: TSlider( - value: advancedValue, - min: 0, - max: 100, - divisions: 5, - showThumbValue: true, - thumbFormatter: _formatPercent, - showScaleValue: true, - scaleFormatter: _formatPercent, - onChanged: (next) => setState(() => advancedValue = next), - onChangeStart: (_) => - setState(() => singleEventText = 'onChangeStart'), - onChangeEnd: (_) => setState(() => singleEventText = 'onChangeEnd'), + Widget _buildScale(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _panel( + TSlider( + key: const ValueKey('slider-scale'), + value: _scale, + min: 0, + max: 100, + divisions: 5, + showScaleValue: true, + scaleFormatter: _integer, + onChanged: (value) => setState(() => _scale = value), + ), ), - ); - - @ExampleCode(group: 'slider') - Widget _buildAdvancedRange(BuildContext context) => _withValueLabel( - context, - label: rangeEventText, - child: TRangeSlider( - value: advancedRange, - min: 0, - max: 100, - divisions: 5, - showThumbValue: true, - thumbFormatter: _formatPercent, - showScaleValue: true, - scaleFormatter: _formatPercent, - onChanged: (next) => setState(() => advancedRange = next), - onChangeStart: (_) => - setState(() => rangeEventText = '范围 onChangeStart'), - onChangeEnd: (_) => setState(() => rangeEventText = '范围 onChangeEnd'), + _subsection(context, '带刻度双游标滑块'), + _panel( + TRangeSlider( + key: const ValueKey('slider-scale-range'), + value: _scaleRange, + min: 0, + max: 100, + divisions: 5, + showScaleValue: true, + scaleFormatter: _integer, + onChanged: (value) => setState(() => _scaleRange = value), + ), ), - ); + ], + ); + } @ExampleCode(group: 'slider') Widget _buildDisabled(BuildContext context) => const Column( - mainAxisSize: MainAxisSize.min, + children: [ + TSlider(key: ValueKey('slider-disabled'), value: 35, min: 0, max: 100), + SizedBox(height: 16), + Row( children: [ - TSlider(value: 40, min: 0, max: 100), - TRangeSlider( - value: RangeValues(20, 70), - min: 0, - max: 100, + TText('0%'), + Expanded( + child: TRangeSlider( + key: ValueKey('slider-disabled-labeled-range'), + value: RangeValues(40, 60), + min: 0, + max: 100, + showThumbValue: true, + thumbFormatter: _percent, + ), ), + TText('100%'), ], - ); + ), + SizedBox(height: 16), + TRangeSlider( + key: ValueKey('slider-disabled-scale-range'), + value: RangeValues(20, 60), + min: 0, + max: 100, + divisions: 5, + showScaleValue: true, + scaleFormatter: _integer, + ), + ], + ); @ExampleCode(group: 'slider') - Widget _buildTheme(BuildContext context) { - final decoration = TSliderThemeData( - decoration: BoxDecoration( - color: context.tTheme.bgColorSecondaryContainer, - borderRadius: BorderRadius.circular(context.tTheme.radiusDefault), - ), + Widget _buildCapsule(BuildContext context) { + final capsuleTheme = SliderTheme.of(context).copyWith( + trackHeight: 16, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), + rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 8), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 18), ); - return Theme( - data: Theme.of(context).mergeExtension(decoration), - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - activeTrackColor: context.tTheme.successNormalColor, - thumbColor: context.tTheme.successNormalColor, - trackHeight: 6, + + Widget capsule(Widget slider) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: SliderTheme(data: capsuleTheme, child: slider), + ); + + return Column( + children: [ + capsule( + TSlider( + key: const ValueKey('slider-capsule'), + value: _capsule, + min: 0, + max: 100, + onChanged: (value) => setState(() => _capsule = value), + ), ), - child: TSlider( - value: themedValue, - min: 0, - max: 100, - divisions: 5, - showScaleValue: true, - scaleFormatter: _formatPercent, - onChanged: (next) => setState(() => themedValue = next), + capsule( + TRangeSlider( + key: const ValueKey('slider-capsule-range'), + value: _capsuleRange, + min: 0, + max: 100, + onChanged: (value) => setState(() => _capsuleRange = value), + ), ), - ), + capsule( + TRangeSlider( + key: const ValueKey('slider-capsule-labeled-range'), + value: _capsuleLabeledRange, + min: 0, + max: 100, + showThumbValue: true, + thumbFormatter: _percent, + onChanged: (value) => setState(() => _capsuleLabeledRange = value), + ), + ), + capsule( + TSlider( + key: const ValueKey('slider-capsule-scale'), + value: _capsuleScale, + min: 0, + max: 100, + divisions: 5, + showScaleValue: true, + scaleFormatter: _integer, + onChanged: (value) => setState(() => _capsuleScale = value), + ), + ), + capsule( + TRangeSlider( + key: const ValueKey('slider-capsule-scale-range'), + value: _capsuleScaleRange, + min: 0, + max: 100, + divisions: 5, + showScaleValue: true, + scaleFormatter: _integer, + onChanged: (value) => setState(() => _capsuleScaleRange = value), + ), + ), + ], ); } - String _formatPercent(double value) => '${value.toInt()}%'; + @ExampleCode(group: 'slider') + Widget _buildVertical(BuildContext context) { + final capsuleTheme = SliderTheme.of(context).copyWith( + trackHeight: 16, + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), + rangeThumbShape: const RoundRangeSliderThumbShape(enabledThumbRadius: 8), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 18), + ); + + Widget verticalSlider({ + required Widget slider, + String? thumbLabel, + double? normalizedValue, + bool showMarks = false, + Key? labelKey, + Key? marksKey, + }) { + return SizedBox( + height: 210, + child: Center( + child: SizedBox( + width: 100, + height: 200, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 8, + top: 0, + child: SizedBox( + width: 48, + height: 200, + child: RotatedBox(quarterTurns: 1, child: slider), + ), + ), + if (showMarks) + Positioned( + left: 58, + top: 24, + bottom: 24, + child: Column( + key: marksKey, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + TText('0'), + TText('20'), + TText('40'), + TText('60'), + TText('80'), + TText('100'), + ], + ), + ), + if (thumbLabel != null && normalizedValue != null) + Positioned( + left: 58, + top: 16 + normalizedValue * 152, + child: TText(thumbLabel, key: labelKey), + ), + ], + ), + ), + ), + ); + } - Widget _withValueLabel( - BuildContext context, { - required String label, - required Widget child, - }) { return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - child, - const SizedBox(height: 8), - TText( - label, - textColor: context.tTheme.textColorPlaceholder, - maxLines: 1, - overflow: TextOverflow.ellipsis, + _subsection(context, '单游标垂直滑块', top: false), + verticalSlider( + slider: TSlider( + key: const ValueKey('slider-vertical'), + value: _vertical, + min: 0, + max: 100, + onChanged: (value) => setState(() => _vertical = value), + ), + thumbLabel: _percent(_vertical), + normalizedValue: _vertical / 100, + labelKey: const ValueKey('slider-vertical-label'), + ), + _subsection(context, '带刻度的双游标垂直滑块'), + verticalSlider( + slider: TRangeSlider( + key: const ValueKey('slider-vertical-scale-range'), + value: _verticalScaleRange, + min: 0, + max: 100, + divisions: 5, + onChanged: (value) => setState(() => _verticalScaleRange = value), + ), + showMarks: true, + marksKey: const ValueKey('slider-vertical-scale-labels'), + ), + _subsection(context, '胶囊型垂直滑块'), + verticalSlider( + slider: SliderTheme( + data: capsuleTheme, + child: TSlider( + key: const ValueKey('slider-vertical-capsule'), + value: _verticalCapsule, + min: 0, + max: 100, + onChanged: (value) => setState(() => _verticalCapsule = value), + ), + ), + thumbLabel: _percent(_verticalCapsule), + normalizedValue: _verticalCapsule / 100, + labelKey: const ValueKey('slider-vertical-capsule-label'), + ), + _subsection(context, '带刻度的胶囊型垂直滑块'), + verticalSlider( + slider: SliderTheme( + data: capsuleTheme, + child: TRangeSlider( + key: const ValueKey('slider-vertical-capsule-scale-range'), + value: _verticalCapsuleScaleRange, + min: 0, + max: 100, + divisions: 5, + onChanged: (value) => + setState(() => _verticalCapsuleScaleRange = value), + ), + ), + showMarks: true, + marksKey: const ValueKey('slider-vertical-capsule-scale-labels'), ), ], ); } + + static String _percent(double value) => '${value.round()}%'; + static String _integer(double value) => value.round().toString(); + + Widget _panel(Widget child) => + Padding(padding: const EdgeInsets.symmetric(horizontal: 8), child: child); + + Widget _subsection(BuildContext context, String text, {bool top = true}) => + Padding( + padding: EdgeInsets.only(top: top ? 12 : 0, bottom: 8), + child: TText(text, font: context.tTheme.fontBodyMedium), + ); } diff --git a/tdesign-component/example/test/fonts/README.md b/tdesign-component/example/test/fonts/README.md index 0f9e4e01b..fa69149e3 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 的字形选择与像素基线。 +`SliderGoldenCJK-Regular.otf` 仅补充 Slider 整页 Golden 新增且既有子集未覆盖的 +字形,字符清单见 `slider_demo_glyphs.txt`,不会改变既有组件的字形选择。子集 +SHA-256 为 `59c5bebba9bf720005fb977a94b9c150b0b8e8c2698c1dca3a66d9f0d7cd9722`。 + `BackTopGoldenCJK-Regular.otf` 仅补充 BackTop 公开说明中的“帮”,字符清单见 `backtop_demo_glyphs.txt`。它作为 BackTop Demo 最后的专用 fallback,不改变其他页面 或既有共享字体的像素基线,子集 SHA-256 为 @@ -82,6 +86,7 @@ HarfBuzz 11.4.5,子集 SHA-256 为 Textarea、Upload、PullDownRefresh、Toast 或 SwipeCell Demo 页面文案后,更新原字符清单; 更新 Loading、Message、Popover 或 Popup 页面文案时,更新补充字符清单;更新 ActionSheet、Dialog、DropdownMenu 或 NoticeBar 页面文案时,更新 feedback 字符清单。 +更新 Slider 页面文案时,更新 Slider 字符清单。 更新 Picker 页面文案时,更新 Picker 字符清单。 更新 Calendar 页面文案时,更新 Calendar 字符清单。 更新 TabBar 页面文案时,更新 TabBar 字符清单。 diff --git a/tdesign-component/example/test/fonts/SliderGoldenCJK-Regular.otf b/tdesign-component/example/test/fonts/SliderGoldenCJK-Regular.otf new file mode 100644 index 000000000..49fb3a862 Binary files /dev/null and b/tdesign-component/example/test/fonts/SliderGoldenCJK-Regular.otf differ diff --git a/tdesign-component/example/test/fonts/slider_demo_glyphs.txt b/tdesign-component/example/test/fonts/slider_demo_glyphs.txt new file mode 100644 index 000000000..b7731df68 --- /dev/null +++ b/tdesign-component/example/test/fonts/slider_demo_glyphs.txt @@ -0,0 +1 @@ +刻囊始档游胶零 diff --git a/tdesign-component/example/test/goldens/loading_page_dark.png b/tdesign-component/example/test/goldens/loading_page_dark.png index 2924f0a8f..bb6e532db 100644 Binary files a/tdesign-component/example/test/goldens/loading_page_dark.png and b/tdesign-component/example/test/goldens/loading_page_dark.png differ diff --git a/tdesign-component/example/test/goldens/loading_page_light.png b/tdesign-component/example/test/goldens/loading_page_light.png index 7c5f1142b..d5904b787 100644 Binary files a/tdesign-component/example/test/goldens/loading_page_light.png and b/tdesign-component/example/test/goldens/loading_page_light.png differ diff --git a/tdesign-component/example/test/goldens/slider_page_dark.png b/tdesign-component/example/test/goldens/slider_page_dark.png new file mode 100644 index 000000000..7a3645713 Binary files /dev/null and b/tdesign-component/example/test/goldens/slider_page_dark.png differ diff --git a/tdesign-component/example/test/goldens/slider_page_light.png b/tdesign-component/example/test/goldens/slider_page_light.png new file mode 100644 index 000000000..1cf24a902 Binary files /dev/null and b/tdesign-component/example/test/goldens/slider_page_light.png differ diff --git a/tdesign-component/example/test/slider_demo_golden_test.dart b/tdesign-component/example/test/slider_demo_golden_test.dart new file mode 100644 index 000000000..29551393f --- /dev/null +++ b/tdesign-component/example/test/slider_demo_golden_test.dart @@ -0,0 +1,4 @@ +import 'demo_page_test_utils.dart'; +import 'slider_demo_test_spec.dart'; + +void main() => registerDemoGoldenTests(sliderDemoPageTestSpec); diff --git a/tdesign-component/example/test/slider_demo_test.dart b/tdesign-component/example/test/slider_demo_test.dart new file mode 100644 index 000000000..998eec6ca --- /dev/null +++ b/tdesign-component/example/test/slider_demo_test.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tdesign_flutter/tdesign_flutter.dart'; + +import 'demo_page_test_utils.dart'; +import 'slider_demo_test_spec.dart'; + +void main() { + registerDemoStructureTests(sliderDemoPageTestSpec); + + testWidgets('Slider Demo keeps official scenarios in order', (tester) async { + await pumpFullDemoPage(tester, sliderDemoPageTestSpec, ThemeMode.light); + const keys = [ + 'single', + 'range', + 'labeled', + 'labeled-range', + 'non-zero', + 'scale', + 'scale-range', + 'disabled', + 'disabled-labeled-range', + 'disabled-scale-range', + 'capsule', + 'capsule-range', + 'capsule-labeled-range', + 'capsule-scale', + 'capsule-scale-range', + 'vertical', + 'vertical-scale-range', + 'vertical-capsule', + 'vertical-capsule-scale-range', + ]; + final tops = keys + .map((id) => tester.getTopLeft(find.byKey(ValueKey('slider-$id'))).dy) + .toList(); + expect(tops, orderedEquals([...tops]..sort())); + expect(find.byType(TSlider), findsNWidgets(9)); + expect(find.byType(TRangeSlider), findsNWidgets(10)); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('each official scenario keeps its key parameters', ( + tester, + ) async { + await pumpFullDemoPage(tester, sliderDemoPageTestSpec, ThemeMode.light); + final single = tester.widget( + find.byKey(const ValueKey('slider-single')), + ); + expect(single.value, 23); + expect(single.onChanged, isNotNull); + + final range = tester.widget( + find.byKey(const ValueKey('slider-range')), + ); + expect(range.value, const RangeValues(35, 65)); + expect(range.onChanged, isNotNull); + + final labeled = tester.widget( + find.byKey(const ValueKey('slider-labeled')), + ); + expect(labeled.value, 35); + expect(labeled.showThumbValue, isTrue); + expect(labeled.thumbFormatter, isNotNull); + + final labeledRange = tester.widget( + find.byKey(const ValueKey('slider-labeled-range')), + ); + expect(labeledRange.value, const RangeValues(40, 60)); + expect(labeledRange.showThumbValue, isTrue); + expect(labeledRange.thumbFormatter, isNotNull); + + final nonZero = tester.widget( + find.byKey(const ValueKey('slider-non-zero')), + ); + expect(nonZero.value, 30); + expect(nonZero.min, 20); + expect(nonZero.max, 100); + expect(nonZero.divisions, 4); + expect(nonZero.showScaleValue, isTrue); + + for (final key in ['slider-scale', 'slider-capsule-scale']) { + final slider = tester.widget(find.byKey(ValueKey(key))); + expect(slider.divisions, 5, reason: key); + expect(slider.showScaleValue, isTrue, reason: key); + } + for (final key in [ + 'slider-scale-range', + 'slider-disabled-scale-range', + 'slider-capsule-scale-range', + ]) { + final slider = tester.widget(find.byKey(ValueKey(key))); + expect(slider.divisions, 5, reason: key); + expect(slider.showScaleValue, isTrue, reason: key); + } + + for (final key in [ + 'slider-disabled', + 'slider-disabled-labeled-range', + 'slider-disabled-scale-range', + ]) { + final widget = tester.widget(find.byKey(ValueKey(key))); + final onChanged = switch (widget) { + TSlider() => widget.onChanged, + TRangeSlider() => widget.onChanged, + _ => throw StateError('Unexpected slider type for $key'), + }; + expect(onChanged, isNull, reason: key); + } + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('controlled slider updates after a real drag', (tester) async { + await pumpFullDemoPage(tester, sliderDemoPageTestSpec, ThemeMode.light); + final slider = find.descendant( + of: find.byKey(const ValueKey('slider-single')), + matching: find.byType(Slider), + ); + await tester.drag(slider, const Offset(80, 0)); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byKey(const ValueKey('slider-single'))).value, + greaterThan(23), + ); + await disposeDemoPage(tester); + }, tags: 'demo'); + + testWidgets('vertical labels stay upright and capsule styles are explicit', ( + tester, + ) async { + await pumpFullDemoPage(tester, sliderDemoPageTestSpec, ThemeMode.light); + for (final key in [ + 'slider-vertical', + 'slider-vertical-scale-range', + 'slider-vertical-capsule', + 'slider-vertical-capsule-scale-range', + ]) { + final rotated = tester.widget( + find.ancestor( + of: find.byKey(ValueKey(key)), + matching: find.byType(RotatedBox), + ), + ); + expect(rotated.quarterTurns, 1, reason: key); + } + final scaleLabels = tester + .widgetList( + find.descendant( + of: find.byKey(const ValueKey('slider-vertical-scale-labels')), + matching: find.byType(TText), + ), + ) + .map((text) => text.data) + .toList(); + expect(scaleLabels, orderedEquals(['0', '20', '40', '60', '80', '100'])); + for (final key in [ + 'slider-vertical-label', + 'slider-vertical-scale-labels', + 'slider-vertical-capsule-label', + 'slider-vertical-capsule-scale-labels', + ]) { + expect( + find.ancestor( + of: find.byKey(ValueKey(key)), + matching: find.byType(RotatedBox), + ), + findsNothing, + reason: key, + ); + } + for (final key in [ + 'slider-capsule', + 'slider-capsule-range', + 'slider-capsule-labeled-range', + 'slider-capsule-scale', + 'slider-capsule-scale-range', + 'slider-vertical-capsule', + 'slider-vertical-capsule-scale-range', + ]) { + expect( + find.ancestor( + of: find.byKey(ValueKey(key)), + matching: find.byType(SliderTheme), + ), + findsWidgets, + reason: key, + ); + } + await disposeDemoPage(tester); + }, tags: 'demo'); +} diff --git a/tdesign-component/example/test/slider_demo_test_spec.dart b/tdesign-component/example/test/slider_demo_test_spec.dart new file mode 100644 index 000000000..5e858b195 --- /dev/null +++ b/tdesign-component/example/test/slider_demo_test_spec.dart @@ -0,0 +1,32 @@ +import 'package:tdesign_flutter_example/page/t_slider_page.dart'; + +import 'demo_page_test_utils.dart'; + +const sliderDemoPageTestSpec = DemoPageTestSpec( + name: 'slider', + title: 'Slider 滑动选择器', + page: TSliderPage(), + expectedTexts: [ + '用于选择横轴上的数值、区间、档位。', + '01 组件类型', + '单游标滑块', + '双游标滑块', + '带数值滑动选择器', + '带数值双游标滑块', + '起始非零滑动选择器', + '带刻度滑动选择器', + '带刻度双游标滑块', + '02 组件状态', + '滑块禁用状态', + '03 特殊样式', + '胶囊型滑块', + '04 垂直状态', + '单游标垂直滑块', + '带刻度的双游标垂直滑块', + '胶囊型垂直滑块', + '带刻度的胶囊型垂直滑块', + ], + useAlignmentCjkFont: true, + supplementalCjkFontFamily: 'TDesign Slider Golden CJK', + supplementalCjkFontPath: 'test/fonts/SliderGoldenCJK-Regular.otf', +); diff --git a/tdesign-component/lib/src/components/slider/t_slider.dart b/tdesign-component/lib/src/components/slider/t_slider.dart index c61e85377..94a734adf 100644 --- a/tdesign-component/lib/src/components/slider/t_slider.dart +++ b/tdesign-component/lib/src/components/slider/t_slider.dart @@ -8,49 +8,104 @@ import 't_slider_theme.dart'; /// Formats the value shown above a slider thumb. typedef TSliderThumbFormatter = String Function(double value); -ShowValueIndicator _showValueIndicatorFor(int? divisions) => divisions == null - ? ShowValueIndicator.onlyForContinuous - : ShowValueIndicator.onlyForDiscrete; - SliderThemeData _sliderThemeWithTokenFallback(BuildContext context) { final inherited = SliderTheme.of(context); + final material = Theme.of(context); + final colorScheme = material.tExplicitColorScheme; final token = context.tTheme; final brand = token.brandNormalColor; - final component = token.bgColorComponent; - final disabled = token.bgColorComponentDisabled; + final inactiveTrack = token.bgColorComponentHover; + final disabledComponent = token.bgColorComponentDisabled; + final disabledBrand = token.brandDisabledColor; + final thumb = colorScheme?.primary ?? token.textColorAnti; + final disabledThumb = colorScheme == null + ? token.textColorAnti + : colorScheme.onSurface.withValues(alpha: 0.38); + final thumbBorder = colorScheme?.outline ?? token.grayColor1; + final disabledThumbBorder = + colorScheme?.outlineVariant ?? token.bgColorComponentDisabled; return inherited.copyWith( - activeTrackColor: inherited.activeTrackColor ?? brand, - inactiveTrackColor: inherited.inactiveTrackColor ?? component, + activeTrackColor: + inherited.activeTrackColor ?? colorScheme?.primary ?? brand, + inactiveTrackColor: + inherited.inactiveTrackColor ?? + colorScheme?.surfaceContainerHighest ?? + inactiveTrack, secondaryActiveTrackColor: - inherited.secondaryActiveTrackColor ?? brand.withAlpha(0x8a), - disabledActiveTrackColor: inherited.disabledActiveTrackColor ?? disabled, + inherited.secondaryActiveTrackColor ?? + colorScheme?.primary.withValues(alpha: 0.54) ?? + brand.withAlpha(0x8a), + disabledActiveTrackColor: + inherited.disabledActiveTrackColor ?? + colorScheme?.primary.withValues(alpha: 0.38) ?? + disabledBrand, disabledInactiveTrackColor: - inherited.disabledInactiveTrackColor ?? disabled, + inherited.disabledInactiveTrackColor ?? + colorScheme?.onSurface.withValues(alpha: 0.12) ?? + disabledComponent, disabledSecondaryActiveTrackColor: - inherited.disabledSecondaryActiveTrackColor ?? disabled, - activeTickMarkColor: inherited.activeTickMarkColor ?? brand, - inactiveTickMarkColor: inherited.inactiveTickMarkColor ?? component, + inherited.disabledSecondaryActiveTrackColor ?? + colorScheme?.primary.withValues(alpha: 0.38) ?? + disabledBrand, + activeTickMarkColor: + inherited.activeTickMarkColor ?? colorScheme?.primary ?? brand, + inactiveTickMarkColor: + inherited.inactiveTickMarkColor ?? + colorScheme?.surfaceContainerHighest ?? + inactiveTrack, disabledActiveTickMarkColor: - inherited.disabledActiveTickMarkColor ?? disabled, + inherited.disabledActiveTickMarkColor ?? + colorScheme?.primary.withValues(alpha: 0.38) ?? + disabledBrand, disabledInactiveTickMarkColor: - inherited.disabledInactiveTickMarkColor ?? disabled, - thumbColor: inherited.thumbColor ?? brand, - disabledThumbColor: inherited.disabledThumbColor ?? disabled, - overlayColor: inherited.overlayColor ?? brand.withAlpha(0x1f), - valueIndicatorColor: inherited.valueIndicatorColor ?? brand, - valueIndicatorStrokeColor: inherited.valueIndicatorStrokeColor ?? brand, + inherited.disabledInactiveTickMarkColor ?? + colorScheme?.onSurface.withValues(alpha: 0.12) ?? + disabledComponent, + thumbColor: inherited.thumbColor ?? thumb, + disabledThumbColor: inherited.disabledThumbColor ?? disabledThumb, + thumbShape: + inherited.thumbShape ?? + _TDesignSliderThumbShape( + borderColor: thumbBorder, + disabledBorderColor: disabledThumbBorder, + ), + rangeThumbShape: + inherited.rangeThumbShape ?? + _TDesignRangeSliderThumbShape( + borderColor: thumbBorder, + disabledBorderColor: disabledThumbBorder, + ), + overlayColor: + inherited.overlayColor ?? + colorScheme?.primary.withValues(alpha: 0.12) ?? + brand.withAlpha(0x1f), + valueIndicatorColor: + inherited.valueIndicatorColor ?? colorScheme?.primary ?? brand, + valueIndicatorStrokeColor: + inherited.valueIndicatorStrokeColor ?? colorScheme?.primary ?? brand, valueIndicatorTextStyle: inherited.valueIndicatorTextStyle ?? TextStyle( - color: token.textColorAnti, - fontSize: token.fontBodyLarge?.size, - height: token.fontBodyLarge?.height, - fontWeight: token.fontBodyLarge?.fontWeight, - ).merge(Theme.of(context).tExplicitTextTheme?.bodyLarge), + color: colorScheme?.onSurface ?? token.textColorPrimary, + fontSize: token.fontBodyMedium?.size, + height: token.fontBodyMedium?.height, + fontWeight: token.fontBodyMedium?.fontWeight, + ).merge(material.tExplicitTextTheme?.bodyMedium), ); } +Color _disabledThumbLabelColor(BuildContext context) { + final inheritedColor = SliderTheme.of(context).valueIndicatorTextStyle?.color; + if (inheritedColor != null) { + return inheritedColor.withValues(alpha: 0.38); + } + return Theme.of( + context, + ).tExplicitColorScheme?.onSurface.withValues(alpha: 0.38) ?? + context.tTheme.textDisabledColor; +} + /// 基于 Material [Slider] 的严格受控单值滑块。 class TSlider extends StatelessWidget { const TSlider({ @@ -77,7 +132,7 @@ class TSlider extends StatelessWidget { /// 离散刻度数;null 表示连续。 this.divisions, - /// 是否显示拇指上方数值。 + /// 是否持续显示拇指上方数值。 this.showThumbValue = false, /// 拇指上方数值格式化回调。 @@ -114,7 +169,7 @@ class TSlider extends StatelessWidget { /// 离散刻度数;null 表示连续。 final int? divisions; - /// 是否显示拇指上方数值。 + /// 是否持续显示拇指上方数值。 final bool showThumbValue; /// 拇指上方数值格式化回调。 @@ -128,6 +183,9 @@ class TSlider extends StatelessWidget { @override Widget build(BuildContext context) { + final label = showThumbValue + ? (thumbFormatter?.call(value) ?? value.toStringAsFixed(2)) + : null; final slider = Slider( value: value, onChanged: onChanged, @@ -136,18 +194,26 @@ class TSlider extends StatelessWidget { min: min, max: max, divisions: divisions, - label: showThumbValue - ? (thumbFormatter?.call(value) ?? value.toStringAsFixed(2)) - : null, + label: label, ); - final sliderTheme = _sliderThemeWithTokenFallback(context).copyWith( - showValueIndicator: - showThumbValue ? _showValueIndicatorFor(divisions) : null, + final baseTheme = _sliderThemeWithTokenFallback(context); + final sliderTheme = baseTheme.copyWith( + showValueIndicator: showThumbValue ? ShowValueIndicator.never : null, + thumbShape: label == null + ? baseTheme.thumbShape + : _LabeledSliderThumbShape( + base: baseTheme.thumbShape!, + label: label, + disabledLabelColor: _disabledThumbLabelColor(context), + ), ); final decoration = Theme.of( context, ).extension()?.decoration; final themedSlider = SliderTheme(data: sliderTheme, child: slider); + final sliderContent = showThumbValue + ? Padding(padding: const EdgeInsets.only(top: 20), child: themedSlider) + : themedSlider; final safeDivisions = divisions != null && divisions! > 0 ? divisions : null; @@ -157,9 +223,9 @@ class TSlider extends StatelessWidget { max: max, divisions: safeDivisions, formatter: scaleFormatter, - slider: themedSlider, + slider: sliderContent, ) - : themedSlider; + : sliderContent; return decoration == null ? content : DecoratedBox(decoration: decoration, child: content); @@ -192,7 +258,7 @@ class TRangeSlider extends StatelessWidget { /// 离散刻度数;null 表示连续。 this.divisions, - /// 是否显示拇指上方数值。 + /// 是否持续显示拇指上方数值。 this.showThumbValue = false, /// 拇指上方数值格式化回调。 @@ -228,7 +294,7 @@ class TRangeSlider extends StatelessWidget { /// 离散刻度数;null 表示连续。 final int? divisions; - /// 是否显示拇指上方数值。 + /// 是否持续显示拇指上方数值。 final bool showThumbValue; /// 拇指上方数值格式化回调。 @@ -242,6 +308,13 @@ class TRangeSlider extends StatelessWidget { @override Widget build(BuildContext context) { + assert(value.start >= min && value.end <= max); + final labels = showThumbValue + ? RangeLabels( + thumbFormatter?.call(value.start) ?? value.start.toStringAsFixed(2), + thumbFormatter?.call(value.end) ?? value.end.toStringAsFixed(2), + ) + : null; final slider = RangeSlider( values: value, onChanged: onChanged, @@ -250,22 +323,26 @@ class TRangeSlider extends StatelessWidget { min: min, max: max, divisions: divisions, - labels: showThumbValue - ? RangeLabels( - thumbFormatter?.call(value.start) ?? - value.start.toStringAsFixed(2), - thumbFormatter?.call(value.end) ?? value.end.toStringAsFixed(2), - ) - : null, + labels: labels, ); - final sliderTheme = _sliderThemeWithTokenFallback(context).copyWith( - showValueIndicator: - showThumbValue ? _showValueIndicatorFor(divisions) : null, + final baseTheme = _sliderThemeWithTokenFallback(context); + final sliderTheme = baseTheme.copyWith( + showValueIndicator: showThumbValue ? ShowValueIndicator.never : null, + rangeThumbShape: labels == null + ? baseTheme.rangeThumbShape + : _LabeledRangeSliderThumbShape( + base: baseTheme.rangeThumbShape!, + labels: labels, + disabledLabelColor: _disabledThumbLabelColor(context), + ), ); final decoration = Theme.of( context, ).extension()?.decoration; final themedSlider = SliderTheme(data: sliderTheme, child: slider); + final sliderContent = showThumbValue + ? Padding(padding: const EdgeInsets.only(top: 20), child: themedSlider) + : themedSlider; final safeDivisions = divisions != null && divisions! > 0 ? divisions : null; @@ -275,15 +352,271 @@ class TRangeSlider extends StatelessWidget { max: max, divisions: safeDivisions, formatter: scaleFormatter, - slider: themedSlider, + slider: sliderContent, ) - : themedSlider; + : sliderContent; return decoration == null ? content : DecoratedBox(decoration: decoration, child: content); } } +class _TDesignSliderThumbShape extends SliderComponentShape { + const _TDesignSliderThumbShape({ + required this.borderColor, + required this.disabledBorderColor, + }); + + final Color borderColor; + final Color disabledBorderColor; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => + const Size.fromRadius(10); + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + _paintTDesignThumb( + context.canvas, + center, + enableAnimation: enableAnimation, + activationAnimation: activationAnimation, + sliderTheme: sliderTheme, + borderColor: borderColor, + disabledBorderColor: disabledBorderColor, + ); + } +} + +class _TDesignRangeSliderThumbShape extends RangeSliderThumbShape { + const _TDesignRangeSliderThumbShape({ + required this.borderColor, + required this.disabledBorderColor, + }); + + final Color borderColor; + final Color disabledBorderColor; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => + const Size.fromRadius(10); + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + bool isDiscrete = false, + bool isEnabled = false, + bool isOnTop = false, + TextDirection textDirection = TextDirection.ltr, + required SliderThemeData sliderTheme, + Thumb thumb = Thumb.start, + bool isPressed = false, + }) { + _paintTDesignThumb( + context.canvas, + center, + enableAnimation: enableAnimation, + activationAnimation: activationAnimation, + sliderTheme: sliderTheme, + borderColor: borderColor, + disabledBorderColor: disabledBorderColor, + ); + } +} + +void _paintTDesignThumb( + Canvas canvas, + Offset center, { + required Animation enableAnimation, + required Animation activationAnimation, + required SliderThemeData sliderTheme, + required Color borderColor, + required Color disabledBorderColor, +}) { + const radius = 10.0; + final fill = Color.lerp( + sliderTheme.disabledThumbColor, + sliderTheme.thumbColor, + enableAnimation.value, + )!; + final border = Color.lerp( + disabledBorderColor, + borderColor, + enableAnimation.value, + )!; + final path = Path()..addOval(Rect.fromCircle(center: center, radius: radius)); + canvas.drawShadow( + path, + Colors.black, + 1 + 2 * activationAnimation.value, + true, + ); + canvas.drawCircle(center, radius, Paint()..color = fill); + canvas.drawCircle( + center, + radius, + Paint() + ..color = border + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); +} + +class _LabeledSliderThumbShape extends SliderComponentShape { + const _LabeledSliderThumbShape({ + required this.base, + required this.label, + required this.disabledLabelColor, + }); + + final SliderComponentShape base; + final String label; + final Color disabledLabelColor; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => + base.getPreferredSize(isEnabled, isDiscrete); + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + required bool isDiscrete, + required TextPainter labelPainter, + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required TextDirection textDirection, + required double value, + required double textScaleFactor, + required Size sizeWithOverflow, + }) { + base.paint( + context, + center, + activationAnimation: activationAnimation, + enableAnimation: enableAnimation, + isDiscrete: isDiscrete, + labelPainter: labelPainter, + parentBox: parentBox, + sliderTheme: sliderTheme, + textDirection: textDirection, + value: value, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + ); + _paintThumbLabel( + context.canvas, + center, + label, + sliderTheme, + textDirection, + enableAnimation: enableAnimation, + disabledColor: disabledLabelColor, + ); + } +} + +class _LabeledRangeSliderThumbShape extends RangeSliderThumbShape { + const _LabeledRangeSliderThumbShape({ + required this.base, + required this.labels, + required this.disabledLabelColor, + }); + + final RangeSliderThumbShape base; + final RangeLabels labels; + final Color disabledLabelColor; + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) => + base.getPreferredSize(isEnabled, isDiscrete); + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation enableAnimation, + bool isDiscrete = false, + bool isEnabled = false, + bool isOnTop = false, + TextDirection textDirection = TextDirection.ltr, + required SliderThemeData sliderTheme, + Thumb thumb = Thumb.start, + bool isPressed = false, + }) { + base.paint( + context, + center, + activationAnimation: activationAnimation, + enableAnimation: enableAnimation, + isDiscrete: isDiscrete, + isEnabled: isEnabled, + isOnTop: isOnTop, + textDirection: textDirection, + sliderTheme: sliderTheme, + thumb: thumb, + isPressed: isPressed, + ); + _paintThumbLabel( + context.canvas, + center, + thumb == Thumb.start ? labels.start : labels.end, + sliderTheme, + textDirection, + enableAnimation: enableAnimation, + disabledColor: disabledLabelColor, + ); + } +} + +void _paintThumbLabel( + Canvas canvas, + Offset center, + String label, + SliderThemeData sliderTheme, + TextDirection textDirection, { + required Animation enableAnimation, + required Color disabledColor, +}) { + final enabledStyle = sliderTheme.valueIndicatorTextStyle!; + final textColor = Color.lerp( + disabledColor, + enabledStyle.color ?? disabledColor, + enableAnimation.value, + ); + final painter = TextPainter( + text: TextSpan( + text: label, + style: enabledStyle.copyWith(color: textColor), + ), + textDirection: textDirection, + maxLines: 1, + )..layout(); + painter.paint( + canvas, + Offset(center.dx - painter.width / 2, center.dy - painter.height - 16), + ); +} + class _SliderWithScaleLabels extends StatelessWidget { const _SliderWithScaleLabels({ required this.min, diff --git a/tdesign-component/test/components/slider/goldens/t_slider_material.png b/tdesign-component/test/components/slider/goldens/t_slider_material.png index 62705eea6..083fc05fc 100644 Binary files a/tdesign-component/test/components/slider/goldens/t_slider_material.png and b/tdesign-component/test/components/slider/goldens/t_slider_material.png differ diff --git a/tdesign-component/test/components/slider/t_slider_test.dart b/tdesign-component/test/components/slider/t_slider_test.dart index fffd52ee2..91fe79563 100644 --- a/tdesign-component/test/components/slider/t_slider_test.dart +++ b/tdesign-component/test/components/slider/t_slider_test.dart @@ -3,6 +3,19 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; void main() { + void expectThumbBorders( + SliderThemeData theme, { + required Color borderColor, + required Color disabledBorderColor, + }) { + final dynamic thumbShape = theme.thumbShape; + final dynamic rangeThumbShape = theme.rangeThumbShape; + expect(thumbShape.borderColor, borderColor); + expect(thumbShape.disabledBorderColor, disabledBorderColor); + expect(rangeThumbShape.borderColor, borderColor); + expect(rangeThumbShape.disabledBorderColor, disabledBorderColor); + } + Widget wrap(Widget child, {TSliderThemeData? sliderTheme}) { return MaterialApp( theme: ThemeData( @@ -12,25 +25,32 @@ void main() { ], sliderTheme: const SliderThemeData(trackHeight: 6), ), - home: Scaffold(body: Center(child: SizedBox(width: 320, child: child))), + home: Scaffold( + body: Center(child: SizedBox(width: 320, child: child)), + ), ); } group('TSlider v1 behavior', () { - testWidgets('forwards controlled value, bounds, divisions and callbacks', - (tester) async { + testWidgets('forwards controlled value, bounds, divisions and callbacks', ( + tester, + ) async { double? changed; double? started; double? ended; - await tester.pumpWidget(wrap(TSlider( - value: 40, - min: 0, - max: 100, - divisions: 10, - onChanged: (value) => changed = value, - onChangeStart: (value) => started = value, - onChangeEnd: (value) => ended = value, - ))); + await tester.pumpWidget( + wrap( + TSlider( + value: 40, + min: 0, + max: 100, + divisions: 10, + onChanged: (value) => changed = value, + onChangeStart: (value) => started = value, + onChangeEnd: (value) => ended = value, + ), + ), + ); final slider = tester.widget(find.byType(Slider)); expect(slider.value, 40); @@ -38,7 +58,9 @@ void main() { expect(slider.max, 100); expect(slider.divisions, 10); expect( - SliderTheme.of(tester.element(find.byType(Slider))).trackHeight, 6); + SliderTheme.of(tester.element(find.byType(Slider))).trackHeight, + 6, + ); await tester.drag(find.byType(Slider), const Offset(80, 0)); await tester.pumpAndSettle(); @@ -52,100 +74,191 @@ void main() { expect(tester.widget(find.byType(Slider)).onChanged, isNull); }); - testWidgets('uses TDesign token colors when SliderTheme is unspecified', - (tester) async { + testWidgets('uses TDesign token colors when SliderTheme is unspecified', ( + tester, + ) async { await tester.pumpWidget(wrap(const TSlider(value: 0.5))); final theme = SliderTheme.of(tester.element(find.byType(Slider))); expect(theme.activeTrackColor, TThemeData.defaultData().brandNormalColor); - expect(theme.thumbColor, TThemeData.defaultData().brandNormalColor); + expect( + theme.inactiveTrackColor, + TThemeData.defaultData().bgColorComponentHover, + ); + expect(theme.thumbColor, TThemeData.defaultData().textColorAnti); + expect( + theme.disabledActiveTrackColor, + TThemeData.defaultData().brandDisabledColor, + ); + expect( + theme.disabledInactiveTrackColor, + TThemeData.defaultData().bgColorComponentDisabled, + ); + expectThumbBorders( + theme, + borderColor: TThemeData.defaultData().grayColor1, + disabledBorderColor: + TThemeData.defaultData().bgColorComponentDisabled, + ); + }); + + testWidgets('uses dark TDesign token colors without Material pollution', ( + tester, + ) async { + final token = TThemeData.defaultData(); + final darkToken = token.dark ?? token; + await tester.pumpWidget( + MaterialApp( + theme: TThemeBuilder.dark(token), + home: const Scaffold(body: TSlider(value: 0.5)), + ), + ); + + final theme = SliderTheme.of(tester.element(find.byType(Slider))); + expect(theme.activeTrackColor, darkToken.brandNormalColor); + expect(theme.inactiveTrackColor, darkToken.bgColorComponentHover); + expect(theme.thumbColor, darkToken.textColorAnti); + expectThumbBorders( + theme, + borderColor: darkToken.grayColor1, + disabledBorderColor: darkToken.bgColorComponentDisabled, + ); }); - testWidgets('preserves local SliderTheme color overrides', (tester) async { + testWidgets('preserves local SliderTheme color and label overrides', ( + tester, + ) async { await tester.pumpWidget( MaterialApp( theme: ThemeData( extensions: [TThemeData.defaultData()], sliderTheme: const SliderThemeData( activeTrackColor: Colors.red, + valueIndicatorTextStyle: TextStyle(color: Colors.teal), ), ), home: const Scaffold( - body: - Center(child: SizedBox(width: 320, child: TSlider(value: 0.5))), + body: Center( + child: SizedBox( + width: 320, + child: TSlider(value: 0.5, showThumbValue: true), + ), + ), ), ), ); final theme = SliderTheme.of(tester.element(find.byType(Slider))); expect(theme.activeTrackColor, Colors.red); - expect(theme.thumbColor, TThemeData.defaultData().brandNormalColor); + expect(theme.thumbColor, TThemeData.defaultData().textColorAnti); + expect(theme.valueIndicatorTextStyle?.color, Colors.teal); + }); + + testWidgets('explicit ColorScheme takes priority over TDesign tokens', ( + tester, + ) async { + const scheme = ColorScheme.light( + primary: Colors.purple, + onSurface: Colors.orange, + surfaceContainerHighest: Colors.green, + outline: Colors.brown, + ); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + colorScheme: scheme, + extensions: [TThemeData.defaultData()], + ), + home: const Scaffold(body: TSlider(value: 0.5)), + ), + ); + + final theme = SliderTheme.of(tester.element(find.byType(Slider))); + expect(theme.activeTrackColor, scheme.primary); + expect(theme.inactiveTrackColor, scheme.surfaceContainerHighest); + expect(theme.thumbColor, scheme.primary); + expectThumbBorders( + theme, + borderColor: scheme.outline, + disabledBorderColor: scheme.outlineVariant, + ); }); testWidgets('Theme decoration wraps the slider', (tester) async { - await tester.pumpWidget(wrap( - const TSlider(value: 0.5), - sliderTheme: const TSliderThemeData( - decoration: BoxDecoration(color: Colors.red), + await tester.pumpWidget( + wrap( + const TSlider(value: 0.5), + sliderTheme: const TSliderThemeData( + decoration: BoxDecoration(color: Colors.red), + ), ), - )); + ); expect(find.byType(DecoratedBox), findsOneWidget); }); - testWidgets('showThumbValue forwards formatted label and value indicator', - (tester) async { - await tester.pumpWidget(wrap(TSlider( - value: 40, - min: 0, - max: 100, - showThumbValue: true, - thumbFormatter: (value) => '${value.toInt()}%', - ))); + testWidgets('showThumbValue keeps formatted label visible at rest', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + TSlider( + value: 40, + min: 0, + max: 100, + showThumbValue: true, + thumbFormatter: (value) => '${value.toInt()}%', + ), + ), + ); final slider = tester.widget(find.byType(Slider)); expect(slider.label, '40%'); expect( SliderTheme.of(tester.element(find.byType(Slider))).showValueIndicator, - ShowValueIndicator.onlyForContinuous, + ShowValueIndicator.never, ); }); - testWidgets('discrete slider uses the discrete value indicator', - (tester) async { - await tester.pumpWidget(wrap(const TSlider( - value: 0.4, - divisions: 5, - showThumbValue: true, - ))); + testWidgets('discrete slider keeps the Material value indicator hidden', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const TSlider(value: 0.4, divisions: 5, showThumbValue: true)), + ); expect( SliderTheme.of(tester.element(find.byType(Slider))).showValueIndicator, - ShowValueIndicator.onlyForDiscrete, + ShowValueIndicator.never, ); }); - testWidgets('showScaleValue renders formatted scale labels', - (tester) async { - await tester.pumpWidget(wrap(TSlider( - value: 40, - min: 0, - max: 100, - divisions: 4, - showScaleValue: true, - scaleFormatter: (value) => '${value.toInt()}%', - ))); + testWidgets('showScaleValue renders formatted scale labels', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + TSlider( + value: 40, + min: 0, + max: 100, + divisions: 4, + showScaleValue: true, + scaleFormatter: (value) => '${value.toInt()}%', + ), + ), + ); expect(find.text('0%'), findsOneWidget); expect(find.text('50%'), findsOneWidget); expect(find.text('100%'), findsOneWidget); }); - testWidgets('showThumbValue defaults to two decimal places', - (tester) async { - await tester.pumpWidget(wrap(const TSlider( - value: 0.4, - showThumbValue: true, - ))); + testWidgets('showThumbValue defaults to two decimal places', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const TSlider(value: 0.4, showThumbValue: true)), + ); expect(tester.widget(find.byType(Slider)).label, '0.40'); }); @@ -162,20 +275,25 @@ void main() { }); group('TRangeSlider v1 behavior', () { - testWidgets('forwards controlled range and lifecycle callbacks', - (tester) async { + testWidgets('forwards controlled range and lifecycle callbacks', ( + tester, + ) async { RangeValues? changed; RangeValues? started; RangeValues? ended; - await tester.pumpWidget(wrap(TRangeSlider( - value: const RangeValues(20, 60), - min: 0, - max: 100, - divisions: 10, - onChanged: (value) => changed = value, - onChangeStart: (value) => started = value, - onChangeEnd: (value) => ended = value, - ))); + await tester.pumpWidget( + wrap( + TRangeSlider( + value: const RangeValues(20, 60), + min: 0, + max: 100, + divisions: 10, + onChanged: (value) => changed = value, + onChangeStart: (value) => started = value, + onChangeEnd: (value) => ended = value, + ), + ), + ); final slider = tester.widget(find.byType(RangeSlider)); expect(slider.values, const RangeValues(20, 60)); @@ -192,14 +310,17 @@ void main() { expect(ended, isNotNull); }); - testWidgets('onChanged null disables and decoration wraps range slider', - (tester) async { - await tester.pumpWidget(wrap( - const TRangeSlider(value: RangeValues(0.2, 0.8)), - sliderTheme: const TSliderThemeData( - decoration: BoxDecoration(color: Colors.blue), + testWidgets('onChanged null disables and decoration wraps range slider', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + const TRangeSlider(value: RangeValues(0.2, 0.8)), + sliderTheme: const TSliderThemeData( + decoration: BoxDecoration(color: Colors.blue), + ), ), - )); + ); expect( tester.widget(find.byType(RangeSlider)).onChanged, isNull, @@ -207,62 +328,84 @@ void main() { expect(find.byType(DecoratedBox), findsOneWidget); }); - testWidgets('showThumbValue forwards formatted range labels', - (tester) async { - await tester.pumpWidget(wrap(TRangeSlider( - value: const RangeValues(20, 60), - min: 0, - max: 100, - showThumbValue: true, - thumbFormatter: (value) => '${value.toInt()}%', - ))); + testWidgets('showThumbValue keeps formatted range labels visible at rest', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + TRangeSlider( + value: const RangeValues(20, 60), + min: 0, + max: 100, + showThumbValue: true, + thumbFormatter: (value) => '${value.toInt()}%', + ), + ), + ); final slider = tester.widget(find.byType(RangeSlider)); expect(slider.labels, const RangeLabels('20%', '60%')); expect( - SliderTheme.of(tester.element(find.byType(RangeSlider))) - .showValueIndicator, - ShowValueIndicator.onlyForContinuous, + SliderTheme.of( + tester.element(find.byType(RangeSlider)), + ).showValueIndicator, + ShowValueIndicator.never, ); }); - testWidgets('discrete range slider uses the discrete value indicator', - (tester) async { - await tester.pumpWidget(wrap(const TRangeSlider( - value: RangeValues(0.2, 0.6), - divisions: 5, - showThumbValue: true, - ))); + testWidgets('discrete range keeps the Material value indicator hidden', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + const TRangeSlider( + value: RangeValues(0.2, 0.6), + divisions: 5, + showThumbValue: true, + ), + ), + ); expect( - SliderTheme.of(tester.element(find.byType(RangeSlider))) - .showValueIndicator, - ShowValueIndicator.onlyForDiscrete, + SliderTheme.of( + tester.element(find.byType(RangeSlider)), + ).showValueIndicator, + ShowValueIndicator.never, ); }); - testWidgets('showScaleValue renders formatted range scale labels', - (tester) async { - await tester.pumpWidget(wrap(TRangeSlider( - value: const RangeValues(20, 60), - min: 0, - max: 100, - divisions: 4, - showScaleValue: true, - scaleFormatter: (value) => '${value.toInt()}%', - ))); + testWidgets('showScaleValue renders formatted range scale labels', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + TRangeSlider( + value: const RangeValues(20, 60), + min: 0, + max: 100, + divisions: 4, + showScaleValue: true, + scaleFormatter: (value) => '${value.toInt()}%', + ), + ), + ); expect(find.text('0%'), findsOneWidget); expect(find.text('50%'), findsOneWidget); expect(find.text('100%'), findsOneWidget); }); - testWidgets('range showThumbValue defaults to two decimal places', - (tester) async { - await tester.pumpWidget(wrap(const TRangeSlider( - value: RangeValues(0.2, 0.6), - showThumbValue: true, - ))); + testWidgets('range showThumbValue defaults to two decimal places', ( + tester, + ) async { + await tester.pumpWidget( + wrap( + const TRangeSlider( + value: RangeValues(0.2, 0.6), + showThumbValue: true, + ), + ), + ); expect( tester.widget(find.byType(RangeSlider)).labels, @@ -272,36 +415,34 @@ void main() { test('rejects invalid bounds and divisions', () { expect( - () => TRangeSlider( - value: const RangeValues(0.2, 0.8), - min: 1, - max: 1, - ), + () => TRangeSlider(value: const RangeValues(0.2, 0.8), min: 1, max: 1), throwsAssertionError, ); expect( - () => TRangeSlider( - value: const RangeValues(0.2, 0.8), - divisions: 0, - ), + () => TRangeSlider(value: const RangeValues(0.2, 0.8), divisions: 0), throwsAssertionError, ); }); + + testWidgets('rejects a controlled range outside min and max', ( + tester, + ) async { + await tester.pumpWidget( + wrap(const TRangeSlider(value: RangeValues(-0.1, 0.8))), + ); + expect(tester.takeException(), isAssertionError); + }); }); test('TSliderThemeData copyWith and lerp', () { - const base = TSliderThemeData( - decoration: BoxDecoration(color: Colors.red), - ); + const base = TSliderThemeData(decoration: BoxDecoration(color: Colors.red)); const other = TSliderThemeData( decoration: BoxDecoration(color: Colors.blue), ); expect(base.copyWith().decoration, base.decoration); expect( base - .copyWith( - decoration: const BoxDecoration(color: Colors.green), - ) + .copyWith(decoration: const BoxDecoration(color: Colors.green)) .decoration, const BoxDecoration(color: Colors.green), ); diff --git a/tdesign-component/tool/component_test_manifest.dart b/tdesign-component/tool/component_test_manifest.dart index 6ccd803e0..2d6494246 100644 --- a/tdesign-component/tool/component_test_manifest.dart +++ b/tdesign-component/tool/component_test_manifest.dart @@ -593,6 +593,24 @@ const componentTestManifests = [ ), ], ), + ComponentTestManifest( + name: 'slider', + coverageTargets: ['lib/src/components/slider/'], + componentTests: ['test/components/slider/t_slider_test.dart'], + exampleTests: ['test/slider_demo_test.dart'], + visualTests: [ + VisualTestManifest( + name: 'Slider Component', + workingDirectory: '.', + testFiles: ['test/components/slider/t_slider_golden_test.dart'], + ), + VisualTestManifest( + name: 'Slider Demo', + workingDirectory: 'example', + testFiles: ['test/slider_demo_golden_test.dart'], + ), + ], + ), ComponentTestManifest( name: 'switch', coverageTargets: ['lib/src/components/switch/'],