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