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

507 lines
16 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: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<LevelsPage> createState() => _LevelsPageState();
}
class _LevelsPageState extends State<LevelsPage> {
List<Level> _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<void> _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<void> _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<ScrollNotification>(
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),
),
),
);
}
}