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 createState() => _TextbookListPageState(); } class _TextbookListPageState extends State { BookInfo? _currBook; List _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 _getCurrBook() async { try { final res = await LexiconBookApi.instance.getCurrentBook(); if (mounted) setState(() => _currBook = res); } catch (_) { // Vue:静默 catch } } Future _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 _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( 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 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, ), ), ), ); } }