更新
This commit is contained in:
@@ -0,0 +1,885 @@
|
||||
/// ============================================================
|
||||
/// 【ReportPage】攻克报告页
|
||||
/// 1:1 对标 Vue pages/conquerPackage/report/report.vue
|
||||
///
|
||||
/// 路由 extra:
|
||||
/// - recordId 学习记录 id(同时用于 GetReport/{id})
|
||||
/// - packId 攻克包 id(跳解析页时作为 totalPackId)
|
||||
/// - from 来源(submit=交卷后进入:隐藏返回、显示小房子)
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
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:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/api/conquerPackage/classExercises/api.dart';
|
||||
import 'package:new_learn/core/providers/user_provider.dart';
|
||||
import 'package:new_learn/core/utils/img_url.dart';
|
||||
import 'package:new_learn/core/utils/question/exam_answer_card.dart';
|
||||
import 'package:new_learn/core/utils/question/question_constant.dart';
|
||||
import 'package:new_learn/core/utils/question/question_parser.dart';
|
||||
import 'package:new_learn/features/conquerPackage/classExercises/controllers/exercise_timing.dart';
|
||||
import 'package:new_learn/features/conquerPackage/classExercises/widgets/exercise_styles.dart';
|
||||
import 'package:new_learn/features/conquerPackage/report/widgets/conquer_poster_popup.dart';
|
||||
import 'package:new_learn/features/conquerPackage/report/widgets/guide_modal.dart';
|
||||
import 'package:new_learn/features/conquerPackage/report/widgets/semicircle_progress.dart';
|
||||
|
||||
class ReportPage extends ConsumerStatefulWidget {
|
||||
const ReportPage({super.key, this.extra});
|
||||
|
||||
final Map<String, dynamic>? extra;
|
||||
|
||||
@override
|
||||
ConsumerState<ReportPage> createState() => _ReportPageState();
|
||||
}
|
||||
|
||||
class _ReportPageState extends ConsumerState<ReportPage> {
|
||||
// ===================== 颜色(对标 report.vue / common.scss) =====================
|
||||
static const Color _bg = Color(0xFFF5F8FA);
|
||||
static const Color _title = Color(0xFF1D2B47);
|
||||
static const Color _primary = Color(0xFF3D7DF5);
|
||||
static const Color _subtitle = Color(0xFF687FAD);
|
||||
static const Color _success = Color(0xFF00CC96);
|
||||
static const Color _error = Color(0xFFFF5252);
|
||||
static const Color _unanswered = Color(0xFF94A5B8);
|
||||
static const Color _failText = Color(0xFFB4C2E0);
|
||||
|
||||
// ===================== 路由参数 =====================
|
||||
String _recordId = '';
|
||||
String _packId = '';
|
||||
bool _isFromSubmit = false;
|
||||
|
||||
// ===================== 页面数据 =====================
|
||||
Map<String, dynamic> _data = <String, dynamic>{};
|
||||
List<Map<String, dynamic>> _answerCard = [];
|
||||
bool _loading = true;
|
||||
|
||||
bool _showPoster = false;
|
||||
bool _showGuide = false;
|
||||
|
||||
// 跳转防连点(对标 navigateToOnce:2 秒节流 + 原子锁 + 1200ms 兜底解锁)
|
||||
bool _navigating = false;
|
||||
int _lastNavigateAt = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final extra = widget.extra ?? const <String, dynamic>{};
|
||||
_recordId = '${extra['recordId'] ?? ''}';
|
||||
_packId = '${extra['packId'] ?? ''}';
|
||||
_isFromSubmit = extra['from'] == 'submit';
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
|
||||
// ===================== 数据加载(对标 GetSubmitReportFn) =====================
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final res =
|
||||
await ClassExercisesApi.instance.getSubmitReport(_recordId);
|
||||
|
||||
final user = ref.read(currentUserProvider).value;
|
||||
final userName = (user?.userName.isNotEmpty ?? false)
|
||||
? user!.userName
|
||||
: '我';
|
||||
final userAvatar = user?.avatar ?? '';
|
||||
|
||||
final data = Map<String, dynamic>.from(res.raw);
|
||||
// 后端数值字段可能返回 String,统一规范为 num,避免 build 中 as num 崩溃
|
||||
final accuracyRate =
|
||||
num.tryParse('${data['accuracyRate'] ?? 0}') ?? 0;
|
||||
|
||||
setState(() {
|
||||
_data = {
|
||||
...data,
|
||||
'userName': userName,
|
||||
'userAvatar': userAvatar,
|
||||
'accuracyRate': accuracyRate,
|
||||
};
|
||||
});
|
||||
|
||||
// questions 解析 / 答题卡构建单独兜底:单题异常不影响头部与统计
|
||||
final questions = res.questions;
|
||||
if (questions.isNotEmpty) {
|
||||
try {
|
||||
final subjectType =
|
||||
getSubjectTypeById(data['baseSubjectId']) ?? 0;
|
||||
final parsed = questions.map((item) {
|
||||
item['iSFilllankItemUpload'] = subjectType;
|
||||
return parseQuestionData(item, mergeOriginalChilds: true);
|
||||
}).toList();
|
||||
var questionsData = appendQuestionSort(parsed);
|
||||
questionsData =
|
||||
appendExamQuestionChildOptionStatus(questionsData,
|
||||
data['baseSubjectId']);
|
||||
final card = buildExamAnswerCard(questionsData);
|
||||
setState(() => _answerCard = card);
|
||||
} catch (e) {
|
||||
debugPrint('GetSubmitReport build answerCard error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// status 1 已攻克 / 2 未攻克 → 弹海报
|
||||
final status = _di('status');
|
||||
if (status == 1 || status == 2) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() => _showPoster = true);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
_toast('获取报告失败,请稍后重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
int _di(String key) => int.tryParse('${_data[key] ?? 0}') ?? 0;
|
||||
|
||||
num get _accuracyRate =>
|
||||
num.tryParse('${_data['accuracyRate'] ?? 0}') ?? 0;
|
||||
|
||||
bool get _hasHandouts {
|
||||
final h = _data['handouts'];
|
||||
return h is List && h.isNotEmpty;
|
||||
}
|
||||
|
||||
// 回答状态(未回答=0, 判题中=1, 正确=10, 错误=11, 部分错误=12)
|
||||
Color _statusColor(dynamic status) {
|
||||
final s = int.tryParse('$status') ?? 0;
|
||||
if (s == 10) return _success;
|
||||
if (s == 11 || s == 12 || s == 1) return _error;
|
||||
return _unanswered;
|
||||
}
|
||||
|
||||
Color _statusBg(dynamic status) {
|
||||
final s = int.tryParse('$status') ?? 0;
|
||||
if (s == 10) return const Color(0xFFE6FAF4);
|
||||
if (s == 11 || s == 12 || s == 1) return const Color(0xFFFFF2F2);
|
||||
return const Color(0xFFF5F7FA);
|
||||
}
|
||||
|
||||
// ===================== 跳转(对标 navigateToOnce) =====================
|
||||
|
||||
void _navigateToOnce(String location, Map<String, dynamic> extra) {
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
if (now - _lastNavigateAt < 2000) return;
|
||||
if (_navigating) return;
|
||||
_lastNavigateAt = now;
|
||||
_navigating = true;
|
||||
Future.delayed(const Duration(milliseconds: 1200), () {
|
||||
_navigating = false;
|
||||
});
|
||||
context.push(location, extra: extra).whenComplete(() {
|
||||
_navigating = false;
|
||||
});
|
||||
}
|
||||
|
||||
/// 查看讲义
|
||||
void _getLectureNotes() {
|
||||
final id = '${_data['id'] ?? ''}';
|
||||
_navigateToOnce(
|
||||
'/conquerPackage/beforeDuringClass',
|
||||
{
|
||||
'status': '1',
|
||||
'packId': id,
|
||||
'recordId': id,
|
||||
'type': 'view',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 全部解析 / 点答题卡题号(带 targetSort)
|
||||
void _handleShowAnswerCard([Map<String, dynamic>? question]) {
|
||||
final targetSort = int.tryParse('${question?['sort'] ?? 0}') ?? 0;
|
||||
_navigateToOnce(
|
||||
'/conquerPackage/classExercises',
|
||||
{
|
||||
'courseId': '${_data['courseId'] ?? ''}',
|
||||
'packId': '${_data['id'] ?? ''}',
|
||||
'type': 'view',
|
||||
'totalPackId': _packId,
|
||||
if (targetSort > 0) 'targetSort': targetSort,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 错题解析
|
||||
void _handleWrongQuestionAnalysis() {
|
||||
_navigateToOnce(
|
||||
'/conquerPackage/classExercises',
|
||||
{
|
||||
'courseId': '${_data['courseId'] ?? ''}',
|
||||
'packId': '${_data['id'] ?? ''}',
|
||||
'type': 'view',
|
||||
'totalPackId': _packId,
|
||||
'isWrong': true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 海报关闭 → 未看过引导时延迟 500ms 弹引导
|
||||
void _handlePosterClose() {
|
||||
setState(() => _showPoster = false);
|
||||
final isShowGuide = ref.read(currentUserProvider).value?.isShowGuide;
|
||||
if (isShowGuide == false) {
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted) setState(() => _showGuide = true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _toast(String text) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(text),
|
||||
duration: const Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== Build =====================
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pad = isPadDevice(context);
|
||||
final correct = _di('correctQuestionCount');
|
||||
final total = _di('totalQuestionCount');
|
||||
final allCorrect = correct == total && total > 0;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
// HeaderBac 顶部背景图(固定屏幕顶部,与 classExercises 页一致)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-05-26/my-header-bg-default1779775984375.png',
|
||||
),
|
||||
fit: BoxFit.fitWidth,
|
||||
width: double.infinity,
|
||||
placeholder: (_, _) => const SizedBox.shrink(),
|
||||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
// 内容层:非定位 Column + Expanded,让滚动区严格撑满整屏
|
||||
// (与 class_exercises_page 相同的已验证骨架)
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildNavBar(),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildReportBac(pad),
|
||||
_buildAnswerCard(),
|
||||
// 底部固定栏留白 220rpx
|
||||
SizedBox(height: 110.h),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildFooter(allCorrect: allCorrect),
|
||||
ConquerPosterPopup(
|
||||
show: _showPoster,
|
||||
status: _di('status'),
|
||||
name: '${_data['name'] ?? ''}',
|
||||
accuracyRate: _accuracyRate,
|
||||
overcomeTimeSecond: _di('overcomeTimeSecond'),
|
||||
userName: '${_data['userName'] ?? '我'}',
|
||||
userAvatar: '${_data['userAvatar'] ?? ''}',
|
||||
onClose: _handlePosterClose,
|
||||
),
|
||||
GuideModal(
|
||||
show: _showGuide,
|
||||
onClose: () => setState(() => _showGuide = false),
|
||||
),
|
||||
if (_loading) _buildLoadingMask(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 加载遮罩(对标 uni.showLoading 加载中...) ----------
|
||||
|
||||
Widget _buildLoadingMask() {
|
||||
return const Positioned.fill(
|
||||
child: ColoredBox(
|
||||
color: Color(0x33000000),
|
||||
child: Center(
|
||||
child: Card(
|
||||
color: Color(0xCC000000),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'加载中...',
|
||||
style: TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== 滚动内容(HeaderBac + NavBar + 卡片) =====================
|
||||
// 背景图 / 滚动体 / NavBar 已在 build 的外层全屏 Stack 中分层,见 build()。
|
||||
|
||||
// ---------- NavBar(对标 NavBar.vue:居中标题 + 返回/小房子) ----------
|
||||
// 状态栏高度由外层 SafeArea 处理,这里只绘制 44px 的导航条。
|
||||
|
||||
Widget _buildNavBar() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 44.h,
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(left: 10.w),
|
||||
child: Text(
|
||||
'攻克报告',
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 12.w,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!_isFromSubmit)
|
||||
_navIcon(
|
||||
'/StaticResource/2026-04-01/return.png',
|
||||
onTap: _handleBack,
|
||||
),
|
||||
if (_isFromSubmit)
|
||||
_navIcon(
|
||||
'/StaticResource/2026-06-02/homesa1780391437267.png',
|
||||
onTap: () => context.go('/'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _navIcon(String path, {required VoidCallback onTap}) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2.w, vertical: 10.h),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(path),
|
||||
width: 24.w,
|
||||
height: 24.h,
|
||||
fit: BoxFit.contain,
|
||||
placeholder: (_, _) => Icon(
|
||||
Icons.arrow_back_ios,
|
||||
size: 18.w,
|
||||
color: const Color(0xCC000000),
|
||||
),
|
||||
errorWidget: (_, _, _) => Icon(
|
||||
Icons.home_outlined,
|
||||
size: 20.w,
|
||||
color: const Color(0xCC000000),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleBack() {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== report-bac:头部 + 正确率/统计白卡 =====================
|
||||
|
||||
Widget _buildReportBac(bool pad) {
|
||||
final status = _di('status');
|
||||
return Container(
|
||||
margin: EdgeInsets.fromLTRB(14.w, 30.h, 14.w, 0),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFFAFEFF), Color(0xFFEDF7FF)],
|
||||
),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// head
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
pad ? 29.w : 16.w, 15.h, pad ? 29.w : 16.w, 0),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: LayoutBuilder(
|
||||
builder: (_, constraints) => SizedBox(
|
||||
// .report-head view { width: 55% }
|
||||
width: constraints.maxWidth * 0.55,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (status == 1)
|
||||
_buildHeadTips(
|
||||
icon:
|
||||
'/StaticResource/2026-04-07/conquer-success-icon.png',
|
||||
text: '恭喜你,已攻克!',
|
||||
color: _primary,
|
||||
),
|
||||
if (status == 2)
|
||||
_buildHeadTips(
|
||||
icon:
|
||||
'/StaticResource/2026-04-07/conquer-fail-icon.png',
|
||||
text: '本次未攻克',
|
||||
color: _failText,
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 7.5.h),
|
||||
child: Text(
|
||||
'${_data['name'] ?? ''}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _title,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// report-main(margin-t-50)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 25.h),
|
||||
child: _buildMainCard(),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 右上勋章 260rpx,top -100rpx
|
||||
if (status == 1 || status == 2)
|
||||
Positioned(
|
||||
top: -50.h,
|
||||
right: pad ? 20.w : 0,
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
status == 1
|
||||
? '/StaticResource/2026-04-07/conquer-success.png'
|
||||
: '/StaticResource/2026-04-07/conquer-fail.png',
|
||||
),
|
||||
width: 130.w,
|
||||
height: 130.h,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeadTips({
|
||||
required String icon,
|
||||
required String text,
|
||||
required Color color,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(icon),
|
||||
width: 24.w,
|
||||
height: 24.h,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 14.sp, color: color),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(14.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 半圆环(padding-b-50)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 25.h),
|
||||
child: SemicircleProgress(
|
||||
percent: _accuracyRate,
|
||||
),
|
||||
),
|
||||
_buildStatistics(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatistics() {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 20.w),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFAFBFD),
|
||||
border: Border.all(color: const Color(0xFFE3ECFF), width: 1.w),
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_statItem('${_di('totalQuestionCount')}', '总计', _title),
|
||||
_statItem('${_di('correctQuestionCount')}', '答对', _success),
|
||||
_statItem('${_di('incorrectQuestionCount')}', '答错', _error),
|
||||
_statItem('${_di('unansweredQuestionCount')}', '未答', _title),
|
||||
_statItem(formatDuration(_di('overcomeTimeSecond')), '用时', _title),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _statItem(String num, String label, Color color) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 4.h),
|
||||
child: Text(
|
||||
num,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 12.sp, color: _subtitle),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== 答题卡卡 =====================
|
||||
|
||||
Widget _buildAnswerCard() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: EdgeInsets.fromLTRB(14.w, 14.h, 14.w, 0),
|
||||
padding: EdgeInsets.fromLTRB(14.w, 14.w, 14.w, 20.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildCardHead(),
|
||||
for (var i = 0; i < _answerCard.length; i++)
|
||||
_buildGroup(_answerCard[i], i),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCardHead() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build(
|
||||
'/StaticResource/2026-04-08/conquer-card-icon.png',
|
||||
),
|
||||
width: 18.w,
|
||||
height: 18.h,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(width: 5.w),
|
||||
Container(
|
||||
width: 59.5.w,
|
||||
height: 24.h,
|
||||
alignment: Alignment.centerLeft,
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
ImgUrl.build(
|
||||
'/StaticResource/2026-04-01/title-bacs.png',
|
||||
),
|
||||
),
|
||||
fit: BoxFit.fitWidth,
|
||||
alignment: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'答题卡',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _title,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_legend(_success, '答对'),
|
||||
_legend(_error, '答错'),
|
||||
_legend(_unanswered, '未答'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _legend(Color color, String label) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: 10.w),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6.w,
|
||||
height: 6.w,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
SizedBox(width: 5.w),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 12.sp, color: _subtitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGroup(Map<String, dynamic> group, int index) {
|
||||
final questions = (group['questions'] as List?) ?? const [];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: 20.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: 12.h),
|
||||
child: Text(
|
||||
'${group['name'] ?? ''}',
|
||||
style: TextStyle(fontSize: 12.sp, color: _subtitle),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 10.w,
|
||||
runSpacing: 10.w,
|
||||
children: [
|
||||
for (final q in questions)
|
||||
_buildQuestionItem(Map<String, dynamic>.from(q as Map)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuestionItem(Map<String, dynamic> question) {
|
||||
final status = question['status'];
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _handleShowAnswerCard(question),
|
||||
child: Container(
|
||||
width: 44.w,
|
||||
height: 36.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _statusBg(status),
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Text(
|
||||
'${question['sort'] ?? ''}',
|
||||
style: TextStyle(fontSize: 14.sp, color: _statusColor(status)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ===================== 底部固定按钮栏 =====================
|
||||
|
||||
Widget _buildFooter({required bool allCorrect}) {
|
||||
return Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
top: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
14.w,
|
||||
12.h,
|
||||
14.w,
|
||||
12.h + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_hasHandouts) ...[
|
||||
_buildLectureEntry(),
|
||||
SizedBox(width: 10.w),
|
||||
],
|
||||
Expanded(
|
||||
flex: allCorrect
|
||||
? (_hasHandouts ? 80 : 100)
|
||||
: (_hasHandouts ? 40 : 48),
|
||||
child: _footerButton(
|
||||
text: '全部解析',
|
||||
primary: false,
|
||||
onTap: () => _handleShowAnswerCard(),
|
||||
),
|
||||
),
|
||||
if (!allCorrect) ...[
|
||||
SizedBox(width: 14.w),
|
||||
Expanded(
|
||||
flex: _hasHandouts ? 40 : 48,
|
||||
child: _footerButton(
|
||||
text: '错题解析',
|
||||
primary: true,
|
||||
onTap: _handleWrongQuestionAnalysis,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLectureEntry() {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: _getLectureNotes,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
imageUrl: ImgUrl.build('/StaticResource/2026-04-02/note.png'),
|
||||
width: 24.w,
|
||||
height: 24.h,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(height: 5.h),
|
||||
Text(
|
||||
'查看讲义',
|
||||
style: TextStyle(fontSize: 10.sp, color: _title),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// default-button4(白底蓝边)/ default-button1(蓝渐变 + 投影)
|
||||
Widget _footerButton({
|
||||
required String text,
|
||||
required bool primary,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 46.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: primary ? null : Colors.white,
|
||||
gradient: primary
|
||||
? const LinearGradient(
|
||||
colors: [Color(0xFF3373FF), Color(0xFF3D7DF5)],
|
||||
)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(50.r),
|
||||
border: primary
|
||||
? null
|
||||
: Border.all(color: _primary, width: 1.w),
|
||||
boxShadow: primary
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D3D7DF5),
|
||||
offset: Offset(0, 7.5),
|
||||
blurRadius: 15,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: primary ? Colors.white : _primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user