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