feat(recite): 完成背诵模块基础框架与核心页面开发

本次提交实现了背诵模块的完整基础链路:
1. 新增词书、背单词接口层与对应数据模型
2. 完成背诵首页、章节列表页等核心页面开发
3. 实现音频播放、加载弹窗等通用组件
4. 配置路由跳转链路,替换旧版缓存传参方案
5. 对齐Vue端业务逻辑与UI样式
This commit is contained in:
崔宝新
2026-09-11 14:38:47 +08:00
parent c65df455f2
commit 27eba92315
17 changed files with 4367 additions and 36 deletions
+506
View File
@@ -0,0 +1,506 @@
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),
),
),
);
}
}