本次提交实现了背诵模块的完整基础链路: 1. 新增词书、背单词接口层与对应数据模型 2. 完成背诵首页、章节列表页等核心页面开发 3. 实现音频播放、加载弹窗等通用组件 4. 配置路由跳转链路,替换旧版缓存传参方案 5. 对齐Vue端业务逻辑与UI样式
507 lines
16 KiB
Dart
507 lines
16 KiB
Dart
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),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|