Files
App.K12.Student.Miniapp_Flu…/lib/features/recite/chapters_page.dart
T
崔宝新 27eba92315 feat(recite): 完成背诵模块基础框架与核心页面开发
本次提交实现了背诵模块的完整基础链路:
1. 新增词书、背单词接口层与对应数据模型
2. 完成背诵首页、章节列表页等核心页面开发
3. 实现音频播放、加载弹窗等通用组件
4. 配置路由跳转链路,替换旧版缓存传参方案
5. 对齐Vue端业务逻辑与UI样式
2026-09-11 14:38:47 +08:00

454 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
),
),
],
),
),
);
}
}