This commit is contained in:
chenshuainan
2026-09-28 20:12:34 +08:00
parent 847986f861
commit 5f53bd8f94
60 changed files with 14624 additions and 398 deletions
@@ -0,0 +1,665 @@
import 'dart:ui' as ui show ImageByteFormat;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/center_toast.dart';
import 'package:new_learn/core/services/share_service.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【ConquerPosterPopup】攻克结果海报弹窗
/// 1:1 对标 Vue components/ConquerPosterPopup/ConquerPosterPopup.vue
///
/// - status == 1 成功海报,其余失败海报
/// - 「分享海报」:RepaintBoundary 截图 → ShareService 系统分享
/// - 「保存海报」:截图 → ShareService 存系统相册
/// - 点遮罩/关闭按钮关闭
/// ============================================================
class ConquerPosterPopup extends StatefulWidget {
const ConquerPosterPopup({
super.key,
required this.show,
required this.status,
required this.name,
required this.accuracyRate,
required this.overcomeTimeSecond,
required this.userName,
required this.userAvatar,
required this.onClose,
});
final bool show;
/// 1=已攻克 其它=未攻克
final int status;
final String name;
final num accuracyRate;
final int overcomeTimeSecond;
final String userName;
final String userAvatar;
final VoidCallback onClose;
@override
State<ConquerPosterPopup> createState() => _ConquerPosterPopupState();
}
class _ConquerPosterPopupState extends State<ConquerPosterPopup> {
// 主题色(对标 themes/default.json:tColor('primary') = #3D7DF5)
static const Color _primary = Color(0xFF3D7DF5);
static const Color _title = Color(0xFF1D2B47);
static const Color _subtitle = Color(0xFF687FAD);
static const Color _userName = Color(0xFF495A7A);
static const Color _shareText = Color(0xFF6195F6);
static const Color _cardBorder = Color(0xFFE3ECFF);
final GlobalKey _boundaryKey = GlobalKey();
bool _busy = false;
int get _timeMin => widget.overcomeTimeSecond ~/ 60;
int get _timeSec => widget.overcomeTimeSecond % 60;
bool get _isSuccess => widget.status == 1;
/// 整数不显示小数尾零(对标 JS 模板插值:45.0 → 45)
String _formatNum(num v) => v % 1 == 0 ? '${v.toInt()}' : '$v';
@override
Widget build(BuildContext context) {
if (!widget.show) return const SizedBox.shrink();
return Positioned.fill(
child: Stack(
children: [
// 半透明遮罩(uview up-popup 默认 0.5 黑),点击关闭
Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onClose,
child: const ColoredBox(color: Color(0x80000000)),
),
),
// 弹窗主体(内容上下居中;屏幕过矮时可滚动)
Positioned.fill(
child: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Center(
child: SizedBox(
width: 320.w,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(height: 20.h),
// 右上角关闭按钮
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onClose,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-24/topRight-close.png',
),
width: 32.w,
height: 32.h,
fit: BoxFit.contain,
errorWidget: (_, _, _) => SizedBox(
width: 32.w,
height: 32.h,
child: const Icon(Icons.close,
color: Colors.white, size: 28),
),
),
),
),
SizedBox(height: 16.h),
_buildPoster(),
SizedBox(height: 30.h),
_buildActionButtons(),
SizedBox(height: 20.h),
],
),
),
),
),
),
);
},
),
),
),
// 生成中遮罩
if (_busy)
const Positioned.fill(
child: ColoredBox(
color: Color(0x66000000),
child: Center(
child: Card(
color: Color(0xCC000000),
child: Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
),
],
),
),
),
),
),
),
],
),
);
}
// ===================== 海报主容器(截图区域) =====================
Widget _buildPoster() {
return RepaintBoundary(
key: _boundaryKey,
child: Container(
width: 320.w,
padding: EdgeInsets.all(10.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build(
'/StaticResource/2026-04-24/wholeBlueBg-bg.png',
),
),
fit: BoxFit.fill,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 背景装饰星星
Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 19.h,
right: 10.w,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-24/white-star-bg.png',
),
width: 159.w,
fit: BoxFit.fitWidth,
),
),
Positioned(
top: 135.h,
right: 165.w,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-24/black-star-icon.png',
),
width: 16.w,
fit: BoxFit.fitWidth,
),
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左上角 Logo
Padding(
padding: EdgeInsets.only(
left: 8.w, top: 15.h, bottom: 15.h),
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-24/topLeft-logo.png',
),
width: 70.w,
height: 17.h,
fit: BoxFit.contain,
),
),
_buildInnerCard(),
],
),
],
),
],
),
),
);
}
/// 内部卡片:上半渐变(用户+吉祥物)+ 下半白色(知识点+统计)
Widget _buildInnerCard() {
return Container(
width: 300.w,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFE0F6FF), Color(0xFFD6EAFF)],
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12.r),
topRight: Radius.circular(60.r),
bottomLeft: Radius.circular(12.r),
bottomRight: Radius.circular(12.r),
),
border: Border.all(color: Colors.white, width: 1.w),
),
child: Stack(
children: [
// 125.81deg 白色高光渐变(对标 CSS 第一层 background)
Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12.r),
topRight: Radius.circular(60.r),
bottomLeft: Radius.circular(12.r),
bottomRight: Radius.circular(12.r),
),
gradient: const LinearGradient(
begin: Alignment(0.54, -0.84),
end: Alignment(-0.54, 0.84),
colors: [
Color(0xCCFFFFFF),
Color(0x00FFFFFF),
],
),
),
),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildCardTop(),
_buildCardBottom(),
],
),
],
),
);
}
Widget _buildCardTop() {
return Container(
height: 150.h, // min-height 300rpx
padding: EdgeInsets.fromLTRB(20.w, 20.h, 20.w, 0),
child: Stack(
clipBehavior: Clip.none,
children: [
// 用户信息行
Row(
children: [
Container(
width: 28.w,
height: 28.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2.w),
),
child: ClipOval(
child: CachedNetworkImage(
imageUrl: widget.userAvatar,
fit: BoxFit.cover,
placeholder: (_, _) => _defaultAvatar(),
errorWidget: (_, _, _) => _defaultAvatar(),
),
),
),
SizedBox(width: 8.w),
Flexible(
child: Text(
widget.userName.isNotEmpty ? widget.userName : '我',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: _userName,
),
),
),
],
),
// 状态区:发光 + 文案 + 吉祥物
Positioned(
top: 15.h,
left: 0,
right: 0,
bottom: 0,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 53.w,
top: 81.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
_isSuccess
? '/StaticResource/2026-04-24/conquerSuccessTextBlow-icon.png'
: '/StaticResource/2026-04-24/conquerFailTextBlow-icon.png',
),
width: 69.w,
fit: BoxFit.fitWidth,
),
),
Positioned(
left: 0,
top: 24.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
_isSuccess
? '/StaticResource/2026-04-24/conquerSuccessText.png'
: '/StaticResource/2026-04-24/conquerFailText.png',
),
width: 108.w,
fit: BoxFit.fitWidth,
),
),
Positioned(
right: -5.w,
bottom: 15.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
_isSuccess
? '/StaticResource/2026-04-24/conquerSuccess-image.png'
: '/StaticResource/2026-04-24/conquerFail-image.png',
),
width: 131.w,
fit: BoxFit.fitWidth,
),
),
],
),
),
],
),
);
}
Widget _buildCardBottom() {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(20.w, 20.h, 20.w, 14.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(12.r),
bottomRight: Radius.circular(12.r),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本次攻克知识点',
style: TextStyle(fontSize: 12.sp, color: _primary),
),
SizedBox(height: 10.h),
Text(
widget.name,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w500,
color: _title,
height: 1.25,
),
),
SizedBox(height: 20.h),
_buildStatsBox(),
],
),
);
}
Widget _buildStatsBox() {
return Container(
height: 92.h,
padding: EdgeInsets.only(top: 16.h, bottom: 8.h),
decoration: BoxDecoration(
color: const Color(0xFFFAFBFD),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: _cardBorder, width: 0.5.w),
),
child: Row(
children: [
Expanded(child: _buildStatAccuracy()),
Expanded(child: _buildStatTime()),
],
),
);
}
Widget _buildStatAccuracy() {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatNum(widget.accuracyRate),
style: TextStyle(
fontSize: 28.sp,
color: _primary,
height: 1.0,
),
),
SizedBox(width: 5.w),
Padding(
padding: EdgeInsets.only(bottom: 3.h),
child: Text('%',
style: TextStyle(fontSize: 14.sp, color: _primary)),
),
],
),
Text('正确率', style: TextStyle(fontSize: 12.sp, color: _subtitle)),
],
);
}
Widget _buildStatTime() {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (_timeMin > 0) ...[
Text('$_timeMin',
style: TextStyle(fontSize: 28.sp, color: _primary, height: 1)),
SizedBox(width: 2.w),
Padding(
padding: EdgeInsets.only(bottom: 3.h),
child: Text('分',
style: TextStyle(fontSize: 14.sp, color: _primary)),
),
SizedBox(width: 5.w),
],
Text('$_timeSec',
style: TextStyle(fontSize: 28.sp, color: _primary, height: 1)),
SizedBox(width: 2.w),
Padding(
padding: EdgeInsets.only(bottom: 3.h),
child: Text('秒',
style: TextStyle(fontSize: 14.sp, color: _primary)),
),
],
),
Text('用时', style: TextStyle(fontSize: 12.sp, color: _subtitle)),
],
);
}
// ===================== 底部按钮 =====================
Widget _buildActionButtons() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildButton(
text: '分享海报',
width: 115.w,
textColor: _shareText,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(50.r),
border: Border.all(color: const Color(0x6683CBFB), width: 1.w),
boxShadow: const [
BoxShadow(
color: Color(0x143D7DF5),
offset: Offset(0, 7.5),
blurRadius: 5.6,
),
],
),
onTap: _handleShare,
),
SizedBox(width: 30.w),
_buildButton(
text: '保存海报',
width: 115.w,
textColor: Colors.white,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0.0, 0.6422, 1.0],
colors: [Color(0xFF8AD1FD), Color(0xFF3C84FF), Color(0xFF3D7DF5)],
),
borderRadius: BorderRadius.circular(50.r),
boxShadow: const [
BoxShadow(
color: Color(0x143D7DF5),
offset: Offset(0, 7.5),
blurRadius: 5.6,
),
],
),
onTap: _handleSave,
),
],
);
}
Widget _buildButton({
required String text,
required double width,
required Color textColor,
required Decoration decoration,
required VoidCallback onTap,
}) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: width,
height: 49.h,
alignment: Alignment.center,
decoration: decoration,
child: Text(
text,
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.w500,
color: textColor,
),
),
),
);
}
Widget _defaultAvatar() {
return CachedNetworkImage(
imageUrl: ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png'),
fit: BoxFit.cover,
);
}
// ===================== 截图 / 分享 / 保存 =====================
Future<dynamic> _capturePoster() async {
final boundary = _boundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return null;
final image = await boundary.toImage(pixelRatio: 3);
final byteData =
await image.toByteData(format: ui.ImageByteFormat.png);
return byteData?.buffer.asUint8List();
}
Future<void> _handleShare() async {
if (_busy) return;
setState(() => _busy = true);
try {
final bytes = await _capturePoster();
if (bytes == null) {
_toast('生成海报失败');
return;
}
final result = await ShareService.instance.shareImageBytes(
bytes: bytes,
fileName: 'conquer_poster.png',
);
if (result.status.name == 'unavailable') {
_toast('当前版本暂不支持分享');
}
} catch (e) {
_toast('生成海报失败');
} finally {
if (mounted) setState(() => _busy = false);
}
}
Future<void> _handleSave() async {
if (_busy) return;
setState(() => _busy = true);
try {
final bytes = await _capturePoster();
if (bytes == null) {
_toast('生成海报失败');
return;
}
final ok = await ShareService.saveToGalleryFromBytes(
bytes: bytes,
fileName:
'conquer_poster_${DateTime.now().millisecondsSinceEpoch}.png',
);
if (ok) {
// 先弹 toast(挂在根 Overlay),再关闭海报弹框
_toast('保存成功');
widget.onClose();
} else {
_toast('保存失败');
}
} catch (e) {
_toast('保存失败');
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _toast(String text) {
if (!mounted) return;
CenterToast.show(context, text);
}
}
@@ -0,0 +1,168 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/auth/api.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_styles.dart';
/// ============================================================
/// 【GuideModal】攻克报告后功能引导遮罩
/// 1:1 对标 Vue components/GuideModal/GuideModal.vue
///
/// 行为:点击任意位置 → ChangeShowGuide → 刷新用户信息 → 关闭
/// ============================================================
class GuideModal extends ConsumerStatefulWidget {
const GuideModal({
super.key,
required this.show,
required this.onClose,
});
final bool show;
final VoidCallback onClose;
@override
ConsumerState<GuideModal> createState() => _GuideModalState();
}
class _GuideModalState extends ConsumerState<GuideModal> {
bool _closing = false;
Future<void> _handleClose() async {
if (_closing) return;
_closing = true;
try {
await UserApi.changeShowGuide();
// 强制刷新全局用户信息(失败也不阻塞关闭,refresh 内部已兜底)
await ref.read(currentUserProvider.notifier).refresh();
} catch (_) {
// 对标 Vue:接口失败仅打印,不影响关闭
} finally {
widget.onClose();
}
}
@override
Widget build(BuildContext context) {
if (!widget.show) return const SizedBox.shrink();
final pad = isPadDevice(context);
final buttonWidth = pad ? 255.w : 165.w;
return Positioned.fill(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleClose,
child: Stack(
children: [
const Positioned.fill(
child: ColoredBox(color: Color(0x8C000000)), // rgba(0,0,0,.55)
),
// 「点击任意位置退出」居中白字
Positioned.fill(
child: Center(
child: Text(
'点击任意位置退出',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w400,
height: 1.375,
),
),
),
),
// 引导图 bottom 292rpx
Positioned(
left: 0,
right: 0,
bottom: 146.h,
height: 139.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-06-09/guide_pg1781012082368.png',
),
fit: BoxFit.contain,
),
),
// 箭头 bottom 206rpx / left 180rpx
Positioned(
left: 90.w,
bottom: 103.h,
width: 12.w,
height: 50.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-06-09/guide_arrow1781012131243.png',
),
fit: BoxFit.contain,
),
),
// 假按钮组 bottom 80rpx
Positioned(
left: 0,
right: 0,
bottom: 40.h,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: buttonWidth,
height: 49.h,
margin: EdgeInsets.only(right: 17.w),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(50.r),
border: Border.all(
color: const Color(0xFF66A6FF),
width: 1.w,
),
),
child: Text(
'全部解析',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: const Color(0xFF66A6FF),
),
),
),
Container(
width: buttonWidth,
height: 49.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF3373FF), Color(0xFF3D7DF5)],
),
borderRadius: BorderRadius.circular(50.r),
boxShadow: const [
BoxShadow(
color: Color(0x4D3D7DF5),
offset: Offset(0, 7.5),
blurRadius: 15,
),
],
),
child: Text(
'错题解析',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,166 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【SemicircleProgress】半圆正确率进度环
/// 1:1 对标 Vue components/lProgressCircle/lProgressCircle.vue
/// (report.vue 用法:width 308rpx / height 168rpx / strokeWidth 14 /
/// bgColor #D8DFEB / progressColor #1578FF,上半圆从左到右)
///
/// 尺寸说明(Vue canvas 逻辑像素 → Flutter dp):
/// 308rpx = 154.w,168rpx = 84.h,描边 14px = 7.w,
/// paddingTop 默认 8px = 4.h;
/// radius = min(宽/2 - 描边, 高 - 描边 - paddingTop)
/// ============================================================
class SemicircleProgress extends StatelessWidget {
const SemicircleProgress({
super.key,
required this.percent,
this.width = 154,
this.height = 84,
this.strokeWidth = 10,
this.paddingTop = 4,
this.trackColor = const Color(0xFFD8DFEB),
this.progressColor = const Color(0xFF1578FF),
});
/// 0 ~ 100
final num percent;
final double width;
final double height;
final double strokeWidth;
final double paddingTop;
final Color trackColor;
final Color progressColor;
@override
Widget build(BuildContext context) {
final w = width.w;
final h = height.h;
final sw = strokeWidth.w;
return SizedBox(
width: w,
height: h,
child: CustomPaint(
size: Size(w, h),
painter: _SemicirclePainter(
percent: percent.toDouble().clamp(0, 100),
strokeWidth: sw,
paddingTop: paddingTop.h,
trackColor: trackColor,
progressColor: progressColor,
),
// 对标 Vue slot:progress-content top 43rpx,居中展示数值
child: Padding(
padding: EdgeInsets.only(top: 31.5.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 大数字 + %(font-50 粗体标题色 + font-24 副色 %,底 10rpx)
Padding(
padding: EdgeInsets.only(bottom: 5.h),
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: '${percent % 1 == 0 ? percent.toInt() : percent}',
style: TextStyle(
fontSize: 25.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
TextSpan(
text: '%',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF495A7A),
),
),
],
),
),
),
Text(
'本次正确率',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF687FAD),
),
),
],
),
),
),
);
}
}
class _SemicirclePainter extends CustomPainter {
const _SemicirclePainter({
required this.percent,
required this.strokeWidth,
required this.paddingTop,
required this.trackColor,
required this.progressColor,
});
final double percent;
final double strokeWidth;
final double paddingTop;
final Color trackColor;
final Color progressColor;
@override
void paint(Canvas canvas, Size size) {
final halfStroke = strokeWidth / 2;
final maxRadiusByWidth = size.width / 2 - strokeWidth;
final maxRadiusByHeight = size.height - strokeWidth - paddingTop;
final radius = math.max(0.0, math.min(maxRadiusByWidth, maxRadiusByHeight));
final centerX = size.width / 2;
final centerY = size.height - halfStroke;
final trackPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..color = trackColor;
// 背景半圆环:π(左)→ 0(右)
canvas.drawArc(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius),
math.pi,
math.pi,
false,
trackPaint,
);
// 进度弧
if (percent > 0) {
final progressPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..color = progressColor;
final sweep = math.pi * (percent / 100);
canvas.drawArc(
Rect.fromCircle(center: Offset(centerX, centerY), radius: radius),
math.pi,
sweep,
false,
progressPaint,
);
}
}
@override
bool shouldRepaint(covariant _SemicirclePainter oldDelegate) {
return oldDelegate.percent != percent ||
oldDelegate.strokeWidth != strokeWidth ||
oldDelegate.trackColor != trackColor ||
oldDelegate.progressColor != progressColor;
}
}