463 lines
14 KiB
Dart
463 lines
14 KiB
Dart
/// ============================================================
|
||
/// 【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<Map<String, dynamic>> questions;
|
||
final bool isExamAnswerCard;
|
||
|
||
/// answer / result
|
||
final String statusMode;
|
||
final bool enableExtendedAnswerStatus;
|
||
|
||
final VoidCallback? onCancel;
|
||
final VoidCallback? onSubmit;
|
||
final ValueChanged<Map<String, dynamic>>? 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<String, dynamic> 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<String, dynamic> 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<Map<String, dynamic>> _groups() {
|
||
final flat = buildExamAnswerCardQuestions(
|
||
questions,
|
||
isExamAnswerCard: isExamAnswerCard,
|
||
)..sort((a, b) => _num(a['sort']).compareTo(_num(b['sort'])));
|
||
|
||
final result = <Map<String, dynamic>>[];
|
||
Map<String, dynamic>? current;
|
||
for (final q in flat) {
|
||
final typeName = '${q['questionTypeName'] ?? '题目'}';
|
||
if (current == null || current['name'] != typeName) {
|
||
current = {
|
||
'name': typeName,
|
||
'questions': <Map<String, dynamic>>[],
|
||
};
|
||
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<Map<String, dynamic>>();
|
||
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;
|
||
}
|