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/api.dart'; import 'package:new_learn/core/api/lexicon/model.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/recite_args.dart'; import 'package:new_learn/features/recite/widgets/recite_progress.dart'; /// ============================================================ /// 【LevelsPage】关卡列表 —— 对标 Vue pages/recitePackage/level/level.vue /// /// 入参:extra = Chapter(替代 Vue eventChannel 'getChapter')。 /// 数据:getLevels(chapterId),头部汇总 = 各关 finishLexiconCount 累加, /// 总进度 = round(完成数/总词数*100)。 /// 闯关:未解锁拦截;点击 → /recite/level-start(路由参数替代 storage), /// 返回后刷新(对标 Vue onShow)。 /// Vue 端收到 chapter 后 setTimeout 1s 才加载为历史延迟,此处去掉。 /// phone 纵向卡片 / pad 横向卡片双布局。 /// ============================================================ class LevelsPage extends StatefulWidget { const LevelsPage({super.key, required this.chapter}); final Chapter chapter; @override State createState() => _LevelsPageState(); } class _LevelsPageState extends State { List _list = const []; String _title = ''; int _finishLexiconCount = 0; int _lexiconCount = 0; double _navBarOpacity = 0; bool _loading = false; bool get _isPad => MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint; @override void initState() { super.initState(); // eventChannel 传来的章节头部先兜底 _title = widget.chapter.title; _finishLexiconCount = widget.chapter.finishLexiconCount; _lexiconCount = widget.chapter.lexiconCount; _getList(); } Future _getList() async { setState(() => _loading = true); try { final res = await LexiconApi.instance.getLevels(widget.chapter.id); if (!mounted) return; var finished = 0; for (final l in res.levels) { finished += l.finishLexiconCount; } setState(() { if (res.title.isNotEmpty) _title = res.title; _list = res.levels; _lexiconCount = res.lexiconCount; _finishLexiconCount = finished; }); } catch (_) { // Vue 端无 catch(错误经全局拦截器);保留现状,列表维持空态 } finally { if (mounted) setState(() => _loading = false); } } int get _finishProgress => _lexiconCount <= 0 ? 0 : (_finishLexiconCount * 100 / _lexiconCount).round(); Future _onClickLevel(Level item) async { if (!item.isOpen) return; // 对标 Vue:未解锁直接拦截 await context.push( '/recite/level-start', extra: LevelStartArgs(chapterId: widget.chapter.id, levelId: item.id), ); if (mounted) _getList(); } @override Widget build(BuildContext context) { final hPadding = _isPad ? 40.w : 20.w; return Scaffold( backgroundColor: const Color(0xFFF5F5F5), 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(0xFF333333), fontWeight: FontWeight.w600, ), ), iconTheme: const IconThemeData(color: Color(0xFF333333)), ), body: Stack( children: [ // 顶部绿色渐变背景(500rpx) Positioned( top: 0, left: 0, right: 0, height: 250.h, child: Container( decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFFBAF5D7), Color(0xFFF5F5F5)], ), ), ), ), SafeArea( child: NotificationListener( onNotification: (n) { final opacity = n.metrics.pixels <= 0 ? 0.0 : n.metrics.pixels >= 25 ? 1.0 : (n.metrics.pixels / 25).clamp(0, 1).toDouble(); if (opacity != _navBarOpacity) { setState(() => _navBarOpacity = opacity); } return false; }, child: ListView( padding: EdgeInsets.fromLTRB(hPadding, 60.h, hPadding, 24.h), children: [ _buildHeaderCard(), Padding( padding: EdgeInsets.only(top: 20.h), child: Text( '前面关卡完成后,才可以解锁后面关卡哦~', textAlign: TextAlign.center, style: TextStyle( fontSize: 12.sp, color: const Color(0xFF999999), ), ), ), if (_loading && _list.isEmpty) Padding( padding: EdgeInsets.only(top: 16.h), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( width: 16.w, height: 16.w, child: const CircularProgressIndicator( strokeWidth: 2, color: Color(0xFF1BC26E), ), ), SizedBox(width: 8.w), Text( '努力加载中...', style: TextStyle( fontSize: 12.sp, color: const Color(0xFF999999), ), ), ], ), ), for (var i = 0; i < _list.length; i++) _LevelCard( index: i, level: _list[i], isPad: _isPad, onClick: () => _onClickLevel(_list[i]), ), ], ), ), ), ], ), ); } /// 顶部章节大卡(封面叠字 + 标题 + 章节总进度) Widget _buildHeaderCard() { final coverSize = _isPad ? 125.w : 120.w; return Stack( clipBehavior: Clip.none, children: [ Container( width: double.infinity, padding: EdgeInsets.fromLTRB( _isPad ? 142.w : 137.w, 16.h, 16.w, 16.h, ), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(24.r), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ConstrainedBox( constraints: BoxConstraints(minHeight: _isPad ? 60.h : 46.h), child: Align( alignment: Alignment.centerLeft, child: Text( _title, maxLines: 2, style: TextStyle( fontSize: 15.sp, color: const Color(0xFF333333), ), ), ), ), SizedBox(height: 8.h), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Text( '已完成', style: TextStyle( fontSize: 10.sp, color: const Color(0xFF999999), ), ), SizedBox(width: 3.w), Text( '$_finishProgress%', style: TextStyle( fontSize: 12.sp, color: const Color(0xFF1BC26E), ), ), ], ), Row( children: [ Text( '$_finishLexiconCount', style: TextStyle( fontSize: 10.sp, color: const Color(0xFF1BC26E), ), ), Text( '/$_lexiconCount', style: TextStyle( fontSize: 10.sp, color: const Color(0xFF999999), ), ), ], ), ], ), SizedBox(height: 8.h), SegmentedProgressBar(progress: _finishProgress.toDouble()), ], ), ), // 封面(top:-52rpx left:44rpx) Positioned( left: 22.w, top: -26.h, child: SizedBox( height: coverSize, child: CachedNetworkImage( imageUrl: ImgUrl.build(widget.chapter.cover), fit: BoxFit.fitHeight, placeholder: (_, _) => SizedBox(width: 96.w, height: coverSize), errorWidget: (_, _, _) => SizedBox(width: 96.w, height: coverSize), imageBuilder: (_, provider) => Container( foregroundDecoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withValues(alpha: 0.35), Colors.transparent, ], stops: const [0, 0.45], ), ), child: Image(image: provider, fit: BoxFit.fitHeight), ), ), ), ), Positioned( left: 14.w, top: 10.5.h, width: 96.w, child: Text( _title, maxLines: 3, style: TextStyle( fontSize: _isPad ? 14.sp : 13.sp, color: Colors.white, ), ), ), ], ); } } /// 单个关卡卡(phone 纵向 / pad 横向) class _LevelCard extends StatelessWidget { const _LevelCard({ required this.index, required this.level, required this.isPad, required this.onClick, }); final int index; final Level level; final bool isPad; final VoidCallback onClick; @override Widget build(BuildContext context) { final percent = (level.finishProgress * 100).toInt(); final titleRow = Row( children: [ _NumberBadge(open: level.isOpen, index: index), SizedBox(width: 4.w), Expanded( child: Text( level.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 15.sp, color: const Color(0xFF333333)), ), ), ], ); final countRow = Padding( padding: EdgeInsets.symmetric(horizontal: 4.w), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '${level.finishLexiconCount}', style: TextStyle(fontSize: 10.sp, color: const Color(0xFF999999)), ), Text( '${level.lexiconCount}', style: TextStyle(fontSize: 10.sp, color: const Color(0xFF999999)), ), ], ), ); final bar = Padding( padding: EdgeInsets.symmetric(horizontal: 4.w), child: GradientProgressBar( percent: percent.toDouble(), colors: level.isOpen ? const [Color(0xFF1BC26E), Color(0xFF7ED8AA)] : const [Color(0xFFCCCCCC), Color(0xFFCCCCCC)], ), ); final button = _ChallengeButton(open: level.isOpen, onTap: onClick); return Container( margin: EdgeInsets.only(top: 16.h), padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 15.h), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12.r), ), child: isPad ? Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ titleRow, SizedBox(height: 7.h), countRow, SizedBox(height: 6.h), bar, ], ), ), SizedBox(width: 65.w), button, ], ) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded(child: titleRow), button, ], ), SizedBox(height: 7.h), countRow, SizedBox(height: 6.h), bar, ], ), ); } } /// 序号圆标:开放绿色数字 / 未开放灰底锁 class _NumberBadge extends StatelessWidget { const _NumberBadge({required this.open, required this.index}); final bool open; final int index; @override Widget build(BuildContext context) { return Container( width: 18.w, height: 18.w, alignment: Alignment.center, decoration: BoxDecoration( color: open ? const Color(0xFF1BC26E) : const Color(0xFFCCCCCC), borderRadius: BorderRadius.circular(9.r), ), child: open ? Text( '${index + 1}', style: TextStyle(fontSize: 12.sp, color: Colors.white, height: 1), ) : CachedNetworkImage( imageUrl: ImgUrl.build('/StaticResource/2026-04-02/lock.png'), width: 12.w, height: 12.w, fit: BoxFit.contain, errorWidget: (_, _, _) => Icon(Icons.lock, size: 12.w, color: Colors.white), ), ); } } /// 「闯关」按钮(110rpx x 60rpx;未解锁置灰禁用) class _ChallengeButton extends StatelessWidget { const _ChallengeButton({required this.open, required this.onTap}); final bool open; final VoidCallback onTap; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: open ? onTap : null, child: Container( width: 55.w, height: 30.h, alignment: Alignment.center, decoration: BoxDecoration( color: open ? const Color(0xFF1BC26E) : const Color(0xFFCCCCCC), borderRadius: BorderRadius.circular(999.r), boxShadow: open ? [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 0, offset: const Offset(0, 2), ), ] : null, ), child: Text( '闯关', style: TextStyle(fontSize: 13.sp, color: Colors.white), ), ), ); } }