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
+512
View File
@@ -0,0 +1,512 @@
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_circle_progress.dart';
import 'package:new_learn/features/recite/widgets/voice_anim_button.dart';
/// ============================================================
/// 【LevelOverPage】学习小结 —— 对标 Vue levelOver.vue
///
/// 入参:extra = LevelOverArgs { info, chapterId }(替代 Vue URL JSON + storage)
/// 章节标题:getLevels(chapterId)(对标 onLoad 里的 getLevels)
/// 下一关:nextLevelId/nextChapterId 都有 → pushReplacement 答题页;
/// 否则 toast「已到最后一关」。
/// ============================================================
class LevelOverPage extends StatefulWidget {
const LevelOverPage({super.key, required this.args});
final LevelOverArgs args;
@override
State<LevelOverPage> createState() => _LevelOverPageState();
}
class _LevelOverPageState extends State<LevelOverPage> {
String _title = '';
LevelOverInfo get _info => widget.args.info;
bool get _isPad =>
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
@override
void initState() {
super.initState();
_loadTitle();
}
@override
void dispose() {
ReciteAudioManager.instance.stopAll();
super.dispose();
}
Future<void> _loadTitle() async {
try {
final res = await LexiconApi.instance.getLevels(widget.args.chapterId);
if (mounted && res.title.isNotEmpty) {
setState(() => _title = res.title);
}
} catch (_) {
// 标题仅展示用,失败静默
}
}
void _onNext() {
if (_info.nextLevelId != 0 && _info.nextChapterId != 0) {
// 对标 Vue uni.redirectTo
context.pushReplacement(
'/recite/level-start',
extra: LevelStartArgs(
chapterId: _info.nextChapterId,
levelId: _info.nextLevelId,
),
);
} else {
CenterToast.show(context, '已到最后一关');
}
}
@override
Widget build(BuildContext context) {
final hPadding = _isPad ? 40.w : 20.w;
return Scaffold(
backgroundColor: const Color(0xFF1BC26E),
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: Text(
'学习小结',
style: TextStyle(fontSize: 17.sp, color: Colors.white),
),
iconTheme: const IconThemeData(color: Colors.white),
),
body: Stack(
children: [
// 背景线纹图(铺满宽度,顶部对齐)
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-03/img_summary_bgline.png',
),
fit: BoxFit.fitWidth,
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
),
SafeArea(
child: Padding(
padding: EdgeInsets.only(top: 56.h),
child: Stack(
clipBehavior: Clip.none,
children: [
// 彩旗(564rpx)
Positioned(
top: 0,
left: 0,
right: 0,
child: Center(
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-03/img_summary_bunting.png',
),
width: 282.w,
fit: BoxFit.fitWidth,
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
),
// 猴子(192rpx)
Positioned(
top: 0,
left: 0,
right: 0,
child: Center(
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-03/img_summary_monkey.png',
),
width: 96.w,
fit: BoxFit.fitWidth,
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
),
// 白色信息卡
Positioned.fill(
top: 54.h,
child: Padding(
padding: EdgeInsets.fromLTRB(
hPadding,
0,
hPadding,
_isPad ? 40.h : 20.h,
),
child: _OverInfoPanel(
info: _info,
title: _title,
onNext: _onNext,
),
),
),
],
),
),
),
],
),
);
}
}
/// 白色信息卡:标题图 + 汇总灰板(正确率/总数/正确数)+ 单词回顾 + 下一关
class _OverInfoPanel extends StatelessWidget {
const _OverInfoPanel({
required this.info,
required this.title,
required this.onNext,
});
final LevelOverInfo info;
final String title;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(20.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 顶部标题图(336rpx x 83rpx,悬浮在卡片上沿)
Transform.translate(
offset: Offset(0, -37.h),
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-03/img_summary_title.png',
),
width: 168.w,
fit: BoxFit.fitWidth,
errorWidget: (_, _, _) => SizedBox(height: 20.h),
),
),
Transform.translate(
offset: Offset(0, -20.h),
child: _SummaryBoard(info: info, title: title),
),
// 单词回顾标题行
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_ReviewTitle(),
Row(
children: [
Text(
'错误:${info.wrongCount}',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFF56245),
),
),
SizedBox(width: 8.w),
Text(
'可在"错题"进行复习',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF999999),
),
),
],
),
],
),
SizedBox(height: 7.h),
// 单词回顾列表(弹性高度)
Flexible(
child: ListView.separated(
padding: EdgeInsets.symmetric(vertical: 7.h),
itemCount: info.lexicons.length,
separatorBuilder: (_, _) => SizedBox(height: 10.h),
itemBuilder: (_, i) => _ReviewItem(item: info.lexicons[i]),
),
),
SizedBox(height: 10.h),
// 下一关
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onNext,
child: Container(
width: double.infinity,
height: 44.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF1BC26E),
borderRadius: BorderRadius.circular(999.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 0,
offset: const Offset(0, 2),
),
],
),
child: Text(
'下一关',
style: TextStyle(fontSize: 15.sp, color: Colors.white),
),
),
),
],
),
);
}
}
/// 汇总灰板:章节标题角标 + 正确率圆环 + 单词总数 + 正确单词
class _SummaryBoard extends StatelessWidget {
const _SummaryBoard({required this.info, required this.title});
final LevelOverInfo info;
final String title;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(20.w, 25.5.h, 20.w, 14.h),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(10.r),
),
child: Stack(
clipBehavior: Clip.none,
children: [
// 悬浮章节标题角标
if (title.isNotEmpty)
Positioned(
top: -34.h / 2 - 8.h,
left: 0,
right: 0,
child: Center(
child: Container(
constraints: BoxConstraints(minWidth: 103.w),
padding: EdgeInsets.symmetric(horizontal: 16.w),
height: 34.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(999.r),
border: Border.all(color: Colors.white, width: 0.5),
),
child: Text(
title,
maxLines: 1,
style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF666666),
),
),
),
),
),
Row(
children: [
// 正确率圆环(Vue :clockwise="false")
Expanded(
child: Center(
child: ReciteCircleProgress(
percent: info.rate.toDouble(),
size: 77,
strokeWidth: 5,
clockwise: false,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${info.rate}',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.bold,
color: const Color(0xFF2E3238),
height: 1,
),
),
Padding(
padding: EdgeInsets.only(bottom: 1.h),
child: Text(
'%',
style: TextStyle(
fontSize: 8.sp,
color: const Color(0xFF2E3238),
height: 1,
),
),
),
],
),
SizedBox(height: 4.h),
Text(
'正确率',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
],
),
),
),
),
Expanded(
child: _Stat(value: '${info.lexiconCount}', label: '单词总数'),
),
Expanded(
child: _Stat(value: '${info.correctCount}', label: '正确单词'),
),
],
),
],
),
);
}
}
/// 绿字数字 + 灰字标签
class _Stat extends StatelessWidget {
const _Stat({required this.value, required this.label});
final String value;
final String label;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: TextStyle(fontSize: 21.sp, color: const Color(0xFF1BC26E)),
),
SizedBox(height: 6.h),
Text(
label,
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
],
);
}
}
/// 「单词回顾」左侧绿色竖条标题
class _ReviewTitle extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 3.w, height: 16.h, color: const Color(0xFF1BC26E)),
SizedBox(width: 8.w),
Text(
'单词回顾',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF333333)),
),
],
);
}
}
/// 回顾行:状态圆点 + 英文/中文 + 发音按钮
class _ReviewItem extends StatelessWidget {
const _ReviewItem({required this.item});
final Lexicon item;
@override
Widget build(BuildContext context) {
final dotColor = item.isCorrect
? const Color(0xFF1BC26E)
: const Color(0xFFF56245);
return Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 9.5.h),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(10.r),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 8.w,
height: 8.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: dotColor, width: 1.5),
),
),
SizedBox(width: 6.w),
Flexible(
child: Text(
item.english,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
color: const Color(0xFF333333),
),
),
),
],
),
SizedBox(height: 4.h),
Text(
item.chineseDesc,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF999999),
),
),
],
),
),
SizedBox(width: 12.w),
VoiceAnimButton(url: item.voiceUrl, size: 18),
],
),
);
}
}