本次提交实现了背诵模块的完整基础链路: 1. 新增词书、背单词接口层与对应数据模型 2. 完成背诵首页、章节列表页等核心页面开发 3. 实现音频播放、加载弹窗等通用组件 4. 配置路由跳转链路,替换旧版缓存传参方案 5. 对齐Vue端业务逻辑与UI样式
913 lines
28 KiB
Dart
913 lines
28 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:new_learn/core/api/lexicon/api.dart';
|
||
import 'package:new_learn/core/api/lexicon/model.dart';
|
||
import 'package:new_learn/core/components/center_toast.dart';
|
||
import 'package:new_learn/core/utils/img_url.dart';
|
||
import 'package:new_learn/core/utils/screen_adapt_template.dart';
|
||
import 'package:new_learn/features/recite/recite_args.dart';
|
||
import 'package:new_learn/features/recite/widgets/recite_audio.dart';
|
||
import 'package:new_learn/features/recite/widgets/recite_loading.dart';
|
||
import 'package:new_learn/features/recite/widgets/voice_anim_button.dart';
|
||
|
||
/// ============================================================
|
||
/// 【LevelStartPage】闯关答题 —— 对标 Vue levelStart.vue
|
||
///
|
||
/// 完整流程:
|
||
/// 1. getLevels(chapterId) 拿章节标题 + levelStart(levelId) 拿题;
|
||
/// 2. 进场 1s 后自动播英式发音;
|
||
/// 3. 提交答案(loading「正在提交」,失败重置选择允许重选)→ 标对错 →
|
||
/// contextMatch 非空自动翻转看语境;
|
||
/// 4. 非末题:4s 后切下一题(瞬时翻回 + splice + 自动发音);
|
||
/// 5. 末题:4s 后显示「加载中...」,轮询 GetLevelIsFinish(200ms/次,
|
||
/// 最多 30 次 ≈ 6s,替代 Vue 无限 while 死循环)→ 取小结跳转。
|
||
///
|
||
/// 音频:ReciteAudioManager 同时只播一个;dispose 时 stopAll(cleanupAllAudio)。
|
||
/// ============================================================
|
||
class LevelStartPage extends StatefulWidget {
|
||
const LevelStartPage({super.key, required this.args});
|
||
|
||
final LevelStartArgs args;
|
||
|
||
@override
|
||
State<LevelStartPage> createState() => _LevelStartPageState();
|
||
}
|
||
|
||
class _LevelStartPageState extends State<LevelStartPage>
|
||
with SingleTickerProviderStateMixin {
|
||
String _title = '';
|
||
final List<Question> _questions = [];
|
||
final List<Lexicon> _answered = [];
|
||
|
||
int _userLevelId = 0;
|
||
int _finishLexiconCount = 0;
|
||
int _lexiconCount = 0;
|
||
|
||
bool _loading = true;
|
||
String _selectAnswer = '';
|
||
String _correctAnswer = '';
|
||
|
||
/// 答题后到下一题的延迟(Vue intervalTime = 4000)
|
||
static const Duration _interval = Duration(milliseconds: 4000);
|
||
|
||
/// 轮询最多 30 次 ×200ms ≈ 6s
|
||
static const int _maxPollAttempts = 30;
|
||
static const Duration _pollInterval = Duration(milliseconds: 200);
|
||
|
||
Timer? _transitionTimer;
|
||
bool _submitting = false;
|
||
|
||
late final AnimationController _flipCtl = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 600),
|
||
);
|
||
|
||
bool get _isPad =>
|
||
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
|
||
|
||
Question? get _current => _questions.isEmpty ? null : _questions.first;
|
||
|
||
bool get _canFlip {
|
||
final q = _current;
|
||
return q != null && q.contextMatch.isNotEmpty;
|
||
}
|
||
|
||
bool get _hasResult => _correctAnswer.isNotEmpty;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_bootstrap();
|
||
}
|
||
|
||
Future<void> _bootstrap() async {
|
||
try {
|
||
final res = await LexiconApi.instance.getLevels(widget.args.chapterId);
|
||
final info = await LexiconApi.instance.levelStart(widget.args.levelId);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
if (res.title.isNotEmpty) _title = res.title;
|
||
_questions.addAll(info.questions);
|
||
_answered.addAll(info.lexicons);
|
||
_finishLexiconCount = info.finishLexiconCount;
|
||
_lexiconCount = info.lexiconCount;
|
||
_userLevelId = info.userLevelId;
|
||
_loading = false;
|
||
});
|
||
} catch (_) {
|
||
if (mounted) {
|
||
setState(() => _loading = false);
|
||
CenterToast.show(context, '页面加载失败,请重试');
|
||
}
|
||
return;
|
||
}
|
||
// Vue:挂载 1s 后自动播英式发音
|
||
_transitionTimer = Timer(const Duration(seconds: 1), () {
|
||
if (mounted) _playVoice(isAmerican: false);
|
||
});
|
||
}
|
||
|
||
// ---------------- 发音 ----------------
|
||
|
||
void _playVoice({required bool isAmerican}) {
|
||
final q = _current;
|
||
if (q == null) return;
|
||
// 对标 Vue:hasActiveAudio 时直接忽略
|
||
if (ReciteAudioManager.instance.hasActive) return;
|
||
ReciteAudioManager.instance.play(
|
||
isAmerican ? q.americanVoiceUrl : q.voiceUrl,
|
||
);
|
||
}
|
||
|
||
// ---------------- 翻转 ----------------
|
||
|
||
void _toggleFlip() {
|
||
if (_flipCtl.isAnimating) return;
|
||
if (_flipCtl.value > 0.5) {
|
||
_flipCtl.reverse();
|
||
} else {
|
||
_flipCtl.forward();
|
||
}
|
||
}
|
||
|
||
// ---------------- 答题 ----------------
|
||
|
||
Future<void> _onSelectAnswer(String answer) async {
|
||
if (_selectAnswer.isNotEmpty || _submitting) return; // 防重复提交
|
||
final q = _current;
|
||
if (q == null) return;
|
||
|
||
setState(() => _selectAnswer = answer);
|
||
ReciteLoading.show(context, '正在提交');
|
||
_submitting = true;
|
||
bool ok = false;
|
||
try {
|
||
ok = await LexiconApi.instance.answerQuestion(
|
||
levelId: widget.args.levelId,
|
||
userLevelId: _userLevelId,
|
||
lexiconId: q.id,
|
||
answer: answer,
|
||
);
|
||
} catch (_) {
|
||
// Vue catch:重置 selectAnswer,允许重新作答
|
||
if (mounted) setState(() => _selectAnswer = '');
|
||
} finally {
|
||
_submitting = false;
|
||
if (mounted) ReciteLoading.hide(context);
|
||
}
|
||
if (!ok || !mounted) return;
|
||
|
||
setState(() {
|
||
_correctAnswer = q.chineseDesc;
|
||
_answered.add(
|
||
Lexicon(
|
||
id: q.id,
|
||
english: q.english,
|
||
phonetic: q.phonetic,
|
||
voiceUrl: q.voiceUrl,
|
||
chineseDesc: q.chineseDesc,
|
||
isCorrect: answer == q.chineseDesc,
|
||
),
|
||
);
|
||
_finishLexiconCount++;
|
||
});
|
||
// 答对后自动翻到语境面(Vue:未翻转且可翻 → 翻)
|
||
if (_canFlip && _flipCtl.value < 0.5) {
|
||
_flipCtl.forward();
|
||
}
|
||
|
||
if (_questions.length <= 1) {
|
||
_scheduleFinish();
|
||
} else {
|
||
_scheduleNextQuestion();
|
||
}
|
||
}
|
||
|
||
/// 非末题:4s 后清状态 → 瞬时翻回正面 → 换下一题 → 自动发音
|
||
void _scheduleNextQuestion() {
|
||
_transitionTimer?.cancel();
|
||
_transitionTimer = Timer(_interval, () {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_correctAnswer = '';
|
||
_selectAnswer = '';
|
||
if (_finishLexiconCount < _lexiconCount) {
|
||
_questions.removeAt(0);
|
||
}
|
||
});
|
||
_flipCtl.value = 0; // 无动画复位(Vue isAnimation=false 那一套)
|
||
_playVoice(isAmerican: false);
|
||
});
|
||
}
|
||
|
||
/// 末题:4s 后轮询完关 → 取小结 → redirect 小结页
|
||
void _scheduleFinish() {
|
||
_transitionTimer?.cancel();
|
||
_transitionTimer = Timer(_interval, () async {
|
||
if (!mounted) return;
|
||
ReciteLoading.show(context, '加载中...');
|
||
final finished = await _pollingIsFinish();
|
||
if (!mounted) return;
|
||
if (!finished) {
|
||
ReciteLoading.hide(context);
|
||
CenterToast.show(context, '闯关结果获取失败,请重试');
|
||
return;
|
||
}
|
||
try {
|
||
final over = await LexiconApi.instance.getLevelOverInfo(_userLevelId);
|
||
if (!mounted) return;
|
||
ReciteLoading.hide(context);
|
||
// 对标 Vue uni.redirectTo(替换当前栈)
|
||
context.pushReplacement(
|
||
'/recite/level-over',
|
||
extra: LevelOverArgs(info: over, chapterId: widget.args.chapterId),
|
||
);
|
||
} catch (_) {
|
||
if (mounted) {
|
||
ReciteLoading.hide(context);
|
||
CenterToast.show(context, '获取闯关结束信息失败');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 轮询关卡是否完成(加超时保护:最多 30 次 ≈ 6s)
|
||
Future<bool> _pollingIsFinish() async {
|
||
for (var i = 0; i < _maxPollAttempts; i++) {
|
||
try {
|
||
if (await LexiconApi.instance.getLevelIsFinish(widget.args.levelId)) {
|
||
return true;
|
||
}
|
||
} catch (_) {
|
||
// 单次失败继续轮询(对标 Vue finally sleep 后继续)
|
||
}
|
||
await Future<void>.delayed(_pollInterval);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_transitionTimer?.cancel();
|
||
_flipCtl.dispose();
|
||
ReciteAudioManager.instance.stopAll(); // 对标 cleanupAllAudio
|
||
super.dispose();
|
||
}
|
||
|
||
// ================= UI =================
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF5F5F5),
|
||
extendBodyBehindAppBar: true,
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.transparent,
|
||
surfaceTintColor: Colors.transparent,
|
||
elevation: 0,
|
||
iconTheme: const IconThemeData(color: Color(0xFF333333)),
|
||
),
|
||
body: Stack(
|
||
children: [
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: 250.h,
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Color(0xFFBAF5D7), Color(0xFFF5F5F5)],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SafeArea(
|
||
child: _loading
|
||
? const Center(
|
||
child: CircularProgressIndicator(color: Color(0xFF1BC26E)),
|
||
)
|
||
: Column(
|
||
children: [
|
||
Padding(
|
||
padding: EdgeInsets.only(top: 4.h),
|
||
child: Text(
|
||
_title,
|
||
style: TextStyle(
|
||
fontSize: 17.sp,
|
||
color: Colors.black,
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
padding: EdgeInsets.fromLTRB(
|
||
_isPad ? 40.w : 20.w,
|
||
10.h,
|
||
_isPad ? 40.w : 20.w,
|
||
20.h,
|
||
),
|
||
child: _current == null
|
||
? SizedBox(
|
||
height: 300.h,
|
||
child: Center(
|
||
child: Text(
|
||
'暂无题目',
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
color: const Color(0xFF999999),
|
||
),
|
||
),
|
||
),
|
||
)
|
||
: Column(
|
||
children: [
|
||
_AnswerProgress(
|
||
finishLexiconCount: _finishLexiconCount,
|
||
lexiconCount: _lexiconCount,
|
||
isPad: _isPad,
|
||
),
|
||
SizedBox(height: 23.h),
|
||
_QuestionCard(
|
||
question: _current!,
|
||
selectAnswer: _selectAnswer,
|
||
correctAnswer: _correctAnswer,
|
||
hasResult: _hasResult,
|
||
flipCtl: _flipCtl,
|
||
canFlip: _canFlip,
|
||
isPad: _isPad,
|
||
onToggleFlip: _toggleFlip,
|
||
onPlayVoice: (v) =>
|
||
_playVoice(isAmerican: v),
|
||
onSelectAnswer: _onSelectAnswer,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// ============== 顶部答题进度(轨道 + 小鳄鱼 + 移动小猴子) ==============
|
||
class _AnswerProgress extends StatelessWidget {
|
||
const _AnswerProgress({
|
||
required this.finishLexiconCount,
|
||
required this.lexiconCount,
|
||
required this.isPad,
|
||
});
|
||
|
||
final int finishLexiconCount;
|
||
final int lexiconCount;
|
||
final bool isPad;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final progress = lexiconCount > 0
|
||
? (finishLexiconCount / lexiconCount).clamp(0.0, 1.0)
|
||
: 0.0;
|
||
final trackHeight = isPad ? 20.h : 16.h;
|
||
final monkeyWidth = isPad ? 31.5.w : 26.5.w;
|
||
|
||
return Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 11.w),
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final trackWidth = constraints.maxWidth;
|
||
final monkeyLeft = trackWidth * progress - monkeyWidth / 2;
|
||
return SizedBox(
|
||
height: trackHeight + 24.h,
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
// 进度 > 10% 时左侧出现小鳄鱼
|
||
if (progress > 0.1)
|
||
Positioned(
|
||
left: -7.5.w,
|
||
top: -5.h,
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/StaticResource/2026-06-03/crocodile1780457600126.png',
|
||
),
|
||
width: isPad ? 57.5.w : 49.w,
|
||
fit: BoxFit.fitWidth,
|
||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||
),
|
||
),
|
||
// 轨道
|
||
Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
top: 8.h,
|
||
child: SizedBox(
|
||
height: trackHeight,
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(trackHeight / 2),
|
||
child: Stack(
|
||
children: [
|
||
Container(color: const Color(0xFFF5F5F5)),
|
||
FractionallySizedBox(
|
||
widthFactor: progress,
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
colors: [
|
||
Color(0xFF1BC26E),
|
||
Color(0xFF7ED8AA),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// 随进度移动的小猴子
|
||
Positioned(
|
||
left: monkeyLeft,
|
||
top: 0,
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/StaticResource/2026-04-02/img_answer_progress.png',
|
||
),
|
||
width: monkeyWidth,
|
||
fit: BoxFit.fitWidth,
|
||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||
),
|
||
),
|
||
// 右侧计数
|
||
Positioned(
|
||
right: 0,
|
||
top: trackHeight + 2.h,
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
'$finishLexiconCount',
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
color: const Color(0xFF1BC26E),
|
||
),
|
||
),
|
||
Text(
|
||
'/$lexiconCount',
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
color: const Color(0xFF999999),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 题目卡(单词 + 音标 + 翻转卡 + 选项)
|
||
class _QuestionCard extends StatelessWidget {
|
||
const _QuestionCard({
|
||
required this.question,
|
||
required this.selectAnswer,
|
||
required this.correctAnswer,
|
||
required this.hasResult,
|
||
required this.flipCtl,
|
||
required this.canFlip,
|
||
required this.isPad,
|
||
required this.onToggleFlip,
|
||
required this.onPlayVoice,
|
||
required this.onSelectAnswer,
|
||
});
|
||
|
||
final Question question;
|
||
final String selectAnswer;
|
||
final String correctAnswer;
|
||
final bool hasResult;
|
||
final AnimationController flipCtl;
|
||
final bool canFlip;
|
||
final bool isPad;
|
||
final VoidCallback onToggleFlip;
|
||
final void Function(bool isAmerican) onPlayVoice;
|
||
final ValueChanged<String> onSelectAnswer;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.symmetric(vertical: 30.h),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(10.r),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
// 英/美 单词与音标
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||
child: Wrap(
|
||
alignment: WrapAlignment.center,
|
||
crossAxisAlignment: WrapCrossAlignment.start,
|
||
spacing: 12.w,
|
||
runSpacing: 12.h,
|
||
children: [
|
||
_WordColumn(
|
||
word: question.english,
|
||
phonetic: question.phonetic,
|
||
voiceUrl: question.voiceUrl,
|
||
tag: '英',
|
||
onPlay: () => onPlayVoice(false),
|
||
),
|
||
if (question.americanPhonetic.isNotEmpty)
|
||
_WordColumn(
|
||
word: question.americanEnglish,
|
||
phonetic: question.americanPhonetic,
|
||
voiceUrl: question.americanVoiceUrl,
|
||
tag: '美',
|
||
onPlay: () => onPlayVoice(true),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(height: 30.h),
|
||
// 翻转卡片(540x360rpx;pad 1080x720rpx)
|
||
_FlipCard(
|
||
question: question,
|
||
flipCtl: flipCtl,
|
||
canFlip: canFlip,
|
||
isPad: isPad,
|
||
onToggleFlip: onToggleFlip,
|
||
),
|
||
SizedBox(height: 20.h),
|
||
// 选项
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 32.5.w),
|
||
child: Column(
|
||
children: [
|
||
for (var i = 0; i < question.options.length; i++) ...[
|
||
if (i > 0) SizedBox(height: 12.h),
|
||
_OptionItem(
|
||
text: question.options[i],
|
||
status: _optionStatus(question.options[i]),
|
||
onTap: () => onSelectAnswer(question.options[i]),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 0 默认 / 1 正确(绿)/ 2 错选(红)
|
||
int _optionStatus(String option) {
|
||
if (!hasResult) return 0;
|
||
if (option == correctAnswer) return 1;
|
||
if (option == selectAnswer) return 2;
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/// 单词 + 音标胶囊(带 4 帧发音按钮)
|
||
class _WordColumn extends StatelessWidget {
|
||
const _WordColumn({
|
||
required this.word,
|
||
required this.phonetic,
|
||
required this.voiceUrl,
|
||
required this.tag,
|
||
required this.onPlay,
|
||
});
|
||
|
||
final String word;
|
||
final String phonetic;
|
||
final String voiceUrl;
|
||
final String tag;
|
||
final VoidCallback onPlay;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
word,
|
||
style: TextStyle(
|
||
fontSize: 28.sp,
|
||
color: const Color(0xFF333333),
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
),
|
||
SizedBox(height: 16.h),
|
||
Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.h),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF8F8F8),
|
||
borderRadius: BorderRadius.circular(999.r),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (phonetic.isNotEmpty) ...[
|
||
Text(
|
||
tag,
|
||
style: TextStyle(
|
||
fontSize: 13.sp,
|
||
color: const Color(0xFF666666),
|
||
),
|
||
),
|
||
SizedBox(width: 4.w),
|
||
Text(
|
||
phonetic,
|
||
style: TextStyle(
|
||
fontSize: 13.sp,
|
||
color: const Color(0xFF999999),
|
||
),
|
||
),
|
||
SizedBox(width: 6.w),
|
||
],
|
||
VoiceAnimButton(url: voiceUrl, size: 27),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 翻转卡片(正面配图 / 背面语境搭配,0.6s Y 轴翻转)
|
||
class _FlipCard extends StatelessWidget {
|
||
const _FlipCard({
|
||
required this.question,
|
||
required this.flipCtl,
|
||
required this.canFlip,
|
||
required this.isPad,
|
||
required this.onToggleFlip,
|
||
});
|
||
|
||
final Question question;
|
||
final AnimationController flipCtl;
|
||
final bool canFlip;
|
||
final bool isPad;
|
||
final VoidCallback onToggleFlip;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final width = isPad ? 540.w : 270.w;
|
||
final height = isPad ? 360.h : 180.h;
|
||
|
||
return AnimatedBuilder(
|
||
animation: flipCtl,
|
||
builder: (context, _) {
|
||
final angle = flipCtl.value * 3.1415926;
|
||
final showBack = angle > 3.1415926 / 2;
|
||
return SizedBox(
|
||
width: width,
|
||
height: height,
|
||
child: Transform(
|
||
alignment: Alignment.center,
|
||
transform: Matrix4.identity()
|
||
..setEntry(3, 2, 0.001)
|
||
..rotateY(angle),
|
||
child: showBack
|
||
? Transform(
|
||
alignment: Alignment.center,
|
||
transform: Matrix4.identity()..rotateY(3.1415926),
|
||
child: _backSide(width, height),
|
||
)
|
||
: _frontSide(width, height),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 「翻转」小胶囊(正反两面右上角都有)
|
||
Widget _flipChip() {
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onToggleFlip,
|
||
child: Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 2.h),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white.withValues(alpha: 0.45),
|
||
borderRadius: BorderRadius.circular(999.r),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
'翻转',
|
||
style: TextStyle(fontSize: 11.sp, color: const Color(0xFF999999)),
|
||
),
|
||
SizedBox(width: 4.w),
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build('/StaticResource/2026-04-02/flip.png'),
|
||
width: 12.w,
|
||
height: 12.w,
|
||
errorWidget: (_, _, _) =>
|
||
Icon(Icons.flip, size: 12.w, color: const Color(0xFF999999)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _frontSide(double width, double height) {
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(10.r),
|
||
child: Stack(
|
||
children: [
|
||
Positioned.fill(
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(question.imageDesc),
|
||
fit: BoxFit.cover,
|
||
placeholder: (_, _) => Container(color: const Color(0xFFF8F8F8)),
|
||
errorWidget: (_, _, _) =>
|
||
Container(color: const Color(0xFFF8F8F8)),
|
||
),
|
||
),
|
||
// 仅 contextMatch 非空正面才显示翻转入口
|
||
if (canFlip) Positioned(top: 14.h, right: 18.w, child: _flipChip()),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _backSide(double width, double height) {
|
||
return Container(
|
||
padding: EdgeInsets.fromLTRB(12.w, 16.h, 12.w, 12.h),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF8F8F8),
|
||
borderRadius: BorderRadius.circular(10.r),
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'语境搭配',
|
||
style: TextStyle(
|
||
fontSize: 16.sp,
|
||
color: const Color(0xFF333333),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: 6.h),
|
||
Container(
|
||
width: 62.w,
|
||
height: 4.h,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(2.r),
|
||
gradient: const LinearGradient(
|
||
colors: [Color(0x661BC26E), Color(0x667ED8AA)],
|
||
),
|
||
),
|
||
),
|
||
SizedBox(height: 14.h),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
child: _HighlightText(
|
||
source: question.contextMatch,
|
||
keyword: question.english,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
Positioned(top: 0, right: 0, child: _flipChip()),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 语境搭配高亮(对标 r-highlight:单词本身绿色)
|
||
class _HighlightText extends StatelessWidget {
|
||
const _HighlightText({required this.source, required this.keyword});
|
||
|
||
final String source;
|
||
final String keyword;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (keyword.isEmpty) {
|
||
return Text(source, style: _baseStyle);
|
||
}
|
||
final spans = <TextSpan>[];
|
||
final pattern = RegExp(RegExp.escape(keyword), caseSensitive: false);
|
||
var lastEnd = 0;
|
||
for (final m in pattern.allMatches(source)) {
|
||
if (m.start > lastEnd) {
|
||
spans.add(TextSpan(text: source.substring(lastEnd, m.start)));
|
||
}
|
||
spans.add(
|
||
TextSpan(
|
||
text: source.substring(m.start, m.end),
|
||
style: const TextStyle(
|
||
color: Color(0xFF1BC26E),
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
lastEnd = m.end;
|
||
}
|
||
if (lastEnd < source.length) {
|
||
spans.add(TextSpan(text: source.substring(lastEnd)));
|
||
}
|
||
return Text.rich(
|
||
TextSpan(children: spans, style: _baseStyle),
|
||
style: const TextStyle(height: 1.5),
|
||
);
|
||
}
|
||
|
||
TextStyle get _baseStyle =>
|
||
TextStyle(fontSize: 14.sp, color: const Color(0xFF333333), height: 1.5);
|
||
}
|
||
|
||
/// 选项胶囊(默认灰 / 正确绿 / 错选红 + 角标)
|
||
class _OptionItem extends StatelessWidget {
|
||
const _OptionItem({
|
||
required this.text,
|
||
required this.status,
|
||
required this.onTap,
|
||
});
|
||
|
||
final String text;
|
||
|
||
/// 0 默认 / 1 正确 / 2 错选
|
||
final int status;
|
||
final VoidCallback onTap;
|
||
|
||
static const Color _green = Color(0xFF1BC26E);
|
||
static const Color _red = Color(0xFFF56245);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final bg = switch (status) {
|
||
1 => _green,
|
||
2 => _red,
|
||
_ => const Color(0xFFF5F5F5),
|
||
};
|
||
final fg = status == 0 ? const Color(0xFF333333) : Colors.white;
|
||
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onTap,
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.symmetric(horizontal: 30.w, vertical: 14.h),
|
||
decoration: BoxDecoration(
|
||
color: bg,
|
||
borderRadius: BorderRadius.circular(999.r),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(fontSize: 15.sp, color: fg, height: 1.3),
|
||
),
|
||
),
|
||
if (status != 0)
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
status == 1
|
||
? '/StaticResource/2026-04-02/checkSmall.png'
|
||
: '/StaticResource/2026-04-02/closeSmall.png',
|
||
),
|
||
width: 16.w,
|
||
height: 16.w,
|
||
errorWidget: (_, _, _) => Icon(
|
||
status == 1 ? Icons.check_circle : Icons.cancel,
|
||
size: 16.w,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|