/// ============================================================ /// 【AnswerCardPopup】答题卡弹层 /// (1:1 迁移 Vue components/AnswerCardPopup/AnswerCardPopup.vue) /// /// statusMode: /// answer 答题中:未答 / 已答 /(扩展)时长不足 / 待补笔记,底部双按钮 /// result 查看结果:答对 / 答错 / 未答,无底部按钮 /// /// 数据:buildExamAnswerCardQuestions 拍平 → 按 sort 排序 → /// 相邻 questionTypeName 分组。 /// 事件:onSelect(questionMap) 选择题目(同时关闭); /// onCancel 继续答题/关闭;onSubmit 交卷。 /// ============================================================ library; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/core/utils/question/exam_answer_card.dart'; import 'exercise_styles.dart'; /// 答题卡状态码 class AnswerCardStatus { AnswerCardStatus._(); static const int unanswered = 0; static const int answered = 1; static const int correct = 2; static const int wrong = 3; static const int planTimeNotEnough = 20; static const int pendingNotes = 21; } class AnswerCardPopup extends StatelessWidget { const AnswerCardPopup({ super.key, required this.show, required this.questions, this.isExamAnswerCard = true, this.statusMode = 'answer', this.enableExtendedAnswerStatus = false, this.onCancel, this.onSubmit, this.onSelect, }); final bool show; /// QuestionModel.m 原始题目 Map 列表(同一引用即可,内部只读取/拷贝) final List> questions; final bool isExamAnswerCard; /// answer / result final String statusMode; final bool enableExtendedAnswerStatus; final VoidCallback? onCancel; final VoidCallback? onSubmit; final ValueChanged>? onSelect; bool get _extended => statusMode == 'answer' && enableExtendedAnswerStatus; // ---------------- 状态判定(1:1 移植 Vue) ---------------- String _normalizeAnswer(dynamic value) => '$value'.replaceAll(RegExp(r'<[^>]+>'), '').replaceAll(RegExp(r'\s+'), '').toUpperCase(); bool _hasUploadedNotes(dynamic notes) { if (notes is List) return notes.isNotEmpty; return hasValue(notes); } bool _hasFinishedPlanTime(Map q) { final planTimeSecond = _num(q['planTimeSecond']); if (planTimeSecond <= 0) return true; if (q['ISFinishPlanTime'] == true) return true; final answerTimeSecond = _num(q['answerTimeSecond']); return answerTimeSecond > planTimeSecond; } int _questionStatus(Map q) { final fillBlankAnswered = isFillBlankFullyAnswered(q); final answered = (fillBlankAnswered == null || !fillBlankAnswered) ? hasValue(q['userAnswer']) : fillBlankAnswered; if (statusMode == 'answer') { if (!answered) return AnswerCardStatus.unanswered; if (_extended && _num(q['planTimeSecond']) > 0 && !_hasFinishedPlanTime(q)) { return AnswerCardStatus.planTimeNotEnough; } if (_extended && q['isUploadNotes'] == true && !_hasUploadedNotes(q['notes'])) { return AnswerCardStatus.pendingNotes; } return AnswerCardStatus.answered; } // result 模式 if (!answered) return AnswerCardStatus.unanswered; final rawStatus = q['answerStatus']; if (rawStatus != null) { final status = _num(rawStatus, -1); if (status == 10) return AnswerCardStatus.correct; if (status == 11 || status == 12 || status == 1) { return AnswerCardStatus.wrong; } if (status == 0) return AnswerCardStatus.unanswered; } final myAnswer = _normalizeAnswer(q['userAnswer']); final correctAnswer = _normalizeAnswer(q['correctAnswer'] ?? q['answer']); return myAnswer == correctAnswer ? AnswerCardStatus.correct : AnswerCardStatus.wrong; } /// 拍平 + 按相邻题型分组 List> _groups() { final flat = buildExamAnswerCardQuestions( questions, isExamAnswerCard: isExamAnswerCard, )..sort((a, b) => _num(a['sort']).compareTo(_num(b['sort']))); final result = >[]; Map? current; for (final q in flat) { final typeName = '${q['questionTypeName'] ?? '题目'}'; if (current == null || current['name'] != typeName) { current = { 'name': typeName, 'questions': >[], }; result.add(current); } (current['questions'] as List).add({ ...q, 'status': _questionStatus(q), }); } return result; } // ---------------- 渲染 ---------------- @override Widget build(BuildContext context) { if (!show) return const SizedBox.shrink(); final isPad = isPadDevice(context); final screenH = MediaQuery.sizeOf(context).height; return Positioned.fill( child: Stack( children: [ Positioned.fill( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onCancel, child: const ColoredBox(color: Color(0x99000000)), ), ), Align( alignment: Alignment.bottomCenter, child: Container( height: screenH * 0.8, width: double.infinity, padding: EdgeInsets.symmetric(horizontal: 14.w), decoration: BoxDecoration( borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), image: DecorationImage( image: CachedNetworkImageProvider( ImgUrl.build( isPad ? '/StaticResource/2026-06-02/answer-sheet-bg-Pad1780382627852.png' : '/StaticResource/2026-04-08/answer-sheet-bg.png', ), ), fit: isPad ? BoxFit.fill : BoxFit.fill, ), ), clipBehavior: Clip.antiAlias, child: Column( children: [ _buildHeader(isPad), Expanded(child: _buildBody()), if (statusMode == 'answer') _buildFooter(), ], ), ), ), // pad 右上关闭热区(50% 宽 × 400rpx) if (isPad) Positioned( right: 0, top: screenH * 0.04, width: MediaQuery.sizeOf(context).width * 0.5, height: 200.h, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onCancel, ), ), ], ), ); } Widget _buildHeader(bool isPad) { return Padding( padding: EdgeInsets.only(top: isPad ? 180.h : 95.h), // 190/360rpx child: Column( children: [ // 标题 "答题卡",右下角 title-bacs.png 装饰背景 Align( alignment: Alignment.centerLeft, child: Container( padding: EdgeInsets.only(right: 30.w), decoration: BoxDecoration( image: DecorationImage( image: CachedNetworkImageProvider( ImgUrl.build( '/StaticResource/2026-04-01/title-bacs.png'), ), alignment: Alignment.centerRight, fit: BoxFit.fitHeight, ), ), child: Text( '答题卡', style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.w600, color: ExerciseColors.title, ), ), ), ), SizedBox(height: 10.h), if (statusMode == 'answer') _buildLegend( isPad, _extended ? const [ _LegendItem('未答', Color(0xFF94A5B8)), _LegendItem('已答', Color(0xFF3D7DF5)), _LegendItem('时长不足', Color(0xFFFF9F1A)), _LegendItem('待补笔记', Color(0xFF8B5CF6)), ] : const [ _LegendItem('未答', Color(0xFF94A5B8)), _LegendItem('已答', Color(0xFF3D7DF5)), ], extended: _extended, ) else _buildLegend( isPad, const [ _LegendItem('答对', Color(0xFF00CC96)), _LegendItem('答错', Color(0xFFFF4D4D)), _LegendItem('未答', Color(0xFF94A5B8)), ], extended: false, ), ], ), ); } Widget _buildLegend( bool isPad, List<_LegendItem> items, { required bool extended, }) { return Container( width: extended ? 320.w : 150.w, padding: EdgeInsets.all(5.w), decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0x05FFFFFF), Color(0x33FFFFFF)], ), border: Border.all(color: const Color(0x4DFFFFFF), width: 1.w), borderRadius: BorderRadius.circular(16.r), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ for (final item in items) Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 6.w, height: 6.w, decoration: BoxDecoration( color: item.color, shape: BoxShape.circle, ), ), SizedBox(width: 5.w), Text( item.label, style: TextStyle(fontSize: 12.sp, color: ExerciseColors.subtitle), ), ], ), ], ), ); } Widget _buildBody() { final groups = _groups(); return Padding( padding: EdgeInsets.only(top: 20.h, bottom: 20.h), child: ListView.separated( padding: EdgeInsets.zero, itemCount: groups.length, separatorBuilder: (_, _) => SizedBox(height: 20.h), itemBuilder: (context, i) { final g = groups[i]; final qs = (g['questions'] as List).cast>(); return Container( padding: EdgeInsets.all(14.w), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10.r), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(bottom: 12.h), child: Text( g['name'] as String, style: TextStyle( fontSize: 12.sp, fontWeight: FontWeight.w600, color: ExerciseColors.subtitle, ), ), ), Wrap( spacing: 10.w, runSpacing: 10.h, children: [ for (final q in qs) GestureDetector( onTap: () => onSelect?.call(q), behavior: HitTestBehavior.opaque, child: Container( width: 44.w, // 88rpx height: 36.h, // 72rpx alignment: Alignment.center, decoration: BoxDecoration( color: _itemBg(q['status'] as int), borderRadius: BorderRadius.circular(6.r), ), child: Text( '${q['sort']}', style: TextStyle( fontSize: 14.sp, color: _itemColor(q['status'] as int), ), ), ), ), ], ), ], ), ); }, ), ); } Widget _buildFooter() { return Container( color: Colors.white, padding: EdgeInsets.only(top: 12.h, bottom: 12.h), child: SafeArea( top: false, child: Row( children: [ Expanded( child: ExerciseButton( text: '继续答题', style: ExerciseButtonStyle.outline, onTap: onCancel, ), ), SizedBox(width: 10.w), Expanded( child: ExerciseButton( text: '交卷', style: ExerciseButtonStyle.blueGradient, onTap: onSubmit, ), ), ], ), ), ); } Color _itemBg(int status) { switch (status) { case AnswerCardStatus.answered: return const Color(0xFFF0F8FF); case AnswerCardStatus.correct: return const Color(0xFFE8FAF6); case AnswerCardStatus.wrong: return const Color(0xFFFFF0F0); case AnswerCardStatus.planTimeNotEnough: return const Color(0xFFFFF6E8); case AnswerCardStatus.pendingNotes: return const Color(0xFFF4EEFF); default: return const Color(0xFFF5F7FA); } } Color _itemColor(int status) { switch (status) { case AnswerCardStatus.answered: return Color(0xFF3D7DF5); case AnswerCardStatus.correct: return ExerciseColors.correct; case AnswerCardStatus.wrong: return const Color(0xFFFF5252); case AnswerCardStatus.planTimeNotEnough: return const Color(0xFFFF9F1A); case AnswerCardStatus.pendingNotes: return const Color(0xFF8B5CF6); default: return const Color(0xFF94A5B8); } } } class _LegendItem { const _LegendItem(this.label, this.color); final String label; final Color color; } int _num(dynamic v, [int def = 0]) { if (v is num) return v.toInt(); return int.tryParse('${v ?? ''}') ?? def; }