import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:go_router/go_router.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/features/recite/widgets/recite_progress.dart'; /// ============================================================ /// 【RecitePage】「背诵」tab 首页 —— 对标 Vue pages/tabbar/recite/recite.vue /// /// 一、数据(Vue onShow,Flutter 在 initState + push 返回后刷新): /// 1. getCurrentLexiconBooks → 当前词书(可能为 null,卡片整体隐藏) /// 2. getLearnDayCount → 坚持学习天数 /// 两个接口均静默失败(Vue hideErrorToast),catch 后保留旧值/默认值。 /// /// 二、跳转: /// [切换教材] → /recite/textbooks,返回后重新拉数据(书可能已切换) /// [开始学习] → /recite/chapters/:bookId;无当前书时拦截(Vue 端此处 /// 会读 null.id 崩溃,Flutter 迁移时补判空容错) /// /// 三、与 Vue 差异说明: /// Vue 自定义 Tabbar 需手动预留 paddingBottom;Flutter 端底部导航由 /// StatefulShellRoute 壳提供,body 天然在 tabbar 上方,无需该逻辑。 /// ============================================================ class RecitePage extends StatefulWidget { const RecitePage({super.key}); @override State createState() => _RecitePageState(); } class _RecitePageState extends State { BookInfo? _currBook; int _learnDayCount = 0; @override void initState() { super.initState(); _load(); } Future _load() async { // 两个接口互不依赖,并发拉取;均静默失败 final results = await Future.wait([ LexiconBookApi.instance.getCurrentBook().catchError((_) => null), LexiconBookApi.instance.getLearnDayCount().catchError((_) => 0), ]); if (!mounted) return; setState(() { _currBook = results[0] as BookInfo?; _learnDayCount = results[1] as int? ?? 0; }); } /// 切换教材:返回后刷新(对标 Vue onShow) Future _changeBook() async { await context.push('/recite/textbooks'); if (mounted) _load(); } /// 开始学习:进入章节列表 void _startLearn() { final book = _currBook; if (book == null) { // 容错:Vue 端 currBook 为 null 时点按钮会 null.id 崩溃 CenterToast.show(context, '请先选择教材'); return; } context.push('/recite/chapters/${book.id}'); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF0F8FF), body: Stack( children: [ // HeaderBac:顶部主题背景图(fit-width 顶部对齐) Positioned( top: 0, left: 0, right: 0, child: CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-05-26/my-header-bg-default1779775984375.png', ), fit: BoxFit.fitWidth, alignment: Alignment.topCenter, placeholder: (_, _) => const SizedBox.shrink(), errorWidget: (_, _, _) => const SizedBox.shrink(), ), ), // 右上角装饰图(mp: top 164rpx / right 24rpx / width 341rpx) Positioned( top: 82.h, right: 12.w, child: SafeArea( bottom: false, child: CachedNetworkImage( imageUrl: ImgUrl.build( '/StaticResource/2026-04-09/recite-top-img.png', ), width: 170.w, fit: BoxFit.fitWidth, placeholder: (_, _) => const SizedBox.shrink(), errorWidget: (_, _, _) => const SizedBox.shrink(), ), ), ), // 主体内容 SafeArea( bottom: false, child: SingleChildScrollView( padding: EdgeInsets.fromLTRB(25.w, 88.h, 25.w, 24.h), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildLearnDay(), if (_currBook != null) ...[ SizedBox(height: 23.h), _CurrBookCard(book: _currBook!, onChangeBook: _changeBook), ], SizedBox(height: 24.h), _StartButton(onTap: _startLearn), ], ), ), ), ], ), ); } /// 坚持学习天数 Widget _buildLearnDay() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '$_learnDayCount', style: TextStyle( fontSize: 50.sp, height: 1, color: const Color(0xFF7DAAFF), ), ), Padding( padding: EdgeInsets.only(left: 10.w, bottom: 4.h), child: Text( '天', style: TextStyle( fontSize: 20.sp, color: const Color(0xFF66A6FF), ), ), ), ], ), SizedBox(height: 4.h), Padding( padding: EdgeInsets.only(left: 2.w), child: Text( '坚持学习天数', style: TextStyle(fontSize: 14.sp, color: const Color(0xFF495A7A)), ), ), ], ); } } /// 当前词书卡片(海报 + slogan + 切换按钮 + 封面 + 进度) class _CurrBookCard extends StatelessWidget { const _CurrBookCard({required this.book, required this.onChangeBook}); final BookInfo book; final VoidCallback onChangeBook; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8.r), ), clipBehavior: Clip.antiAlias, child: Stack( children: [ Column( mainAxisSize: MainAxisSize.min, children: [ // 海报 + slogan 遮罩 + 切换按钮 Stack( children: [ CachedNetworkImage( imageUrl: ImgUrl.build(book.poster), width: double.infinity, fit: BoxFit.fitWidth, placeholder: (_, _) => Container(height: 120.h), errorWidget: (_, _, _) => Container(height: 120.h), ), Positioned.fill( child: Container( color: Colors.black.withValues(alpha: 0.4), ), ), Padding( padding: EdgeInsets.only( top: 127.h, left: 22.w, right: 6.w, ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '“', style: TextStyle( fontSize: 32.sp, color: Colors.white, ), ), SizedBox(width: 4.w), Expanded( child: Padding( padding: EdgeInsets.only(top: 15.h), child: Text( book.slogan, softWrap: true, style: TextStyle( fontSize: 15.sp, color: Colors.white, height: 1.4, ), ), ), ), ], ), ), // 切换教材角标按钮 Positioned( right: 0, bottom: 0, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onChangeBook, child: Container( width: 95.w, height: 30.h, decoration: BoxDecoration( borderRadius: BorderRadius.only( topLeft: Radius.circular(15.r), ), gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Color(0xFF8AD1FD), Color(0xFF3C84FF), Color(0xFF3D7DF5), ], stops: [0, 0.64, 1], ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( '切换教材', style: TextStyle( fontSize: 12.sp, color: Colors.white, ), ), Icon( Icons.chevron_right, size: 14.w, color: Colors.white, ), ], ), ), ), ), ], ), // 书籍信息(左侧留出封面位置:277rpx) Padding( padding: EdgeInsets.fromLTRB(138.5.w, 20.h, 13.5.w, 37.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: 16.5.h), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Text( '进度', style: TextStyle( fontSize: 10.sp, color: const Color(0xFF999999), ), ), SizedBox(width: 5.w), 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(), ), ], ), ), ], ), // 左下封面(mp: width 230rpx / bottom 50rpx / left 24rpx) Positioned( left: 12.w, bottom: 25.h, child: CachedNetworkImage( imageUrl: ImgUrl.build(book.cover), width: 115.w, fit: BoxFit.fitWidth, placeholder: (_, _) => const SizedBox.shrink(), errorWidget: (_, _, _) => const SizedBox.shrink(), ), ), ], ), ); } } /// 「开始学习」大按钮(mp: height 96rpx / 圆角 999) class _StartButton extends StatelessWidget { const _StartButton({required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Container( width: double.infinity, height: 48.h, decoration: BoxDecoration( borderRadius: BorderRadius.circular(999.r), gradient: const LinearGradient( colors: [Color(0xFF5A9EFF), Color(0xFF3C7CF6)], ), boxShadow: [ BoxShadow( color: const Color(0xFF3D7DF5).withValues(alpha: 0.3), blurRadius: 12, offset: const Offset(0, 4), ), ], ), alignment: Alignment.center, child: Text( '开始学习', style: TextStyle( fontSize: 17.sp, color: Colors.white, fontWeight: FontWeight.w500, ), ), ), ); } }