更新
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
/// ============================================================
|
||||
/// 【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;
|
||||
}
|
||||
Reference in New Issue
Block a user