Commit b73c5efda4e94c39b732b39cabb28da43c2c9746

Authored by 常守达
1 parent 6dc9dd6c

feat(hw): 鸿蒙版睡眠健身圆环

@@ -52,6 +52,27 @@ class ArcMultiProgressWidget extends StatefulWidget { @@ -52,6 +52,27 @@ class ArcMultiProgressWidget extends StatefulWidget {
52 /// 动画曲线 52 /// 动画曲线
53 final Curve curve; 53 final Curve curve;
54 54
  55 + /// 超过 1 圈时端头阴影颜色 (默认半透明黑)
  56 + final Color shadowColor;
  57 +
  58 + /// 阴影模糊半径 (越大越发散,越小越清晰,默认 2.2)
  59 + final double shadowBlur;
  60 +
  61 + /// 阴影顺时针偏移距离 (像素,默认 0.8)
  62 + final double shadowOffset;
  63 +
  64 + /// 合环三角形宽度占 size 的比例 (默认 15.27 / 50 ≈ 0.3054)
  65 + final double triangleWidthRatio;
  66 +
  67 + /// 合环三角形高度占 size 的比例 (默认 8.27 / 50 ≈ 0.1654)
  68 + final double triangleHeightRatio;
  69 +
  70 + /// 合环三角形向下溢出底部的比例 (占 size 的比例,默认 0.0)
  71 + final double triangleBottomOverflowRatio;
  72 +
  73 + /// 合环三角形圆角半径占 size 的比例 (默认 1.4 / 50 ≈ 0.028)
  74 + final double triangleCornerRadiusRatio;
  75 +
55 const ArcMultiProgressWidget({ 76 const ArcMultiProgressWidget({
56 super.key, 77 super.key,
57 required this.items, 78 required this.items,
@@ -63,6 +84,13 @@ class ArcMultiProgressWidget extends StatefulWidget { @@ -63,6 +84,13 @@ class ArcMultiProgressWidget extends StatefulWidget {
63 this.animate = true, 84 this.animate = true,
64 this.duration = const Duration(milliseconds: 1000), 85 this.duration = const Duration(milliseconds: 1000),
65 this.curve = Curves.easeOutCubic, 86 this.curve = Curves.easeOutCubic,
  87 + this.shadowColor = const Color.fromARGB(173, 0, 0, 0), // alpha ~ 0.35
  88 + this.shadowBlur = 2.2,
  89 + this.shadowOffset = 0.8,
  90 + this.triangleWidthRatio = 0.42,
  91 + this.triangleHeightRatio = 0.22,
  92 + this.triangleBottomOverflowRatio = 0.04,
  93 + this.triangleCornerRadiusRatio = 0.02,
66 }); 94 });
67 95
68 @override 96 @override
@@ -81,8 +109,7 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget> @@ -81,8 +109,7 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget>
81 if (item.ratio == null || item.ratio! <= 0) { 109 if (item.ratio == null || item.ratio! <= 0) {
82 return 0.0; 110 return 0.0;
83 } 111 }
84 - final clampedRatio = min(item.ratio!, 1.0);  
85 - return clampedRatio * totalRad; 112 + return item.ratio! * totalRad;
86 }).toList(); 113 }).toList();
87 } 114 }
88 115
@@ -118,7 +145,6 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget> @@ -118,7 +145,6 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget>
118 } 145 }
119 146
120 final startList = List<double>.from(fromAngles); 147 final startList = List<double>.from(fromAngles);
121 - // 确保长度一致  
122 while (startList.length < toAngles.length) { 148 while (startList.length < toAngles.length) {
123 startList.add(0.0); 149 startList.add(0.0);
124 } 150 }
@@ -167,6 +193,13 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget> @@ -167,6 +193,13 @@ class _ArcMultiProgressWidgetState extends State<ArcMultiProgressWidget>
167 gap: gap, 193 gap: gap,
168 startAngleRad: startAngleRad, 194 startAngleRad: startAngleRad,
169 totalSweepRad: totalSweepRad, 195 totalSweepRad: totalSweepRad,
  196 + shadowColor: widget.shadowColor,
  197 + shadowBlur: widget.shadowBlur,
  198 + shadowOffset: widget.shadowOffset,
  199 + triangleWidthRatio: widget.triangleWidthRatio,
  200 + triangleHeightRatio: widget.triangleHeightRatio,
  201 + triangleBottomOverflowRatio: widget.triangleBottomOverflowRatio,
  202 + triangleCornerRadiusRatio: widget.triangleCornerRadiusRatio,
