Files
App.K12.Student.Miniapp_Flu…/lib/features/conquerPackage/classExercises/widgets/answer_card_popup.dart
T
2026-09-28 20:12:34 +08:00

463 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 【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;
}