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

513 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/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),
],
),
);
}
}