feat(recite): 完成背诵模块基础框架与核心页面开发

本次提交实现了背诵模块的完整基础链路:
1. 新增词书、背单词接口层与对应数据模型
2. 完成背诵首页、章节列表页等核心页面开发
3. 实现音频播放、加载弹窗等通用组件
4. 配置路由跳转链路,替换旧版缓存传参方案
5. 对齐Vue端业务逻辑与UI样式
This commit is contained in:
崔宝新
2026-09-11 14:38:47 +08:00
parent c65df455f2
commit 27eba92315
17 changed files with 4367 additions and 36 deletions
+912
View File
@@ -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,
),
),
],
),
),
);
}
}