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