stress_progress_bar.dart
5.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
import 'package:flutter/material.dart';
/// 压力状态进度条(四段式)
///
/// 对应 Figma node 1361-44236(轨道)和 1361-44489(indicator)。
///
/// 传入 [score](0~100 压力分):
/// - null → 加载态:四段等宽 20px,无 indicator
/// - 0~20 → 优秀·绿段(status 3)
/// - 21~60 → 正常·蓝段(status 2)
/// - 61~80 → 注意压力·橙段(status 1)
/// - 81~100 → 压力过载·红段(status 0)
///
/// indicator 在命中段内的横向位置由分数在该区间的位置决定:
/// 分数越低(越优秀)→ 指针越靠右;分数越高(越差)→ 越靠左。
///
/// 段间距 4px,轨道高 14px,indicator 24×24px(6px stroke)。
class StressProgressBar extends StatelessWidget {
const StressProgressBar({super.key, required this.score})
: assert(score == null || (score >= 0 && score <= 100));
/// null = 加载中;0~100 = 压力分
final int? score;
// ── Figma 图表色/1-4(index 0~3 对应 过载→活力)────────
static const _colors = [
Color(0xFFFF5279), // 0 压力过载·红
Color(0xFFFF9A6E), // 1 注意压力·橙
Color(0xFF7B9BFB), // 2 状态正常·蓝
Color(0xFF3BD49D), // 3 活力满满·绿
];
// ── 尺寸常量(来自 Figma)────────────────────────────
static const _defaultW = 20.0;
static const _activeW = 148.0;
static const _trackH = 14.0;
static const _gap = 4.0;
static const _dotSize = 24.0;
static const _dotBorder = 6.0;
// ── 动画参数 ──────────────────────────────────────────
static const _dur = Duration(milliseconds: 400);
static const _curve = Curves.easeInOut;
// ── 分数区间边界(左闭右闭)───────────────────────────
// status 3 (绿): 0~20 status 2 (蓝): 21~60
// status 1 (橙): 61~80 status 0 (红): 81~100
static const _ranges = [
(lo: 81, hi: 100), // status 0
(lo: 61, hi: 80), // status 1
(lo: 21, hi: 60), // status 2
(lo: 0, hi: 20), // status 3
];
/// 由压力分计算命中段(0=过载, 1=注意, 2=正常, 3=活力)
static int _statusFromScore(int s) {
if (s <= 20) return 3;
if (s <= 60) return 2;
if (s <= 80) return 1;
return 0;
}
/// 在命中段内的归一化位置(0.0=最左/最差, 1.0=最右/最优)
///
/// 分数越低越优秀 → 越靠右,所以 progress = (hi - score) / (hi - lo)
static double _segProgress(int s) {
final st = _statusFromScore(s);
final r = _ranges[st];
if (r.hi == r.lo) return 0.5;
return ((r.hi - s) / (r.hi - r.lo)).clamp(0.0, 1.0);
}
// ── 布局计算 ──────────────────────────────────────────
int? get _status => score != null ? _statusFromScore(score!) : null;
/// 第 [i] 段的当前宽度
double _w(int i) => (_status == i) ? _activeW : _defaultW;
/// 总宽度(含三个间距)
double get _totalW => _w(0) + _w(1) + _w(2) + _w(3) + 3 * _gap;
/// indicator 左边缘 X(在命中段内按 segProgress 定位)
double _dotLeft(int s) {
final prog = score != null ? _segProgress(score!) : 0.5;
double x = 0;
for (int i = 0; i < s; i++) {
x += _w(i) + _gap;
}
// 在命中段宽内按 progress 定位,并保证圆点不超出段的边缘
x += (_w(s) * prog).clamp(_dotSize / 2, _w(s) - _dotSize / 2);
return x - _dotSize / 2;
}
int _indexOfStatus(int score) =>
_ranges.indexWhere((r) => score >= r.lo && score <= r.hi);
@override
Widget build(BuildContext context) {
final s = _status;
final scoreIndex = _indexOfStatus(score ?? -1);
return AnimatedContainer(
duration: _dur,
curve: _curve,
width: _totalW,
height: _dotSize,
child: Stack(
clipBehavior: Clip.none,
children: [
// ── 轨道:四段色块 ─────────────────────────────
Positioned(
top: (_dotSize - _trackH) / 2,
left: 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < 4; i++) ...[
Opacity(
opacity: scoreIndex == i ? 1.0 : 0.5,
child:
AnimatedContainer(
duration: _dur,
curve: _curve,
width: _w(i),
height: _trackH,
decoration: BoxDecoration(
color: _colors[i],
borderRadius: BorderRadius.circular(_trackH / 2),
),
),
),
if (i < 3) const SizedBox(width: _gap),
],
],
),
),
// ── Indicator:白底 + 状态色描边 ────────────────
AnimatedPositioned(
duration: _dur,
curve: _curve,
top: 0,
left: s != null ? _dotLeft(s) : 0,
child: AnimatedOpacity(
opacity: s != null ? 1.0 : 0.0,
duration: _dur,
curve: _curve,
child: Container(
width: _dotSize,
height: _dotSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(
color: _colors[(s ?? 0).clamp(0, 3)],
width: _dotBorder,
),
),
),
),
),
],
),
);
}
}