Files
App.K12.Student.Miniapp_Flu…/lib/features/recite/level_start_page.dart
T
崔宝新 27eba92315 feat(recite): 完成背诵模块基础框架与核心页面开发
本次提交实现了背诵模块的完整基础链路:
1. 新增词书、背单词接口层与对应数据模型
2. 完成背诵首页、章节列表页等核心页面开发
3. 实现音频播放、加载弹窗等通用组件
4. 配置路由跳转链路,替换旧版缓存传参方案
5. 对齐Vue端业务逻辑与UI样式
2026-09-11 14:38:47 +08:00

913 lines
28 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.
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,
),
),
],
),
),
);
}
}