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
@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:just_audio/just_audio.dart';
import 'package:new_learn/core/logger/app_talker.dart';
/// ============================================================
/// 【ReciteAudioManager】背诵模块单例音频管理
/// —— 对标 Vue utils/audioManager
///
/// 规则(与 Vue 完全一致):
/// 1. 全局同时只播放一个音频:新 play 会先 stop 旧的;
/// 2. hasActive 为 true 时页面层直接忽略点击;
/// 3. 播放结束/失败/取消都清空 playingToken(驱动 4 帧动画停在第 1 帧);
/// 4. 离开答题/小结页调 stopAll() 释放(对标 cleanupAllAudio)。
/// ============================================================
class ReciteAudioManager {
ReciteAudioManager._() {
_playerSub = _player.playerStateStream.listen((state) {
if (state.processingState == ProcessingState.completed) {
playingToken.value = null;
currentUrl = null;
}
});
}
static final ReciteAudioManager instance = ReciteAudioManager._();
final AudioPlayer _player = AudioPlayer();
StreamSubscription<PlayerState>? _playerSub;
/// 当前播放令牌(自增),null = 无播放;4 帧按钮据此判断是否该动
final ValueNotifier<int?> playingToken = ValueNotifier<int?>(null);
/// 当前播放的音频 URL,null = 无播放
/// —— VoiceAnimButton 用它判断"当前播的是不是我的音频",
/// 比 token 归属更可靠(自动播放 / 手动播放 / 跨组件触发都能联动)。
String? currentUrl;
int _seq = 0;
bool get hasActive => playingToken.value != null;
/// 播放网络音频,返回本次播放令牌(失败返回 null)
Future<int?> play(String url) async {
if (url.isEmpty) return null;
final token = ++_seq;
try {
await _player.stop();
currentUrl = url;
playingToken.value = token;
await _player.setUrl(url);
await _player.play();
return token;
} catch (e) {
talker.error('背诵音频播放失败: $e');
if (playingToken.value == token) {
playingToken.value = null;
currentUrl = null;
}
return null;
}
}
/// 停止并清空播放态(页面 dispose 时必须调)
Future<void> stopAll() async {
await _player.stop();
playingToken.value = null;
currentUrl = null;
}
void dispose() {
_playerSub?.cancel();
_player.dispose();
}
}
@@ -0,0 +1,108 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【ReciteCircleProgress】圆环进度 —— 对标 Vue l-circle(canvas)
///
/// 用在:
/// 1. 章节页右上角「总进度」(顺时针,绿色 #1BC26E,底 #EBEBEB)
/// 2. 学习小结正确率(Vue :clockwise="false",逆时针绘制,视觉对称)
///
/// percent:0~100;中间内容由 child 自定义。
/// ============================================================
class ReciteCircleProgress extends StatelessWidget {
const ReciteCircleProgress({
super.key,
required this.percent,
required this.size,
this.strokeWidth = 4,
this.color = const Color(0xFF1BC26E),
this.trailColor = const Color(0xFFEBEBEB),
this.clockwise = true,
this.child,
});
final double percent;
final double size; // 直径
final double strokeWidth;
final Color color;
final Color trailColor;
/// false = 逆时针起笔(对标 l-circle :clockwise="false")
final bool clockwise;
final Widget? child;
@override
Widget build(BuildContext context) {
return SizedBox(
width: size.w,
height: size.w,
child: CustomPaint(
painter: _CirclePainter(
percent: percent.clamp(0, 100).toDouble(),
strokeWidth: strokeWidth.w,
color: color,
trailColor: trailColor,
clockwise: clockwise,
),
child: Center(child: child),
),
);
}
}
class _CirclePainter extends CustomPainter {
_CirclePainter({
required this.percent,
required this.strokeWidth,
required this.color,
required this.trailColor,
required this.clockwise,
});
final double percent;
final double strokeWidth;
final Color color;
final Color trailColor;
final bool clockwise;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
final trackPaint =
Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..color = trailColor;
final progressPaint =
Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..color = color;
canvas.drawCircle(center, radius, trackPaint);
const start = -math.pi / 2; // 12 点方向
final sweep = 2 * math.pi * percent / 100;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
clockwise ? start : start - sweep,
sweep,
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant _CirclePainter old) =>
old.percent != percent ||
old.strokeWidth != strokeWidth ||
old.color != color ||
old.clockwise != clockwise;
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【ReciteLoading】模态 loading —— 对标 uni.showLoading({mask:true})
///
/// 用于「切换中...」「正在提交」「加载中...」:
/// ReciteLoading.show(context, '切换中...');
/// ...await...
/// if (mounted) ReciteLoading.hide(context);
///
/// 用根 Navigator 弹出,可盖在底部 tabbar 之上;barrier 拦截所有点击。
/// ============================================================
class ReciteLoading {
ReciteLoading._();
static void show(BuildContext context, String text) {
showDialog<void>(
context: context,
barrierDismissible: false,
useRootNavigator: true,
builder: (_) => _ReciteLoadingView(text: text),
);
}
static void hide(BuildContext context) {
final nav = Navigator.of(context, rootNavigator: true);
if (nav.canPop()) nav.pop();
}
}
class _ReciteLoadingView extends StatelessWidget {
const _ReciteLoadingView({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 20.h),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
SizedBox(height: 12.h),
Text(
text,
style: TextStyle(
fontSize: 14.sp,
color: Colors.white,
decoration: TextDecoration.none,
),
),
],
),
),
);
}
}
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 背诵模块进度条组件
///
/// 1. GradientProgressBar —— 对标 Vue r-progress(linear-gradient 填充)
/// 词书卡蓝色渐变 / 关卡绿色渐变 / 未解锁灰色,全靠 colors 切换。
///
/// 2. SegmentedProgressBar —— 对标 Vue SegmentedProgressBar.vue
/// 三段独立进度,每段各自按 33/33/34 比例填充,绿色渐变。
/// ============================================================
/// 渐变线性进度条(percent:0~100)
class GradientProgressBar extends StatelessWidget {
const GradientProgressBar({
super.key,
required this.percent,
this.height = 6,
this.colors = const [Color(0xFF84AFFF), Color(0xFF3C7CF6)],
this.trackColor = const Color(0xFFF5F5F5),
this.radius,
});
/// 0~100(自动 clamp)
final double percent;
final double height;
/// 填充渐变(单色时传同色两个即可)
final List<Color> colors;
final Color trackColor;
final double? radius;
@override
Widget build(BuildContext context) {
final safe = percent.clamp(0, 100).toDouble();
return ClipRRect(
borderRadius: BorderRadius.circular(radius ?? (height / 2).r),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return Container(
height: height.w,
color: trackColor,
child: Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: width * safe / 100,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: colors,
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
),
),
),
);
},
),
);
}
}
/// 三段式分段进度条(对标 SegmentedProgressBar.vue;progress:0~100)
class SegmentedProgressBar extends StatelessWidget {
const SegmentedProgressBar({super.key, required this.progress});
final double progress;
static const Color _green1 = Color(0xFF1BC26E);
static const Color _green2 = Color(0xFF7ED8AA);
/// 与 Vue updateBarProgress 完全一致的分段算法
List<double> _segmentPercents(double p) {
double bar1;
if (p >= 33) {
bar1 = 100;
} else {
bar1 = (p / 33 * 100).clamp(0, 100).toDouble();
}
double bar2;
if (p >= 66) {
bar2 = 100;
} else if (p > 33) {
bar2 = ((p - 33) / 33 * 100).clamp(0, 100).toDouble();
} else {
bar2 = 0;
}
double bar3;
if (p >= 100) {
bar3 = 100;
} else if (p > 66) {
bar3 = ((p - 66) / 34 * 100).clamp(0, 100).toDouble();
} else {
bar3 = 0;
}
return [bar1, bar2, bar3];
}
@override
Widget build(BuildContext context) {
final segs = _segmentPercents(progress);
return Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) SizedBox(width: 5.w),
Expanded(
child: GradientProgressBar(
percent: segs[i],
height: 6,
colors: const [_green1, _green2],
),
),
],
],
);
}
}
@@ -0,0 +1,135 @@
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:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/recite/widgets/recite_audio.dart';
/// ============================================================
/// 【VoiceAnimButton】发音 4 帧动画按钮
/// —— 1:1 对标 Vue components/FrameAnimation(:fps="5" :loop="true")
///
/// 帧图内容(绿色从无到有递进):
/// _frames[0] = icon_voice_2.png 全灰(0% 绿)
/// _frames[1] = icon_voice_3.png 仅小圆点绿(~15%)
/// _frames[2] = icon_voice_4.png 圆点+内弧绿(~55%)
/// _frames[3] = icon_voice_1.png 全绿(100%)—— 静止帧
///
/// 行为:
/// - 未播放时显示全绿静止帧(icon_voice_1.png);
/// - 只要 ReciteAudioManager 正在播的是当前按钮的 url,就播动画
/// (自动播放 / 手动点击 / 跨组件触发都能联动,不依赖谁调用了 play);
/// - 动画从全灰帧起步,5fps(200ms/帧)循环:
/// 灰 → 少绿 → 中绿 → 全绿 → 灰 → ...
/// - 点击时若无播放,则触发播放;已有播放则忽略;
/// - 播放结束 / 失败 / 被顶掉 → 回到全绿静止帧。
/// ============================================================
class VoiceAnimButton extends StatefulWidget {
const VoiceAnimButton({super.key, required this.url, this.size = 27});
/// 网络音频地址(空串时点击无效)
final String url;
/// 按钮边长(答题页 27px、小结页 18px)
final double size;
@override
State<VoiceAnimButton> createState() => _VoiceAnimButtonState();
}
class _VoiceAnimButtonState extends State<VoiceAnimButton> {
/// 4 帧序列,按"灰→少绿→中绿→全绿"递增排列
static final List<String> _frames = [
ImgUrl.build('/StaticResource/2026-04-02/icon_voice_2.png'), // 全灰
ImgUrl.build('/StaticResource/2026-04-02/icon_voice_3.png'), // 仅圆点绿
ImgUrl.build('/StaticResource/2026-04-02/icon_voice_4.png'), // 圆点+内弧绿
ImgUrl.build('/StaticResource/2026-04-02/icon_voice_1.png'), // 全绿
];
/// 静止帧索引(全绿 = icon_voice_1.png)
static const int _restIndex = 3;
/// 5fps = 200ms 切一帧
static const Duration _frameInterval = Duration(milliseconds: 200);
Timer? _timer;
int _frameIndex = _restIndex;
@override
void initState() {
super.initState();
// 全局播放 token 变化 → 同步本按钮动画启停
// (用 token 作为"播放状态变化"的信号,用 currentUrl 判断是不是我的音频)
ReciteAudioManager.instance.playingToken.addListener(_onPlayingChanged);
// 初始化时如果正好在播我的 url,直接启动画
if (_isMinePlaying) _startAnim();
}
/// 当前播的是不是我的音频
bool get _isMinePlaying {
final mgr = ReciteAudioManager.instance;
return mgr.playingToken.value != null &&
mgr.currentUrl == widget.url &&
widget.url.isNotEmpty;
}
void _onPlayingChanged() {
if (_isMinePlaying) {
_startAnim();
} else {
_stopAnim();
}
}
void _startAnim() {
if (_timer?.isActive ?? false) return;
_frameIndex = 0; // 全灰起步
if (mounted) setState(() {});
_timer = Timer.periodic(_frameInterval, (_) {
if (!mounted) return;
setState(() => _frameIndex = (_frameIndex + 1) % 4);
});
}
void _stopAnim() {
if (_timer == null) return;
_timer?.cancel();
_timer = null;
_frameIndex = _restIndex;
if (mounted) setState(() {});
}
Future<void> _onTap() async {
// 对标 Vue:if (audioManager.hasActiveAudio()) return;
if (ReciteAudioManager.instance.hasActive) return;
if (widget.url.isEmpty) return;
await ReciteAudioManager.instance.play(widget.url);
// 动画启停由 _onPlayingChanged 统一处理
}
@override
void dispose() {
_timer?.cancel();
ReciteAudioManager.instance.playingToken.removeListener(_onPlayingChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _onTap,
child: CachedNetworkImage(
imageUrl: _frames[_frameIndex],
width: widget.size.w,
height: widget.size.w,
fit: BoxFit.contain,
placeholder: (_, _) =>
SizedBox(width: widget.size.w, height: widget.size.w),
errorWidget: (_, _, _) =>
SizedBox(width: widget.size.w, height: widget.size.w),
),
);
}
}