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,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;
}