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 createState() => _LevelStartPageState(); } class _LevelStartPageState extends State with SingleTickerProviderStateMixin { String _title = ''; final List _questions = []; final List _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 _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 _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 _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.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 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 = []; 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, ), ), ], ), ), ); } }