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