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
+453
View File
@@ -0,0 +1,453 @@
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/widgets/recite_circle_progress.dart';
import 'package:new_learn/features/recite/widgets/recite_progress.dart';
/// ============================================================
/// 【ChaptersPage】词汇章节 —— 对标 Vue pages/recitePackage/lexicon/lexicon.vue
///
/// 入参 bookId(路由 path 参数,对标 Vue onLoad options.bookId,默认 1)。
/// 数据:getChapters(bookId),失败 toast「页面加载失败,请重试」。
/// 点章节 → /recite/levels(extra 传整个 Chapter,替代 eventChannel);
/// 从关卡链路返回后重新拉取(对标 Vue onShow 刷新)。
/// ============================================================
class ChaptersPage extends StatefulWidget {
const ChaptersPage({super.key, required this.bookId});
final int bookId;
@override
State<ChaptersPage> createState() => _ChaptersPageState();
}
class _ChaptersPageState extends State<ChaptersPage> {
final List<Chapter> _list = [];
double _rate = 0;
bool _loading = false;
bool get _isPad =>
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
@override
void initState() {
super.initState();
_getList();
}
Future<void> _getList() async {
if (mounted) setState(() => _loading = true);
try {
final res = await LexiconApi.instance.getChapters(widget.bookId);
if (!mounted) return;
setState(() {
_rate = res.totalFinishProgress * 100;
_list
..clear()
..addAll(res.chapters);
});
} catch (_) {
if (mounted) {
CenterToast.show(
context,
'页面加载失败,请重试',
duration: const Duration(milliseconds: 2000),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _onClickCell(Chapter item) async {
await context.push('/recite/levels', extra: item);
if (mounted) _getList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
),
body: Stack(
children: [
// 顶部背景大图
Positioned(
top: 0,
left: 0,
right: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-01/img_index_bgtop.png',
),
fit: BoxFit.fitWidth,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
// 白色内容卡(mp margin-top 19%,pad 30%)
Positioned.fill(
top: 0,
child: SafeArea(
child: Column(
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final topGap =
constraints.maxHeight * (_isPad ? 0.30 : 0.19);
return Padding(
// Vue: -8rpx 让卡片向两侧溢出
padding: EdgeInsets.only(top: topGap),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(_isPad ? 30.r : 24.r),
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
_buildContent(),
// 鳄鱼装饰(top:-35rpx left:30rpx;pad 加大)
Positioned(
top: -17.5.h,
left: _isPad ? 30.w : 15.w,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-01/img_index_crocodile.png',
),
width: _isPad ? 160.w : 109.w,
fit: BoxFit.fitWidth,
placeholder: (_, _) =>
const SizedBox.shrink(),
errorWidget: (_, _, _) =>
const SizedBox.shrink(),
),
),
// 总进度圆环(top:-35rpx right:50rpx)
Positioned(
top: -17.5.h,
right: _isPad ? 50.w : 25.w,
child: _CircleTotalProgress(rate: _rate),
),
],
),
),
);
},
),
),
],
),
),
),
],
),
);
}
Widget _buildContent() {
return Padding(
// Vue chapter-list: 40rpx 横向,50rpx 顶
padding: EdgeInsets.fromLTRB(20.w, 25.h, 20.w, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(),
Expanded(
child: _loading
? Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 16.w,
height: 16.w,
child: const CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF1BC26E),
),
),
SizedBox(width: 8.w),
Text(
'努力加载中...',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF999999),
),
),
],
),
)
: ListView.builder(
padding: EdgeInsets.only(top: 20.h),
itemCount: _list.length,
itemBuilder: (_, i) =>
_ChapterItem(item: _list[i], onTap: _onClickCell),
),
),
],
),
);
}
/// 词汇章节标题 + 短渐变线
Widget _buildTitle() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'词汇章节',
style: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF333333),
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 2.h),
Container(
width: 62.w,
height: 4.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2.r),
gradient: const LinearGradient(
colors: [Color(0x661BC26E), Color(0x667ED8AA)],
),
),
),
],
);
}
}
/// 总进度圆环(白色衬底圆 + 绿色环 + 总进度文案)
class _CircleTotalProgress extends StatelessWidget {
const _CircleTotalProgress({required this.rate});
final double rate;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 67.5.w,
height: 67.5.w,
child: Stack(
alignment: Alignment.center,
children: [
// 白圆衬底(Vue 135rpx,top/left -8rpx)
Container(
width: 67.5.w,
height: 67.5.w,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
),
ReciteCircleProgress(
percent: rate,
size: 60,
strokeWidth: 4,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${rate.toInt()}',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w600,
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: 2.h),
Text(
'总进度',
style: TextStyle(fontSize: 10.sp, color: const Color(0xFF999999)),
),
],
);
}
}
/// 章节列表项(左封面叠字 + 右侧渐变信息卡)
class _ChapterItem extends StatelessWidget {
const _ChapterItem({required this.item, required this.onTap});
final Chapter item;
final ValueChanged<Chapter> onTap;
@override
Widget build(BuildContext context) {
final percent = (item.finishProgress * 100).toInt();
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(item),
child: Container(
height: 100.h,
margin: EdgeInsets.symmetric(vertical: 10.h),
child: Stack(
children: [
// 渐变信息卡(左留 96 给封面;高度 90 贴底)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 90.h,
child: Container(
padding: EdgeInsets.fromLTRB(96.w, 7.5.h, 14.w, 10.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.r),
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFFE1FAED), Colors.white],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
color: const Color(0xFF333333),
),
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
'已完成',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
SizedBox(width: 3.w),
Text(
'$percent%',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF1BC26E),
),
),
],
),
Row(
children: [
Text(
'${item.finishLexiconCount}',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF1BC26E),
),
),
Text(
'/${item.lexiconCount}',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
],
),
],
),
SizedBox(height: 6.h),
SegmentedProgressBar(progress: item.finishProgress * 100),
],
),
),
),
// 左侧封面(height 100,叠白色 “index title”)
Positioned(
left: 0,
top: 0,
bottom: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(item.cover),
fit: BoxFit.fitHeight,
placeholder: (_, _) => SizedBox(width: 96.w),
errorWidget: (_, _, _) => SizedBox(width: 96.w),
imageBuilder: (_, provider) => Container(
foregroundDecoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.35),
Colors.transparent,
],
stops: const [0, 0.45],
),
),
child: Image(image: provider, fit: BoxFit.fitHeight),
),
),
),
Positioned(
left: 12.5.w,
top: 9.h,
right: 4.w,
child: Text(
item.index.isEmpty ? item.title : '${item.index} ${item.title}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11.sp, color: Colors.white),
),
),
],
),
),
);
}
}