feat(recite): 完成背诵模块基础框架与核心页面开发
本次提交实现了背诵模块的完整基础链路: 1. 新增词书、背单词接口层与对应数据模型 2. 完成背诵首页、章节列表页等核心页面开发 3. 实现音频播放、加载弹窗等通用组件 4. 配置路由跳转链路,替换旧版缓存传参方案 5. 对齐Vue端业务逻辑与UI样式
This commit is contained in:
@@ -1,61 +1,417 @@
|
||||
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 页 —— 空页面占位
|
||||
/// 【RecitePage】「背诵」tab 首页 —— 对标 Vue pages/tabbar/recite/recite.vue
|
||||
///
|
||||
/// 后续开发计划:这里放背单词的卡片流(正面/背面翻转、记忆曲线等)。
|
||||
/// 现在先占位,保证 4 个 tab 都能正常切换。
|
||||
/// 一、数据(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 StatelessWidget {
|
||||
class RecitePage extends StatefulWidget {
|
||||
const RecitePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const _Placeholder(
|
||||
icon: Icons.auto_stories,
|
||||
title: '背诵',
|
||||
subtitle: '功能开发中,敬请期待',
|
||||
);
|
||||
}
|
||||
State<RecitePage> createState() => _RecitePageState();
|
||||
}
|
||||
|
||||
/// 通用占位组件:一个居中图标 + 标题 + 副标题
|
||||
class _Placeholder extends StatelessWidget {
|
||||
const _Placeholder({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
});
|
||||
class _RecitePageState extends State<RecitePage> {
|
||||
BookInfo? _currBook;
|
||||
int _learnDayCount = 0;
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
@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(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 64.w, color: const Color(0xFFC9CFD8)),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF666666),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
),
|
||||
// 主体内容
|
||||
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(
|
||||
subtitle,
|
||||
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)),
|
||||
'$_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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user