本次提交实现了背诵模块的完整基础链路: 1. 新增词书、背单词接口层与对应数据模型 2. 完成背诵首页、章节列表页等核心页面开发 3. 实现音频播放、加载弹窗等通用组件 4. 配置路由跳转链路,替换旧版缓存传参方案 5. 对齐Vue端业务逻辑与UI样式
513 lines
16 KiB
Dart
513 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/components/center_toast.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_audio.dart';
|
||
import 'package:new_learn/features/recite/widgets/recite_circle_progress.dart';
|
||
import 'package:new_learn/features/recite/widgets/voice_anim_button.dart';
|
||
|
||
/// ============================================================
|
||
/// 【LevelOverPage】学习小结 —— 对标 Vue levelOver.vue
|
||
///
|
||
/// 入参:extra = LevelOverArgs { info, chapterId }(替代 Vue URL JSON + storage)
|
||
/// 章节标题:getLevels(chapterId)(对标 onLoad 里的 getLevels)
|
||
/// 下一关:nextLevelId/nextChapterId 都有 → pushReplacement 答题页;
|
||
/// 否则 toast「已到最后一关」。
|
||
/// ============================================================
|
||
class LevelOverPage extends StatefulWidget {
|
||
const LevelOverPage({super.key, required this.args});
|
||
|
||
final LevelOverArgs args;
|
||
|
||
@override
|
||
State<LevelOverPage> createState() => _LevelOverPageState();
|
||
}
|
||
|
||
class _LevelOverPageState extends State<LevelOverPage> {
|
||
String _title = '';
|
||
|
||
LevelOverInfo get _info => widget.args.info;
|
||
|
||
bool get _isPad =>
|
||
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadTitle();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
ReciteAudioManager.instance.stopAll();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _loadTitle() async {
|
||
try {
|
||
final res = await LexiconApi.instance.getLevels(widget.args.chapterId);
|
||
if (mounted && res.title.isNotEmpty) {
|
||
setState(() => _title = res.title);
|
||
}
|
||
} catch (_) {
|
||
// 标题仅展示用,失败静默
|
||
}
|
||
}
|
||
|
||
void _onNext() {
|
||
if (_info.nextLevelId != 0 && _info.nextChapterId != 0) {
|
||
// 对标 Vue uni.redirectTo
|
||
context.pushReplacement(
|
||
'/recite/level-start',
|
||
extra: LevelStartArgs(
|
||
chapterId: _info.nextChapterId,
|
||
levelId: _info.nextLevelId,
|
||
),
|
||
);
|
||
} else {
|
||
CenterToast.show(context, '已到最后一关');
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final hPadding = _isPad ? 40.w : 20.w;
|
||
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFF1BC26E),
|
||
extendBodyBehindAppBar: true,
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.transparent,
|
||
surfaceTintColor: Colors.transparent,
|
||
elevation: 0,
|
||
centerTitle: true,
|
||
title: Text(
|
||
'学习小结',
|
||
style: TextStyle(fontSize: 17.sp, color: Colors.white),
|
||
),
|
||
iconTheme: const IconThemeData(color: Colors.white),
|
||
),
|
||
body: Stack(
|
||
children: [
|
||
// 背景线纹图(铺满宽度,顶部对齐)
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: SafeArea(
|
||
bottom: false,
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/StaticResource/2026-04-03/img_summary_bgline.png',
|
||
),
|
||
fit: BoxFit.fitWidth,
|
||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||
),
|
||
),
|
||
),
|
||
SafeArea(
|
||
child: Padding(
|
||
padding: EdgeInsets.only(top: 56.h),
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
// 彩旗(564rpx)
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/StaticResource/2026-04-03/img_summary_bunting.png',
|
||
),
|
||
width: 282.w,
|
||
fit: BoxFit.fitWidth,
|
||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||
),
|
||
),
|
||
),
|
||
// 猴子(192rpx)
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/StaticResource/2026-04-03/img_summary_monkey.png',
|
||
),
|
||
width: 96.w,
|
||
fit: BoxFit.fitWidth,
|
||
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||
),
|
||
),
|
||
),
|
||
// 白色信息卡
|
||
Positioned.fill(
|
||
top: 54.h,
|
||
child: Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
hPadding,
|
||
0,
|
||
hPadding,
|
||
_isPad ? 40.h : 20.h,
|
||
),
|
||
child: _OverInfoPanel(
|
||
info: _info,
|
||
title: _title,
|
||
onNext: _onNext,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 白色信息卡:标题图 + 汇总灰板(正确率/总数/正确数)+ 单词回顾 + 下一关
|
||
class _OverInfoPanel extends StatelessWidget {
|
||
const _OverInfoPanel({
|
||
required this.info,
|
||
required this.title,
|
||
required this.onNext,
|
||
});
|
||
|
||
final LevelOverInfo info;
|
||
final String title;
|
||
final VoidCallback onNext;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.all(20.w),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16.r),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// 顶部标题图(336rpx x 83rpx,悬浮在卡片上沿)
|
||
Transform.translate(
|
||
offset: Offset(0, -37.h),
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/StaticResource/2026-04-03/img_summary_title.png',
|
||
),
|
||
width: 168.w,
|
||
fit: BoxFit.fitWidth,
|
||
errorWidget: (_, _, _) => SizedBox(height: 20.h),
|
||
),
|
||
),
|
||
Transform.translate(
|
||
offset: Offset(0, -20.h),
|
||
child: _SummaryBoard(info: info, title: title),
|
||
),
|
||
// 单词回顾标题行
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
_ReviewTitle(),
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'错误:${info.wrongCount}',
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
color: const Color(0xFFF56245),
|
||
),
|
||
),
|
||
SizedBox(width: 8.w),
|
||
Text(
|
||
'可在"错题"进行复习',
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
color: const Color(0xFF999999),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: 7.h),
|
||
// 单词回顾列表(弹性高度)
|
||
Flexible(
|
||
child: ListView.separated(
|
||
padding: EdgeInsets.symmetric(vertical: 7.h),
|
||
itemCount: info.lexicons.length,
|
||
separatorBuilder: (_, _) => SizedBox(height: 10.h),
|
||
itemBuilder: (_, i) => _ReviewItem(item: info.lexicons[i]),
|
||
),
|
||
),
|
||
SizedBox(height: 10.h),
|
||
// 下一关
|
||
GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: onNext,
|
||
child: Container(
|
||
width: double.infinity,
|
||
height: 44.h,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF1BC26E),
|
||
borderRadius: BorderRadius.circular(999.r),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.1),
|
||
blurRadius: 0,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: Text(
|
||
'下一关',
|
||
style: TextStyle(fontSize: 15.sp, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 汇总灰板:章节标题角标 + 正确率圆环 + 单词总数 + 正确单词
|
||
class _SummaryBoard extends StatelessWidget {
|
||
const _SummaryBoard({required this.info, required this.title});
|
||
|
||
final LevelOverInfo info;
|
||
final String title;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.fromLTRB(20.w, 25.5.h, 20.w, 14.h),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF8F8F8),
|
||
borderRadius: BorderRadius.circular(10.r),
|
||
),
|
||
child: Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
// 悬浮章节标题角标
|
||
if (title.isNotEmpty)
|
||
Positioned(
|
||
top: -34.h / 2 - 8.h,
|
||
left: 0,
|
||
right: 0,
|
||
child: Center(
|
||
child: Container(
|
||
constraints: BoxConstraints(minWidth: 103.w),
|
||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||
height: 34.h,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF8F8F8),
|
||
borderRadius: BorderRadius.circular(999.r),
|
||
border: Border.all(color: Colors.white, width: 0.5),
|
||
),
|
||
child: Text(
|
||
title,
|
||
maxLines: 1,
|
||
style: TextStyle(
|
||
fontSize: 13.sp,
|
||
color: const Color(0xFF666666),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Row(
|
||
children: [
|
||
// 正确率圆环(Vue :clockwise="false")
|
||
Expanded(
|
||
child: Center(
|
||
child: ReciteCircleProgress(
|
||
percent: info.rate.toDouble(),
|
||
size: 77,
|
||
strokeWidth: 5,
|
||
clockwise: false,
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text(
|
||
'${info.rate}',
|
||
style: TextStyle(
|
||
fontSize: 20.sp,
|
||
fontWeight: FontWeight.bold,
|
||
color: const Color(0xFF2E3238),
|
||
height: 1,
|
||
),
|
||
),
|
||
Padding(
|
||
padding: EdgeInsets.only(bottom: 1.h),
|
||
child: Text(
|
||
'%',
|
||
style: TextStyle(
|
||
fontSize: 8.sp,
|
||
color: const Color(0xFF2E3238),
|
||
height: 1,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: 4.h),
|
||
Text(
|
||
'正确率',
|
||
style: TextStyle(
|
||
fontSize: 10.sp,
|
||
color: const Color(0xFF999999),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: _Stat(value: '${info.lexiconCount}', label: '单词总数'),
|
||
),
|
||
Expanded(
|
||
child: _Stat(value: '${info.correctCount}', label: '正确单词'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 绿字数字 + 灰字标签
|
||
class _Stat extends StatelessWidget {
|
||
const _Stat({required this.value, required this.label});
|
||
|
||
final String value;
|
||
final String label;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
value,
|
||
style: TextStyle(fontSize: 21.sp, color: const Color(0xFF1BC26E)),
|
||
),
|
||
SizedBox(height: 6.h),
|
||
Text(
|
||
label,
|
||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 「单词回顾」左侧绿色竖条标题
|
||
class _ReviewTitle extends StatelessWidget {
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(width: 3.w, height: 16.h, color: const Color(0xFF1BC26E)),
|
||
SizedBox(width: 8.w),
|
||
Text(
|
||
'单词回顾',
|
||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF333333)),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 回顾行:状态圆点 + 英文/中文 + 发音按钮
|
||
class _ReviewItem extends StatelessWidget {
|
||
const _ReviewItem({required this.item});
|
||
|
||
final Lexicon item;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dotColor = item.isCorrect
|
||
? const Color(0xFF1BC26E)
|
||
: const Color(0xFFF56245);
|
||
|
||
return Container(
|
||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 9.5.h),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF8F8F8),
|
||
borderRadius: BorderRadius.circular(10.r),
|
||
),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 8.w,
|
||
height: 8.w,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
border: Border.all(color: dotColor, width: 1.5),
|
||
),
|
||
),
|
||
SizedBox(width: 6.w),
|
||
Flexible(
|
||
child: Text(
|
||
item.english,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 15.sp,
|
||
color: const Color(0xFF333333),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
SizedBox(height: 4.h),
|
||
Text(
|
||
item.chineseDesc,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 12.sp,
|
||
color: const Color(0xFF999999),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
SizedBox(width: 12.w),
|
||
VoiceAnimButton(url: item.voiceUrl, size: 18),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|