percent_widget.dart 4.67 KB
import 'package:flutter/material.dart';

import 'paint.dart';

/// 环形百分比进度 Widget(专为单环百分比展示设计,保持平滑动画)
class CircularPercentProgressWidget extends StatefulWidget {
  /// 尺寸宽高
  final double size;

  /// 圆环宽度
  final double strokeWidth;

  /// 进度百分比 (0.0 ~ 1.0+,例如 0.85 表示 85%,null 或 < 0 表示无数据)
  final double? percent;

  /// 渐变色或纯色列表
  final List<Color> gradientColors;

  /// 底环背景颜色
  final Color backgroundColor;

  /// 是否开启动画
  final bool animate;

  /// 动画时长
  final Duration duration;

  /// 动画曲线
  final Curve curve;

  /// 是否在中心显示文字
  final bool showText;

  /// 自定义中心文字样式
  final TextStyle? textStyle;

  /// 无数据时显示的文字,默认为 '-'
  final String noDataText;

  const CircularPercentProgressWidget({
    super.key,
    required this.gradientColors,
    this.percent,
    this.size = 50,
    this.strokeWidth = 4.5,
    this.backgroundColor = const Color(0xFFF3F3F3),
    this.animate = true,
    this.duration = const Duration(milliseconds: 1000),
    this.curve = Curves.easeOutCubic,
    this.showText = true,
    this.textStyle,
    this.noDataText = '-',
  });

  @override
  State<CircularPercentProgressWidget> createState() =>
      _CircularPercentProgressWidgetState();
}

class _CircularPercentProgressWidgetState
    extends State<CircularPercentProgressWidget>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _animation;
  double _currentSweepAngle = 0;

  double get _targetSweepAngle {
    if (widget.percent == null || widget.percent! < 0) {
      return -1;
    }
    return widget.percent! * 360;
  }

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: widget.duration,
    );

    final targetAngle = _targetSweepAngle;
    _updateAnimation(0, targetAngle);
  }

  @override
  void didUpdateWidget(CircularPercentProgressWidget oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.percent != oldWidget.percent) {
      _updateAnimation(_currentSweepAngle, _targetSweepAngle);
    }
  }

  void _updateAnimation(double fromAngle, double toAngle) {
    if (toAngle < 0) {
      setState(() {
        _currentSweepAngle = -1;
      });
      return;
    }

    final begin = fromAngle < 0 ? 0.0 : fromAngle;
    if (!widget.animate) {
      setState(() {
        _currentSweepAngle = toAngle;
      });
      return;
    }

    _controller.duration = widget.duration;
    _animation = Tween<double>(begin: begin, end: toAngle).animate(
      CurvedAnimation(parent: _controller, curve: widget.curve),
    )..addListener(() {
        setState(() {
          _currentSweepAngle = _animation.value;
        });
      });

    _controller.forward(from: 0);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final colors = widget.gradientColors.length == 1
        ? [widget.gradientColors.first, widget.gradientColors.first]
        : widget.gradientColors;

    final hasData = widget.percent != null && widget.percent! >= 0;

    String displayText;
    if (!hasData) {
      displayText = widget.noDataText;
    } else {
      final currentPercentInt = ((_currentSweepAngle / 360) * 100).round();
      displayText = '$currentPercentInt%';
    }

    final defaultTextStyle = TextStyle(
      fontSize: 10,
      fontWeight: FontWeight.w600,
      color: const Color(0xFF0F0F11),
      letterSpacing: -0.5,
    );

    return SizedBox(
      width: widget.size,
      height: widget.size,
      child: Stack(
        alignment: Alignment.center,
        children: [
          CustomPaint(
            painter: ProgressPainter(
              sweepAngle: _currentSweepAngle,
              progressColor: [colors],
              strokeWidth: widget.strokeWidth,
              backgroundColor: widget.backgroundColor,
              reverse: false,
            ),
            size: Size(widget.size, widget.size),
          ),
          if (widget.showText)
            Container(
              width: widget.size - widget.strokeWidth * 2 - 4,
              height: widget.size - widget.strokeWidth * 2 - 4,
              alignment: Alignment.center,
              child: FittedBox(
                fit: BoxFit.scaleDown,
                child: Text(
                  displayText,
                  textAlign: TextAlign.center,
                  maxLines: 1,
                  style: widget.textStyle ?? defaultTextStyle,
                ),
              ),
            ),
        ],
      ),
    );
  }
}