170 ), 203 ),
171 ), 204 ),
172 ); 205 );
@@ -180,6 +213,13 @@ class _ArcMultiProgressPainter extends CustomPainter { @@ -180,6 +213,13 @@ class _ArcMultiProgressPainter extends CustomPainter {
180 final double gap; 213 final double gap;
181 final double startAngleRad; 214 final double startAngleRad;
182 final double totalSweepRad; 215 final double totalSweepRad;
  216 + final Color shadowColor;
  217 + final double shadowBlur;
  218 + final double shadowOffset;
  219 + final double triangleWidthRatio;
  220 + final double triangleHeightRatio;
  221 + final double triangleBottomOverflowRatio;
  222 + final double triangleCornerRadiusRatio;
183 223
184 _ArcMultiProgressPainter({ 224 _ArcMultiProgressPainter({
185 required this.animatedProgresses, 225 required this.animatedProgresses,
@@ -188,6 +228,13 @@ class _ArcMultiProgressPainter extends CustomPainter { @@ -188,6 +228,13 @@ class _ArcMultiProgressPainter extends CustomPainter {
188 required this.gap, 228 required this.gap,
189 required this.startAngleRad, 229 required this.startAngleRad,
190 required this.totalSweepRad, 230 required this.totalSweepRad,
  231 + required this.shadowColor,
  232 + required this.shadowBlur,
  233 + required this.shadowOffset,
  234 + required this.triangleWidthRatio,
  235 + required this.triangleHeightRatio,
  236 + required this.triangleBottomOverflowRatio,
  237 + required this.triangleCornerRadiusRatio,
191 }); 238 });
192 239
193 @override 240 @override
@@ -202,7 +249,7 @@ class _ArcMultiProgressPainter extends CustomPainter { @@ -202,7 +249,7 @@ class _ArcMultiProgressPainter extends CustomPainter {
202 249
203 final rect = Rect.fromCircle(center: center, radius: radius); 250 final rect = Rect.fromCircle(center: center, radius: radius);
204 251
205 - // 1. 绘制底轨 252 + // 1. 绘制底轨 (Track)
206 final hasData = item.ratio != null && item.ratio! > 0; 253 final hasData = item.ratio != null && item.ratio! > 0;
207 final bgColor = (!hasData && item.noDataBackgroundColor != null) 254 final bgColor = (!hasData && item.noDataBackgroundColor != null)
208 ? item.noDataBackgroundColor! 255 ? item.noDataBackgroundColor!
@@ -216,19 +263,170 @@ class _ArcMultiProgressPainter extends CustomPainter { @@ -216,19 +263,170 @@ class _ArcMultiProgressPainter extends CustomPainter {
216 263
217 canvas.drawArc(rect, startAngleRad, totalSweepRad, false, trackPaint); 264 canvas.drawArc(rect, startAngleRad, totalSweepRad, false, trackPaint);
218 265
219 - // 2. 绘制进度  
220 - final progressSweep =  
221 - i < animatedProgresses.length ? animatedProgresses[i] : 0.0;  
222 - if (progressSweep > 0.001) {  
223 - final progressPaint = Paint()  
224 - ..color = item.color  
225 - ..strokeWidth = strokeWidth  
226 - ..style = PaintingStyle.stroke  
227 - ..strokeCap = StrokeCap.round;  
228 -  
229 - canvas.drawArc(rect, startAngleRad, progressSweep, false, progressPaint); 266 + // 2. 绘制进度 (Progress)
  267 + final sweep = i < animatedProgresses.length ? animatedProgresses[i] : 0.0;
  268 + if (sweep <= 0.001) continue;
  269 +
  270 + final fullLaps = (sweep / totalSweepRad).floor();
  271 + final remainingSweep = sweep - fullLaps * totalSweepRad;
  272 +
  273 + final progressPaint = Paint()
  274 + ..color = item.color
  275 + ..strokeWidth = strokeWidth
  276 + ..style = PaintingStyle.stroke
  277 + ..strokeCap = StrokeCap.round;
  278 +
  279 + // ① 如果至少满一圈,先绘制完整的底层圆弧
  280 + if (fullLaps >= 1) {
  281 + canvas.drawArc(
  282 + rect, startAngleRad, totalSweepRad, false, progressPaint);
  283 + }
  284 +
  285 + // ② 绘制超出的部分(或第一圈未满的部分)
  286 + if (fullLaps >= 1) {
  287 + if (remainingSweep > 0.01) {
  288 + final currentEndAngle = startAngleRad + remainingSweep;
  289 +
  290 + // 沿切线前进方向微小偏移
  291 + final tangentOffsetX = -shadowOffset * sin(currentEndAngle);
  292 + final tangentOffsetY = shadowOffset * cos(currentEndAngle);
  293 +
  294 + final shadowCenter = Offset(
  295 + center.dx + radius * cos(currentEndAngle) + tangentOffsetX,
  296 + center.dy + radius * sin(currentEndAngle) + tangentOffsetY,
  297 + );
  298 +
  299 + final capCenter = Offset(
  300 + center.dx + radius * cos(currentEndAngle),
  301 + center.dy + radius * sin(currentEndAngle),
  302 + );
  303 +
  304 + // 构造当前圆环轨道的裁剪区域,严格限制阴影只在环槽内显示,绝不溢出到外圈或内圈
  305 + final trackClipPath = Path()
  306 + ..addOval(Rect.fromCircle(
  307 + center: center, radius: radius + strokeWidth / 2))
  308 + ..addOval(Rect.fromCircle(
  309 + center: center, radius: radius - strokeWidth / 2))
  310 + ..fillType = PathFillType.evenOdd;
  311 +
  312 + // 绘制被精准裁剪的自然阴影
  313 + canvas.save();
  314 + canvas.clipPath(trackClipPath);
  315 + final shadowPaint = Paint()
  316 + ..color = shadowColor
  317 + ..style = PaintingStyle.fill
  318 + ..maskFilter = MaskFilter.blur(BlurStyle.normal, shadowBlur);
  319 + canvas.drawCircle(shadowCenter, strokeWidth / 2, shadowPaint);
  320 + canvas.restore();
  321 +
  322 + // 绘制第二层圆弧(带圆角端头,压在阴影上)
  323 + canvas.drawArc(
  324 + rect, startAngleRad, remainingSweep, false, progressPaint);
  325 +
  326 + // 显式绘制顶层端帽实心圆
  327 + final capFillPaint = Paint()
  328 + ..color = item.color
  329 + ..style = PaintingStyle.fill;
  330 + canvas.drawCircle(capCenter, strokeWidth / 2, capFillPaint);
  331 + }
  332 + } else {
  333 + // 第一圈未满,直接绘制
  334 + canvas.drawArc(rect, startAngleRad, sweep, false, progressPaint);
230 } 335 }
231 } 336 }
  337 +
  338 + // 3. 当所有环都合环(>= 100%)时,在底部中央开口处绘制圆润的合环指示三角形
  339 + final isAllCompleted = items.isNotEmpty &&
  340 + List.generate(items.length, (idx) {
  341 + final sw =
  342 + idx < animatedProgresses.length ? animatedProgresses[idx] : 0.0;
  343 + return sw >= totalSweepRad - 0.01;
  344 + }).every((completed) => completed);
  345 +
  346 + if (isAllCompleted && count > 0) {
  347 + // 按照 size 的比例计算宽度、高度、溢出与圆角
  348 + final triangleWidth = size.width * triangleWidthRatio;
  349 + final triangleHeight = size.height * triangleHeightRatio;
  350 + final halfWidth = triangleWidth / 2;
  351 +
  352 + final outerRadius = (size.width - strokeWidth) / 2;
  353 + // 以最外环端点下边缘(合环底部最低点)为 0 基准
  354 + final ringBottomY =
  355 + center.dy + outerRadius * sin(startAngleRad) + strokeWidth / 2;
  356 +
  357 + final overflow = size.height * triangleBottomOverflowRatio;
  358 + final bottomY = ringBottomY + overflow;
  359 + final topY = bottomY - triangleHeight;
  360 +
  361 + final topPoint = Offset(center.dx, topY);
  362 + final rightPoint = Offset(center.dx + halfWidth, bottomY);
  363 + final leftPoint = Offset(center.dx - halfWidth, bottomY);
  364 +
  365 + final cornerRadius =
  366 + size.width * (triangleCornerRadiusRatio ?? (1.4 / 50.0));
  367 +
  368 + final roundedTrianglePath = _createRoundedTrianglePath(
  369 + top: topPoint,
  370 + right: rightPoint,
  371 + left: leftPoint,
  372 + cornerRadius: cornerRadius,
  373 + );
  374 +
  375 + final trianglePaint = Paint()
  376 + ..color = items.first.color
  377 + ..style = PaintingStyle.fill;
  378 +
  379 + canvas.drawPath(roundedTrianglePath, trianglePaint);
  380 + }
  381 + }
  382 +
  383 + /// 绘制平滑圆角三角形
  384 + Path _createRoundedTrianglePath({
  385 + required Offset top,
  386 + required Offset right,
  387 + required Offset left,
  388 + required double cornerRadius,
  389 + }) {
  390 + final path = Path();
  391 + final points = [top, right, left];
  392 + final n = points.length;
  393 +
  394 + for (int i = 0; i < n; i++) {
  395 + final p0 = points[(i - 1 + n) % n];
  396 + final p1 = points[i];
  397 + final p2 = points[(i + 1) % n];
  398 +
  399 + final v1 = Offset(p0.dx - p1.dx, p0.dy - p1.dy);
  400 + final v2 = Offset(p2.dx - p1.dx, p2.dy - p1.dy);
  401 +
  402 + final d1 = sqrt(v1.dx * v1.dx + v1.dy * v1.dy);
  403 + final d2 = sqrt(v2.dx * v2.dx + v2.dy * v2.dy);
  404 +
  405 + final u1 = Offset(v1.dx / d1, v1.dy / d1);
  406 + final u2 = Offset(v2.dx / d2, v2.dy / d2);
  407 +
  408 + final dot = (u1.dx * u2.dx + u1.dy * u2.dy).clamp(-1.0, 1.0);
  409 + final angle = acos(dot);
  410 + final cutDist = cornerRadius / tan(angle / 2);
  411 +
  412 + final cut1 = Offset(p1.dx + u1.dx * cutDist, p1.dy + u1.dy * cutDist);
  413 + final cut2 = Offset(p1.dx + u2.dx * cutDist, p1.dy + u2.dy * cutDist);
  414 +
  415 + if (i == 0) {
  416 + path.moveTo(cut1.dx, cut1.dy);
  417 + } else {
  418 + path.lineTo(cut1.dx, cut1.dy);
  419 + }
  420 +
  421 + path.arcToPoint(
  422 + cut2,
  423 + radius: Radius.circular(cornerRadius),
  424 + clockwise: true,
  425 + );
  426 + }
  427 +
  428 + path.close();
  429 + return path;
232 } 430 }
233 431
234 @override 432 @override