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
+510
View File
@@ -0,0 +1,510 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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/core/utils/screen_adapt_template.dart';
import 'package:new_learn/features/recite/widgets/recite_loading.dart';
import 'package:new_learn/features/recite/widgets/recite_progress.dart';
/// ============================================================
/// 【TextbookListPage】教材列表 —— 对标 Vue pages/recitePackage/textbookList
///
/// 一、数据:
/// 1. getCurrentLexiconBooks 顶部「当前学习」卡(静默失败)
/// 2. getAllLexiconBooks 我的书库列表(失败 toast「页面加载失败,请重试」)
///
/// 二、切换教材 handleStudy(容错逻辑保留):
/// showLoading「切换中...」(mask) → POST startLearn → 成功刷新两列表
/// → finally hideLoading。
///
/// 三、NavBar:随滚动 0→25px 从透明渐变到纯白(对标 updateNavBarBgColor)。
/// ============================================================
class TextbookListPage extends StatefulWidget {
const TextbookListPage({super.key});
@override
State<TextbookListPage> createState() => _TextbookListPageState();
}
class _TextbookListPageState extends State<TextbookListPage> {
BookInfo? _currBook;
List<BookInfo> _bookList = const [];
/// AppBar 白色不透明度 0~1
double _navBarOpacity = 0;
bool get _isPad =>
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
@override
void initState() {
super.initState();
_getCurrBook();
_getList();
}
Future<void> _getCurrBook() async {
try {
final res = await LexiconBookApi.instance.getCurrentBook();
if (mounted) setState(() => _currBook = res);
} catch (_) {
// Vue:静默 catch
}
}
Future<void> _getList() async {
try {
final res = await LexiconBookApi.instance.getAllBooks();
if (mounted) setState(() => _bookList = res);
} catch (_) {
if (mounted) {
CenterToast.show(
context,
'页面加载失败,请重试',
duration: const Duration(milliseconds: 2000),
);
}
}
}
/// 切换教材(对标 handleStudy)
Future<void> _handleStudy(BookInfo item) async {
ReciteLoading.show(context, '切换中...');
try {
final ok = await LexiconBookApi.instance.startLearn(item.id);
if (ok) {
await Future.wait([_getList(), _getCurrBook()]);
}
} catch (_) {
// DioClient 已统一记录错误;此处保持 loading 必定关闭即可
} finally {
if (mounted) ReciteLoading.hide(context);
}
}
/// 滚动联动 NavBar 底色(0~25px 线性渐变)
void _onScroll(double offset) {
final opacity = offset <= 0
? 0.0
: offset >= 25
? 1.0
: (offset / 25).clamp(0, 1).toDouble();
if (opacity != _navBarOpacity && mounted) {
setState(() => _navBarOpacity = opacity);
}
}
@override
Widget build(BuildContext context) {
final hPadding = _isPad ? 40.w : 12.5.w;
return Scaffold(
backgroundColor: const Color(0xFFF5F8FA),
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: _navBarOpacity),
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: true,
title: Text(
'教材列表',
style: TextStyle(
fontSize: 17.sp,
color: const Color(0xFF1D2B47),
fontWeight: FontWeight.w600,
),
),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
),
body: NotificationListener<ScrollNotification>(
onNotification: (n) {
_onScroll(n.metrics.pixels);
return false;
},
child: SingleChildScrollView(
child: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部背景图卡片
// —— 与 Vue bg-img 对齐:背景图 width:100%, height:auto, position:top
// "当前学习" + "助力英语..." 同一行两端对齐,书卡叠在图上
Container(
width: double.infinity,
margin: EdgeInsets.fromLTRB(hPadding, 20.h, hPadding, 0),
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build(
_isPad
? '/StaticResource/2026-06-03/textbookList-top-bg-pad1780469931684.png'
: '/StaticResource/2026-04-09/textbookList-top-bg.png',
),
),
alignment: Alignment.topCenter,
fit: BoxFit.fitWidth,
),
borderRadius: BorderRadius.circular(16.r),
),
child: Padding(
padding: EdgeInsets.fromLTRB(16.w, 17.h, 16.w, 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部标题行:左"当前学习" + 右"助力英语..."
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_SectionTitle(title: '当前学习'),
const Spacer(),
Padding(
padding: EdgeInsets.only(bottom: 6.h),
child: Text(
'助力英语全方位提升!',
style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF687FAD),
),
),
),
],
),
if (_currBook != null) ...[
SizedBox(height: 20.h),
_CurrentBook(book: _currBook!),
],
],
),
),
),
// 我的书库
Padding(
padding: EdgeInsets.fromLTRB(hPadding, 12.h, hPadding, 24.h),
child: Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(16.w, 19.h, 16.w, 0.5.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(title: '我的书库'),
SizedBox(height: 19.h),
..._bookList.map(
(book) =>
_BookItem(book: book, onStudy: _handleStudy),
),
],
),
),
),
],
),
),
),
),
);
}
}
/// 标题 + 底部渐变短线(study-title / library-title)
///
/// 用 IntrinsicWidth 包裹:组件宽度 shrink-wrap 到标题文字宽度,
/// 渐变线 width:infinity 即等于文字宽度——1:1 对应 Vue 里
/// .text-line(absolute, width:100%)+ relative 父容器(shrink-wrap)。
/// 同时保证本组件放进 Row(无界水平约束)时不会因 infinity 宽度布局崩溃。
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
SizedBox(height: 3.h),
Container(
height: 4.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.r),
gradient: const LinearGradient(
colors: [
Color(0x003D7DF5),
Color(0x331D9CFF),
Color(0x808AECFD),
],
),
),
),
],
),
);
}
}
/// 顶部当前学习书卡
class _CurrentBook extends StatelessWidget {
const _CurrentBook({required this.book});
final BookInfo book;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(book.cover),
width: 95.w,
fit: BoxFit.fitWidth,
placeholder: (_, _) => SizedBox(width: 95.w, height: 112.h),
errorWidget: (_, _, _) => SizedBox(width: 95.w, height: 112.h),
),
SizedBox(width: 17.5.w),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: 2.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: 29.h - 2.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
'进度',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
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()),
],
),
),
),
],
);
}
}
/// 书库列表项(封面浮在左侧渐变内容卡上 + 状态角标 + 学习按钮)
class _BookItem extends StatelessWidget {
const _BookItem({required this.book, required this.onStudy});
final BookInfo book;
final ValueChanged<BookInfo> onStudy;
@override
Widget build(BuildContext context) {
final completed =
book.lexiconCount >= book.totalCount && book.totalCount > 0;
final notStarted = book.lexiconCount == 0;
final buttonText = notStarted
? '学习此书'
: completed
? '已学完'
: '继续学习';
return Container(
height: 100.h,
margin: EdgeInsets.only(bottom: 25.h),
child: Stack(
children: [
// 渐变内容底卡(左留 97 给封面)
Positioned.fill(
child: Container(
padding: EdgeInsets.fromLTRB(97.w, 13.h, 10.w, 10.h),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFFEDF7FF), Colors.white],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
SizedBox(height: 13.5.h),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'进度${book.lexiconCount}词/${book.totalCount}词',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
SizedBox(height: 8.h),
GradientProgressBar(
percent: book.progressPercent.toDouble(),
),
],
),
),
SizedBox(width: 30.w),
_StudyButton(
text: buttonText,
disabled: completed,
onTap: () => onStudy(book),
),
],
),
],
),
),
),
// 左侧浮起封面(170rpx x 200rpx)
Positioned(
left: 0,
top: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(book.cover),
width: 85.w,
height: 100.h,
fit: BoxFit.fill,
placeholder: (_, _) => SizedBox(width: 85.w, height: 100.h),
errorWidget: (_, _, _) => SizedBox(width: 85.w, height: 100.h),
),
),
// 右上角状态标签
if (book.isStudying || completed)
Positioned(
top: 0,
right: 0,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8.5.w, vertical: 2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topRight: Radius.circular(5.r),
bottomLeft: Radius.circular(5.r),
),
gradient: LinearGradient(
colors: book.isStudying
? const [Color(0xFF7ED8AA), Color(0xFF1BC26E)]
: const [Color(0xFFF5963D), Color(0xFFFFD769)],
),
),
child: Text(
book.isStudying ? '学习中' : '已学完',
style: TextStyle(fontSize: 10.sp, color: Colors.white),
),
),
),
],
),
);
}
}
/// 「学习此书 / 继续学习 / 已学完」胶囊按钮
class _StudyButton extends StatelessWidget {
const _StudyButton({
required this.text,
required this.disabled,
required this.onTap,
});
final String text;
final bool disabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final color = disabled ? const Color(0xFFCCCCCC) : const Color(0xFF3D7DF5);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: disabled ? null : onTap,
child: Container(
width: 75.w,
height: 30.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(999.r),
),
child: Text(
text,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
decoration: TextDecoration.none,
),
),
),
);
}
}