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