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

511 lines
17 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: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,
),
),
),
);
}
}