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

419 lines
14 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_book/api.dart';
import 'package:new_learn/core/api/lexicon_book/model.dart';
import 'package:new_learn/core/components/center_toast.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/recite/widgets/recite_progress.dart';
/// ============================================================
/// 【RecitePage】「背诵」tab 首页 —— 对标 Vue pages/tabbar/recite/recite.vue
///
/// 一、数据(Vue onShow,Flutter 在 initState + push 返回后刷新):
/// 1. getCurrentLexiconBooks → 当前词书(可能为 null,卡片整体隐藏)
/// 2. getLearnDayCount → 坚持学习天数
/// 两个接口均静默失败(Vue hideErrorToast),catch 后保留旧值/默认值。
///
/// 二、跳转:
/// [切换教材] → /recite/textbooks,返回后重新拉数据(书可能已切换)
/// [开始学习] → /recite/chapters/:bookId;无当前书时拦截(Vue 端此处
/// 会读 null.id 崩溃,Flutter 迁移时补判空容错)
///
/// 三、与 Vue 差异说明:
/// Vue 自定义 Tabbar 需手动预留 paddingBottom;Flutter 端底部导航由
/// StatefulShellRoute 壳提供,body 天然在 tabbar 上方,无需该逻辑。
/// ============================================================
class RecitePage extends StatefulWidget {
const RecitePage({super.key});
@override
State<RecitePage> createState() => _RecitePageState();
}
class _RecitePageState extends State<RecitePage> {
BookInfo? _currBook;
int _learnDayCount = 0;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
// 两个接口互不依赖,并发拉取;均静默失败
final results = await Future.wait<dynamic>([
LexiconBookApi.instance.getCurrentBook().catchError((_) => null),
LexiconBookApi.instance.getLearnDayCount().catchError((_) => 0),
]);
if (!mounted) return;
setState(() {
_currBook = results[0] as BookInfo?;
_learnDayCount = results[1] as int? ?? 0;
});
}
/// 切换教材:返回后刷新(对标 Vue onShow)
Future<void> _changeBook() async {
await context.push('/recite/textbooks');
if (mounted) _load();
}
/// 开始学习:进入章节列表
void _startLearn() {
final book = _currBook;
if (book == null) {
// 容错:Vue 端 currBook 为 null 时点按钮会 null.id 崩溃
CenterToast.show(context, '请先选择教材');
return;
}
context.push('/recite/chapters/${book.id}');
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF0F8FF),
body: Stack(
children: [
// HeaderBac:顶部主题背景图(fit-width 顶部对齐)
Positioned(
top: 0,
left: 0,
right: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-05-26/my-header-bg-default1779775984375.png',
),
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
// 右上角装饰图(mp: top 164rpx / right 24rpx / width 341rpx)
Positioned(
top: 82.h,
right: 12.w,
child: SafeArea(
bottom: false,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-09/recite-top-img.png',
),
width: 170.w,
fit: BoxFit.fitWidth,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
),
// 主体内容
SafeArea(
bottom: false,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(25.w, 88.h, 25.w, 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLearnDay(),
if (_currBook != null) ...[
SizedBox(height: 23.h),
_CurrBookCard(book: _currBook!, onChangeBook: _changeBook),
],
SizedBox(height: 24.h),
_StartButton(onTap: _startLearn),
],
),
),
),
],
),
);
}
/// 坚持学习天数
Widget _buildLearnDay() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$_learnDayCount',
style: TextStyle(
fontSize: 50.sp,
height: 1,
color: const Color(0xFF7DAAFF),
),
),
Padding(
padding: EdgeInsets.only(left: 10.w, bottom: 4.h),
child: Text(
'天',
style: TextStyle(
fontSize: 20.sp,
color: const Color(0xFF66A6FF),
),
),
),
],
),
SizedBox(height: 4.h),
Padding(
padding: EdgeInsets.only(left: 2.w),
child: Text(
'坚持学习天数',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF495A7A)),
),
),
],
);
}
}
/// 当前词书卡片(海报 + slogan + 切换按钮 + 封面 + 进度)
class _CurrBookCard extends StatelessWidget {
const _CurrBookCard({required this.book, required this.onChangeBook});
final BookInfo book;
final VoidCallback onChangeBook;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.r),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
// 海报 + slogan 遮罩 + 切换按钮
Stack(
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(book.poster),
width: double.infinity,
fit: BoxFit.fitWidth,
placeholder: (_, _) => Container(height: 120.h),
errorWidget: (_, _, _) => Container(height: 120.h),
),
Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.4),
),
),
Padding(
padding: EdgeInsets.only(
top: 127.h,
left: 22.w,
right: 6.w,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'“',
style: TextStyle(
fontSize: 32.sp,
color: Colors.white,
),
),
SizedBox(width: 4.w),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: 15.h),
child: Text(
book.slogan,
softWrap: true,
style: TextStyle(
fontSize: 15.sp,
color: Colors.white,
height: 1.4,
),
),
),
),
],
),
),
// 切换教材角标按钮
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChangeBook,
child: Container(
width: 95.w,
height: 30.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(15.r),
),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF8AD1FD),
Color(0xFF3C84FF),
Color(0xFF3D7DF5),
],
stops: [0, 0.64, 1],
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'切换教材',
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
),
),
Icon(
Icons.chevron_right,
size: 14.w,
color: Colors.white,
),
],
),
),
),
),
],
),
// 书籍信息(左侧留出封面位置:277rpx)
Padding(
padding: EdgeInsets.fromLTRB(138.5.w, 20.h, 13.5.w, 37.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.sp,
color: const Color(0xFF333333),
),
),
SizedBox(height: 16.5.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
'进度',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
SizedBox(width: 5.w),
Text(
'${book.progressPercent}%',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF3D7DF5),
),
),
],
),
Row(
children: [
Text(
'${book.lexiconCount}词',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF3D7DF5),
),
),
Text(
' / ${book.totalCount}词',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
],
),
],
),
SizedBox(height: 8.5.h),
GradientProgressBar(
percent: book.progressPercent.toDouble(),
),
],
),
),
],
),
// 左下封面(mp: width 230rpx / bottom 50rpx / left 24rpx)
Positioned(
left: 12.w,
bottom: 25.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(book.cover),
width: 115.w,
fit: BoxFit.fitWidth,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
],
),
);
}
}
/// 「开始学习」大按钮(mp: height 96rpx / 圆角 999)
class _StartButton extends StatelessWidget {
const _StartButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: double.infinity,
height: 48.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.r),
gradient: const LinearGradient(
colors: [Color(0xFF5A9EFF), Color(0xFF3C7CF6)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF3D7DF5).withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
alignment: Alignment.center,
child: Text(
'开始学习',
style: TextStyle(
fontSize: 17.sp,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
);
}
}