flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||
import 'package:new_learn/features/exam/exam_question.dart';
|
||||
import 'package:new_learn/features/exam/question_type.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【QuestionCard】题目卡片 —— 纯展示组件(业务解耦)
|
||||
///
|
||||
/// 一、设计原则:
|
||||
/// 组件不碰 Riverpod,只接收 question + 当前答案 + 回调,
|
||||
/// 由 ExamPage 负责桥接 provider。这样组件可复用、可单测,
|
||||
/// 换状态管理方案不用改组件。
|
||||
///
|
||||
/// 二、题型分发(本项目核心模式):
|
||||
/// 按 question.type switch 到三个子视图:
|
||||
/// ChoiceQuestionView(单选/多选,共用选项布局,toggle 逻辑区分)
|
||||
/// JudgeQuestionView(判断,对/错两按钮)
|
||||
/// EssayQuestionView(解答,文本输入 + 防抖提交)
|
||||
/// 新增题型只需加一个枚举值 + 一个视图,QuestionCard 加一个 case。
|
||||
///
|
||||
/// 三、题干渲染:
|
||||
/// RichTextContent 纯 widget 渲染 HTML(非 WebView),
|
||||
/// 富文本解析发生在每次 build —— 真实项目可在此接入预解析缓存
|
||||
/// (进卷时批量解析一次 HTML,按 questionId 缓存解析结果)。
|
||||
/// ============================================================
|
||||
class QuestionCard extends StatelessWidget {
|
||||
const QuestionCard({
|
||||
super.key,
|
||||
required this.question,
|
||||
required this.answer,
|
||||
required this.onAnswer,
|
||||
});
|
||||
|
||||
final ExamQuestion question;
|
||||
final String? answer;
|
||||
final ValueChanged<String> onAnswer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = question.type;
|
||||
return Container(
|
||||
margin: EdgeInsets.all(16.w),
|
||||
padding: EdgeInsets.all(16.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(color: const Color(0xFFEEF1F5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ---------- 题型标签 + 题号 ----------
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: _typeColor(type).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4.r),
|
||||
),
|
||||
child: Text(
|
||||
type.label,
|
||||
style: TextStyle(
|
||||
fontSize: 11.sp,
|
||||
color: _typeColor(type),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
'第 ${question.questionId} 题',
|
||||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
// ---------- 题干(富文本) ----------
|
||||
RichTextContent(
|
||||
html: question.title,
|
||||
textStyle: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.6,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
// ---------- 按题型分发 ----------
|
||||
switch (type) {
|
||||
QuestionType.single => ChoiceQuestionView(
|
||||
options: question.options,
|
||||
multiSelect: false,
|
||||
answer: answer,
|
||||
onAnswer: onAnswer,
|
||||
),
|
||||
QuestionType.multi => ChoiceQuestionView(
|
||||
options: question.options,
|
||||
multiSelect: true,
|
||||
answer: answer,
|
||||
onAnswer: onAnswer,
|
||||
),
|
||||
QuestionType.judge => JudgeQuestionView(
|
||||
answer: answer,
|
||||
onAnswer: onAnswer,
|
||||
),
|
||||
QuestionType.essay => EssayQuestionView(
|
||||
answer: answer,
|
||||
onAnswer: onAnswer,
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Color _typeColor(QuestionType type) {
|
||||
return switch (type) {
|
||||
QuestionType.single => const Color(0xFF3B82F6),
|
||||
QuestionType.multi => const Color(0xFF8B5CF6),
|
||||
QuestionType.judge => const Color(0xFFF59E0B),
|
||||
QuestionType.essay => const Color(0xFF10B981),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【ChoiceQuestionView】选择题(单选/多选共用)
|
||||
///
|
||||
/// 受控组件:选中状态完全来自外部 answer,自己不留状态。
|
||||
/// 答案格式:"A"(单选)/ "A,C"(多选,升序逗号拼接)。
|
||||
/// ============================================================
|
||||
class ChoiceQuestionView extends StatelessWidget {
|
||||
const ChoiceQuestionView({
|
||||
super.key,
|
||||
required this.options,
|
||||
required this.multiSelect,
|
||||
required this.answer,
|
||||
required this.onAnswer,
|
||||
});
|
||||
|
||||
final List<String> options;
|
||||
final bool multiSelect;
|
||||
final String? answer;
|
||||
final ValueChanged<String> onAnswer;
|
||||
|
||||
void _onTap(int index) {
|
||||
final letter = String.fromCharCode(65 + index); // 0→A, 1→B...
|
||||
if (!multiSelect) {
|
||||
onAnswer(letter);
|
||||
return;
|
||||
}
|
||||
// 多选:在选中集合中 toggle,再升序拼接
|
||||
final selected = answer == null
|
||||
? <String>{}
|
||||
: answer!.split(',').where((s) => s.isNotEmpty).toSet();
|
||||
if (!selected.add(letter)) selected.remove(letter);
|
||||
final sorted = selected.toList()..sort();
|
||||
onAnswer(sorted.join(','));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedSet = answer == null
|
||||
? <String>{}
|
||||
: answer!.split(',').where((s) => s.isNotEmpty).toSet();
|
||||
return Column(
|
||||
children: [
|
||||
for (int i = 0; i < options.length; i++) ...[
|
||||
_buildOption(i, selectedSet),
|
||||
if (i < options.length - 1) SizedBox(height: 10.h),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOption(int index, Set<String> selectedSet) {
|
||||
final letter = String.fromCharCode(65 + index);
|
||||
final isSelected = selectedSet.contains(letter);
|
||||
return InkWell(
|
||||
onTap: () => _onTap(index),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFFEFF6FF) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFFE5E7EB),
|
||||
width: isSelected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 26.w,
|
||||
height: 26.w,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected ? const Color(0xFF3B82F6) : Colors.white,
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFF9CA3AF),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSelected ? Colors.white : const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
options[index],
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (multiSelect)
|
||||
Icon(
|
||||
isSelected ? Icons.check_circle : Icons.circle_outlined,
|
||||
size: 18,
|
||||
color: isSelected
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFFD1D5DB),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【JudgeQuestionView】判断题(对/错两按钮)
|
||||
/// 答案格式:"对" / "错"(与 Question.answer 存储约定一致)。
|
||||
/// ============================================================
|
||||
class JudgeQuestionView extends StatelessWidget {
|
||||
const JudgeQuestionView({
|
||||
super.key,
|
||||
required this.answer,
|
||||
required this.onAnswer,
|
||||
});
|
||||
|
||||
final String? answer;
|
||||
final ValueChanged<String> onAnswer;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
for (final value in const ['对', '错']) ...[
|
||||
Expanded(
|
||||
child: _buildJudgeButton(value),
|
||||
),
|
||||
if (value == '对') SizedBox(width: 12.w),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildJudgeButton(String value) {
|
||||
final isSelected = answer == value;
|
||||
return InkWell(
|
||||
onTap: () => onAnswer(value),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (value == '对' ? const Color(0xFFE8F7EE) : const Color(0xFFFDECEA))
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? (value == '对' ? const Color(0xFF22C55E) : const Color(0xFFEF4444))
|
||||
: const Color(0xFFE5E7EB),
|
||||
width: isSelected ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
value == '对' ? Icons.check_circle : Icons.cancel,
|
||||
size: 22,
|
||||
color: isSelected
|
||||
? (value == '对' ? const Color(0xFF22C55E) : const Color(0xFFEF4444))
|
||||
: const Color(0xFF9CA3AF),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 【EssayQuestionView】解答题(文本输入 + 300ms 防抖提交)
|
||||
///
|
||||
/// 防抖原因:onChanged 每敲一个字都会触发 provider 更新 + Isar 写库,
|
||||
/// 300ms 空闲后合并为一次提交,减少高频写盘。
|
||||
/// 注意:组件是受控回填的 —— 外部 answer 变化(续答恢复)时
|
||||
/// didUpdateWidget 同步 controller 文本,但不打断用户正在输入。
|
||||
/// ============================================================
|
||||
class EssayQuestionView extends StatefulWidget {
|
||||
const EssayQuestionView({
|
||||
super.key,
|
||||
required this.answer,
|
||||
required this.onAnswer,
|
||||
});
|
||||
|
||||
final String? answer;
|
||||
final ValueChanged<String> onAnswer;
|
||||
|
||||
@override
|
||||
State<EssayQuestionView> createState() => _EssayQuestionViewState();
|
||||
}
|
||||
|
||||
class _EssayQuestionViewState extends State<EssayQuestionView> {
|
||||
late final TextEditingController _controller;
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.answer ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant EssayQuestionView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 外部答案变化(如续答恢复)且与当前输入不一致时才回填,
|
||||
// 避免覆盖用户正在输入的内容
|
||||
if (widget.answer != null && widget.answer != _controller.text) {
|
||||
_controller.text = widget.answer!;
|
||||
}
|
||||
}
|
||||
|
||||
void _onChanged(String text) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(const Duration(milliseconds: 300), () {
|
||||
widget.onAnswer(text.trim());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounce?.cancel();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF9FAFB),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
onChanged: _onChanged,
|
||||
maxLines: 5,
|
||||
minLines: 3,
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入你的解答...',
|
||||
hintStyle: const TextStyle(color: Color(0xFFB0B7C3)),
|
||||
contentPadding: EdgeInsets.all(12.w),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
style: TextStyle(fontSize: 14.sp, height: 1.5),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user