feat(recite): 完成背诵模块基础框架与核心页面开发
本次提交实现了背诵模块的完整基础链路: 1. 新增词书、背单词接口层与对应数据模型 2. 完成背诵首页、章节列表页等核心页面开发 3. 实现音频播放、加载弹窗等通用组件 4. 配置路由跳转链路,替换旧版缓存传参方案 5. 对齐Vue端业务逻辑与UI样式
This commit is contained in:
@@ -0,0 +1,912 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user