17 Commits
Author SHA1 Message Date
崔宝新 27eba92315 feat(recite): 完成背诵模块基础框架与核心页面开发
本次提交实现了背诵模块的完整基础链路:
1. 新增词书、背单词接口层与对应数据模型
2. 完成背诵首页、章节列表页等核心页面开发
3. 实现音频播放、加载弹窗等通用组件
4. 配置路由跳转链路,替换旧版缓存传参方案
5. 对齐Vue端业务逻辑与UI样式
2026-09-11 14:38:47 +08:00
崔宝新 c65df455f2 feat(my): add 我的笔记 page and related functions
1. 新增我的笔记页面,包含学科筛选Tab和游标分页列表
2. 新增user_note接口封装和对应数据模型
3. 在我的页面添加笔记入口跳转
4. 优化收藏/错题卡片的按压反馈效果
2026-09-10 18:14:41 +08:00
崔宝新 14b8e56bc3 feat(my): 新增错题本和我的收藏页面,重构学科标签组件
1.  为我的页面添加错题本和我的收藏跳转路由,指向共用的QuestionListPage
2.  抽离全局学科标签组件,移除class模块内的重复实现
3.  新增用户收藏/错题本接口层和数据模型
4.  完成QuestionListPage分页列表页实现,支持学科筛选和游标分页
5.  调整考试页面卡片布局和图标偏移参数
2026-09-10 17:14:11 +08:00
崔宝新 96ab0221a5 feat(my): add 我的考试 page and related modules
1. 新增我的考试页面,包含学科筛选Tab和分页考试列表
2. 新增学科图标工具类,替换我的报告页重复的学科图标逻辑
3. 在我的页面菜单中添加我的考试入口
4. 新增exam模块的API接口和数据模型
5. 优化我的报告页面卡片样式,统一与考试卡片的布局
2026-09-10 15:59:11 +08:00
崔宝新 0fefc786bb feat(report): 实现AI学习报告计算页及相关组件
1. 新增报告计算进度圆环、科目卡片、完成弹窗组件
2. 重构report_computation_page,替换旧的自定义组件为新封装组件
3. 新增student_info_page的主题资源和UI重构,适配Flutter屏幕适配
4. 新增临时调试入口用于开发测试
2026-09-10 11:57:18 +08:00
崔宝新 e0b95a8b43 Merge branch 'feat-cbx' 2026-09-09 16:51:25 +08:00
崔宝新 5e4cfe9ac6 feat: 新增诊断报告全流程功能,适配登录后报告跳转逻辑
实现了入门测/出门测报告的完整链路:
1. 新增UserInfo模型的出门测报告标记字段与相关接口
2. 新增全局报告路由拦截器,自动在登录后/应用回前台时检查报告状态
3. 新增报告相关API、页面与路由配置,包含欢迎页、H5壳子、AI计算页
4. 适配登录成功后的报告跳转逻辑,更新用户状态管理方法
2026-09-09 16:49:51 +08:00
chenshuainan ee14373c6d 打包更新 2026-09-09 16:26:28 +08:00
chenshuainan 0ce3c0b3ef router 冲突解决 2026-09-09 16:24:44 +08:00
chenshuainan de3457c86e 提交 2026-09-09 16:22:50 +08:00
chenshuainan 7acf745295 合并代码 2026-09-09 16:15:05 +08:00
chenshuainan 592e576319 合并代买 2026-09-09 16:12:59 +08:00
崔宝新 c0c673e2d2 feat: 新增我的报告模块,集成inappwebview支持H5详情页
1.  添加flutter_inappwebview依赖并配置各平台插件注册
2.  新增我的报告列表页、详情WebView页
3.  新增报告模块API和数据模型
4.  配置路由跳转,修复我的菜单跳转逻辑
5.  添加AGP9兼容补丁修复插件构建问题
6.  注释掉登录成功的SnackBar提示
2026-09-09 11:08:47 +08:00
崔宝新 955d6455d8 feat(auth): 实现注册模块与登录页优化
1. 新增用户注册接口与页面,对齐Vue端业务逻辑
2. 新增居中Toast组件,替换原有SnackBar轻提示
3. 完善登录历史本地存储与下拉选择交互
4. 新增审核模式接口适配,动态控制注册入口显示
5. 优化登录页UI布局与交互细节
2026-09-08 17:12:26 +08:00
崔宝新 f718749ad7 Merge branch 'feat-cbx' 2026-09-08 15:36:38 +08:00
崔宝新 7f5d160212 feat: 添加设置页、关于页与账号注销功能,开启网络日志
1.  新增账号注销API接口与设置页菜单,实现完整注销流程
2.  新增关于APP页面,展示隐私政策、用户协议与版本信息
3.  在我的页面添加设置入口,跳转设置页
4.  优化头像上传逻辑,移除预览弹窗步骤并简化流程
5.  开启网络日志打印,方便调试网络请求
2026-09-08 15:35:56 +08:00
chenshuainan 44fb5f86e2 修改iOS 权限弹框内容 2026-09-08 15:17:35 +08:00
68 changed files with 13351 additions and 912 deletions
File diff suppressed because one or more lines are too long
+30
View File
@@ -23,3 +23,33 @@ plugins {
} }
include ":app" include ":app"
// ============================================================
// 【Gradle 兼容补丁】flutter_inappwebview_android 1.1.3 的 AGP 9 兼容问题
//
// 问题:AGP 9 不支持 proguard-android.txt,必须用 proguard-android-optimize.txt。
// 插件 build.gradle 写死了 proguard-android.txt → Gradle 解析时就报错
// (debug 构建也炸,因为 AGP 9 在解析阶段就校验了这个方法调用)。
//
// 修复:在 Gradle 解析插件 build.gradle 之前(beforeProject 钩子),
// 把插件文件里的字符串替换掉。这个钩子比 Gradle 自带机制还早触发,
// 所以能赶在 Gradle 报错前修好。
//
// 优势:
// - Gradle 原生,不依赖 Python/PowerShell
// - 跨平台(Windows/Mac/Linux,Gradle 必备)
// - 所有同事拉代码后自动生效
// - Gradle daemon 每次构建都会重新解析 build.gradle,不会有缓存问题
// ============================================================
gradle.beforeProject { Project project ->
if (project.name == 'flutter_inappwebview_android') {
def gradleFile = project.file('build.gradle')
def text = gradleFile.text
if (text.contains("proguard-android.txt")) {
gradleFile.text = text.replace(
"proguard-android.txt", "proguard-android-optimize.txt"
)
println "✅ Patched flutter_inappwebview_android/build.gradle (AGP 9 compat)"
}
}
}
+68
View File
@@ -89,6 +89,74 @@ class UserApi {
); );
} }
/// 注销账号 → POST /api/v1/User/CloseAccount
///
/// 对齐 Vue api/login: `closeAccount = () => request.post('/api/v1/User/CloseAccount')`
/// 调用方负责弹确认框 + 注销后清理本地状态。
static Future<void> closeAccount() async {
await DioClient.instance.post('/api/v1/User/CloseAccount');
}
// ==================== 注册 ====================
/// 注册 → POST /api/v1/User/Register
///
/// 对齐 Vue api/login `register`: { account, password },密码同样 MD5 加密
/// Vue RegisterResponse = { code, message }(业务响应壳),Flutter 端 DioClient
/// 已自动处理成功/失败分支,成功不关心 body,失败直接抛 ApiException。
static Future<void> register({
required String account,
required String password,
}) async {
await DioClient.instance.post(
'/api/v1/User/Register',
data: {'account': account.trim(), 'password': _md5(password.trim())},
);
}
// ==================== 审核模式 ====================
/// 获取审核模式(决定是否展示"去注册"入口)→ GET /api/v1/Public/GetIsAuditMode
///
/// 对齐 Vue api/login: `getAuditMode()`,返回值本身就是 boolean
/// (request 拦截器已解包),接口不可用时安全回退 false(不展示注册入口)。
static Future<bool> getAuditMode() async {
try {
final data = await DioClient.instance.get(
'/api/v1/Public/GetIsAuditMode',
);
if (data is bool) return data;
if (data is Map) {
// 兼容 { isAuditMode: true } 包装
final v = data['isAuditMode'];
if (v is bool) return v;
}
return false;
} catch (_) {
return false;
}
}
// ==================== 报告状态更新 ====================
/// 更新入门测试报告显示状态(看完后置回 false)
/// → POST /api/v1/User/ChangeShowReport
///
/// 对齐 Vue api/user/index.ts 的 UpdateReportStatus(),
/// 在 reportComputation finishedPopup 点"立即开启学习蝶变"后调用。
static Future<void> changeShowReport() async {
await DioClient.instance.post('/api/v1/User/ChangeShowReport');
}
/// 更新出门测试报告显示状态(看完后置回 false)
/// → POST /api/v1/User/ChangeShowAfterReport
///
/// 对齐 H5 壳子 api/mock/index.ts 的 updateAfterReportStatus(),
/// 在出门测 H5 壳子 navigateReport() 里由 Flutter JS Bridge 拦截后调用。
static Future<void> changeShowAfterReport() async {
await DioClient.instance.post('/api/v1/User/ChangeShowAfterReport');
}
/// MD5 加密(UniApp stringToMd5 的等价实现) /// MD5 加密(UniApp stringToMd5 的等价实现)
static String _md5(String text) { static String _md5(String text) {
return md5.convert(utf8.encode(text)).toString(); return md5.convert(utf8.encode(text)).toString();
+5
View File
@@ -55,6 +55,7 @@ class UserInfo {
this.avatar = '', this.avatar = '',
this.phone = '', this.phone = '',
this.isShowReport = false, this.isShowReport = false,
this.isShowAfterReport = false,
}); });
final int userId; final int userId;
@@ -64,6 +65,9 @@ class UserInfo {
final String phone; final String phone;
final bool isShowReport; final bool isShowReport;
/// 登录响应里的出门测试报告标记(对齐 Vue api/login/type.ts 的 UserInfo)
final bool isShowAfterReport;
factory UserInfo.fromJson(Map<String, dynamic> json) { factory UserInfo.fromJson(Map<String, dynamic> json) {
return UserInfo( return UserInfo(
// userId 可能是字符串(后端统一风格),不能直接 as num // userId 可能是字符串(后端统一风格),不能直接 as num
@@ -73,6 +77,7 @@ class UserInfo {
avatar: json['avatar'] as String? ?? '', avatar: json['avatar'] as String? ?? '',
phone: json['phone'] as String? ?? '', phone: json['phone'] as String? ?? '',
isShowReport: json['isShowReport'] as bool? ?? false, isShowReport: json['isShowReport'] as bool? ?? false,
isShowAfterReport: json['isShowAfterReport'] as bool? ?? false,
); );
} }
} }
@@ -0,0 +1,33 @@
import 'package:new_learn/core/api/conquerPackage/captureList/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【captureList 模块接口】攻克学习包 —— 对标 Vue3 端 api/task/index.ts
///
/// getCoursePack —— 获取课程下的攻克学习包列表
/// GET /api/v1/StudyPack/GetCoursePacks/{courseId}
///
/// startOvercome —— 开始 / 继续攻克学习包
/// POST /api/v1/StudyPack/StartOvercome/{packId}
/// ============================================================
class CaptureListApi {
CaptureListApi._();
static final CaptureListApi instance = CaptureListApi._();
/// 获取课程下的攻克学习包列表
Future<CoursePackResponse> getCoursePack(String courseId) async {
final data = await DioClient.instance.get(
'/api/v1/StudyPack/GetCoursePacks/$courseId',
);
return CoursePackResponse.fromJson(data as Map<String, dynamic>);
}
/// 开始 / 继续攻克学习包
Future<TaskDetail> startOvercome(String packId) async {
final data = await DioClient.instance.post(
'/api/v1/StudyPack/StartOvercome/$packId',
);
return TaskDetail.fromJson(data as Map<String, dynamic>);
}
}
@@ -0,0 +1,156 @@
/// ============================================================
/// 【captureList 模块模型】攻克学习包 —— 对标 Vue3 端 api/task/type.ts
///
/// CoursePackResponse / CourseInfo / CourseRecord
/// TaskDetail(开始/继续攻克返回)
///
/// 序列化:手动 fromJson(兼容 {code, message, data: {...}} 响应壳)
/// ============================================================
library;
/// 攻克记录(单次攻克的结果)
class CourseRecord {
const CourseRecord({
this.id = 0,
this.createTime = '',
this.accuracyRate = 0,
this.status = 0,
this.statusName = '',
});
final int id; // 记录 id
final String createTime; // 创建时间
final int accuracyRate; // 正确率(0-100)
final int status; // 状态:0 未开始 / 1 进行中 / 2 成功 / 3 失败 等(按后端枚举)
final String statusName; // 状态名称
factory CourseRecord.fromJson(Map<String, dynamic> json) {
return CourseRecord(
id: _parseInt(json['id']),
createTime: json['createTime'] as String? ?? '',
accuracyRate: _parseInt(json['accuracyRate']),
status: _parseInt(json['status']),
statusName: json['statusName'] as String? ?? '',
);
}
}
/// 单个学习包(攻克包)信息
class CourseInfo {
const CourseInfo({
this.id = 0,
this.name = '',
this.baseSubjectName = '',
this.overcomeStandard = 0,
this.estimatedTime = 0,
this.maxOvercomeCount = 0,
this.recordCount = 0,
this.records = const [],
this.status = 0,
this.statusName = '',
});
final int id; // 学习包 id
final String name; // 名称
final String baseSubjectName; // 学科名称
final int overcomeStandard; // 攻克标准(百分比)
final int estimatedTime; // 预计用时(分钟)
final int maxOvercomeCount; // 最大可攻克次数
final int recordCount; // 已攻克次数
final List<CourseRecord> records; // 历次攻克记录
final int status; // 状态:0 未开始 / 1 进行中 / 2 已攻克 / 3 失败 / 4 后续学习 / 5 判题中(按后端枚举)
final String statusName; // 状态名称
factory CourseInfo.fromJson(Map<String, dynamic> json) {
return CourseInfo(
id: _parseInt(json['id']),
name: json['name'] as String? ?? '',
baseSubjectName: json['baseSubjectName'] as String? ?? '',
overcomeStandard: _parseInt(json['overcomeStandard']),
estimatedTime: _parseInt(json['estimatedTime']),
maxOvercomeCount: _parseInt(json['maxOvercomeCount']),
recordCount: _parseInt(json['recordCount']),
records: (json['records'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(CourseRecord.fromJson)
.toList(),
status: _parseInt(json['status']),
statusName: json['statusName'] as String? ?? '',
);
}
}
/// 学习包列表响应(接口返回 {packs: [...], remark: '...'})
class CoursePackResponse {
const CoursePackResponse({
this.packs = const [],
this.remark = '',
});
final List<CourseInfo> packs; // 学习包列表
final String remark; // 规则说明文字
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
factory CoursePackResponse.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return CoursePackResponse(
packs: (payload['packs'] as List<dynamic>? ??
payload['courses'] as List<dynamic>? ??
const [])
.whereType<Map<String, dynamic>>()
.map(CourseInfo.fromJson)
.toList(),
remark: payload['remark'] as String? ?? '',
);
}
}
/// 开始 / 继续攻克 接口返回(精简版,包含进入下一步所需字段)
class TaskDetail {
const TaskDetail({
this.id = 0,
this.name = '',
this.overcomeStandard = 0,
this.planTimeMinute = 0,
this.recordId = 0,
this.nowStudyObjectType = 0,
this.nowStudyObjectId = 0,
this.hasHandout = false,
this.hasQuestion = false,
});
final int id; // 学习包 id
final String name; // 名称
final int overcomeStandard; // 攻克标准
final int planTimeMinute; // 计划用时(分钟)
final int recordId; // 学习记录 id(跳转到习题/讲义页用)
final int nowStudyObjectType; // 当前学习对象类型:0 讲义 / 11 习题 等
final int nowStudyObjectId; // 当前学习对象 id
final bool hasHandout; // 是否有讲义
final bool hasQuestion; // 是否有题目
factory TaskDetail.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return TaskDetail(
id: _parseInt(payload['id']),
name: payload['name'] as String? ?? '',
overcomeStandard: _parseInt(payload['overcomeStandard']),
planTimeMinute: _parseInt(payload['planTimeMinute']),
recordId: _parseInt(payload['recordId']),
nowStudyObjectType: _parseInt(payload['nowStudyObjectType']),
nowStudyObjectId: _parseInt(payload['nowStudyObjectId']),
hasHandout: payload['hasHandout'] as bool? ?? false,
hasQuestion: payload['hasQuestion'] as bool? ?? false,
);
}
}
/// 安全解析 int:兼容数字和字符串数字,解析失败返回 0
int _parseInt(dynamic value) {
if (value == null) return 0;
if (value is int) return value;
if (value is double) return value.toInt();
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value) ?? 0;
return 0;
}
+51
View File
@@ -0,0 +1,51 @@
import 'package:new_learn/core/api/exam/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【exam 模块接口】我的考试 —— 对标 Vue3 端 api/exam/index.ts
///
/// getSubjects —— 获取考试学科筛选 Tab 列表
/// search —— 我的考试分页列表
///
/// 注意:考试报告详情(getReport)等考试做题相关接口,
/// 后续扩展时再按模块拆分补进来。
/// ============================================================
class ExamApi {
ExamApi._();
static final ExamApi instance = ExamApi._();
/// 考试学科筛选 Tab 列表
/// GET /api/v1/Evaluation/GetSubjects
Future<List<ExamSubjectTab>> getSubjects() async {
final data = await DioClient.instance
.get('/api/v1/Evaluation/GetSubjects');
final list = data is List
? data.whereType<Map<String, dynamic>>().toList()
: (data is Map<String, dynamic>
? (data['data'] as List<dynamic>? ?? const [])
: const [])
.whereType<Map<String, dynamic>>()
.toList();
return list.map(ExamSubjectTab.fromJson).toList();
}
/// 我的考试分页列表
/// GET /api/v1/Evaluation/Search
/// 参数:baseSubjectId(可 null=全部)/ pageIndex / pageSize
Future<ExamResponse> search({
int? baseSubjectId,
required int pageIndex,
int pageSize = 10,
}) async {
final data = await DioClient.instance.get(
'/api/v1/Evaluation/Search',
query: {
if (baseSubjectId != null) 'baseSubjectId': baseSubjectId,
'pageIndex': pageIndex,
'pageSize': pageSize,
},
);
return ExamResponse.fromJson(data as Map<String, dynamic>);
}
}
+109
View File
@@ -0,0 +1,109 @@
/// ============================================================
/// 【exam 模块模型】我的考试 —— 对标 Vue3 端 api/exam/type.ts
///
/// ExamDto —— 单条考试记录
/// ExamSubjectTab —— 考试科目筛选 Tab
/// ExamResponse —— 考试列表分页响应
///
/// 序列化:手动 fromJson,与项目其他模块风格一致,不依赖 build_runner。
///
/// ⚠ 注意:后端返回的 id 字段类型不统一(数字 or 字符串),
/// 所有数值字段统一用 _asInt() 做兼容解析,避免类型转换异常。
/// ============================================================
library;
int? _optInt(dynamic v) {
if (v == null) return null;
if (v is int) return v;
if (v is double) return v.toInt();
final s = v.toString();
final n = int.tryParse(s);
if (n != null) return n;
return double.tryParse(s)?.toInt();
}
int _asInt(dynamic v, [int fallback = 0]) => _optInt(v) ?? fallback;
/// 单条考试记录
class ExamDto {
const ExamDto({
this.id = 0,
this.taskId = 0,
this.taskName = '',
this.baseSubjectId = 0,
this.baseSubjectName = '',
this.startTime = '',
this.endTime = '',
this.totalScore = 0,
this.userScore = 0,
});
final int id;
final int taskId;
final String taskName;
final int baseSubjectId;
final String baseSubjectName;
final String startTime;
final String endTime;
final int totalScore;
final int userScore;
factory ExamDto.fromJson(Map<String, dynamic> json) {
return ExamDto(
id: _asInt(json['id']),
taskId: _asInt(json['taskId']),
taskName: (json['taskName'] as String?) ?? '',
baseSubjectId: _asInt(json['baseSubjectId']),
baseSubjectName: (json['baseSubjectName'] as String?) ?? '',
startTime: (json['startTime'] as String?) ?? '',
endTime: (json['endTime'] as String?) ?? '',
totalScore: _asInt(json['totalScore']),
userScore: _asInt(json['userScore']),
);
}
}
/// 考试科目筛选 Tab
class ExamSubjectTab {
const ExamSubjectTab({this.id, this.name = ''});
/// 为 null 表示"全部"
final int? id;
final String name;
factory ExamSubjectTab.fromJson(Map<String, dynamic> json) {
return ExamSubjectTab(
id: _optInt(json['id']),
name: (json['name'] as String?) ?? '',
);
}
}
/// 考试列表分页响应
class ExamResponse {
const ExamResponse({
this.data = const [],
this.total = 0,
this.pageIndex = 1,
this.pageSize = 10,
});
final List<ExamDto> data;
final int total;
final int pageIndex;
final int pageSize;
factory ExamResponse.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
final list = (payload['data'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(ExamDto.fromJson)
.toList();
return ExamResponse(
data: list,
total: (payload['total'] as num?)?.toInt() ?? list.length,
pageIndex: (payload['pageIndex'] as num?)?.toInt() ?? 1,
pageSize: (payload['pageSize'] as num?)?.toInt() ?? 10,
);
}
}
+86
View File
@@ -0,0 +1,86 @@
import 'package:new_learn/core/api/lexicon/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【lexicon 模块接口】背单词接口 —— 对标 Vue3 端 api/lexicon/index.ts
///
/// getChapters → GET GetChapters/{bookId} 章节列表
/// getLevels → GET GetLevels/{chapterId} 关卡列表
/// levelStart → POST LevelStart/{levelId} 开局取题
/// answerQuestion → POST Answer 提交答案
/// getLevelIsFinish → GET GetLevelIsFinish/{levelId} 关卡是否完成(轮询)
/// getLevelOverInfo → GET GetLevelOverInfo/{userLevelId} 闯关小结
///
/// 注意:getChapters Vue 端带 hideErrorToast,toast 由页面自行处理;
/// 异常统一向上抛,页面 catch 后决定提示。
/// ============================================================
class LexiconApi {
LexiconApi._();
static final LexiconApi instance = LexiconApi._();
/// 后端响应壳 {code, message, data} 统一取 data(用于裸值响应)
dynamic _unwrap(dynamic data) =>
data is Map<String, dynamic> && data['data'] != null
? data['data']
: data;
/// 章节列表(model 内部兼容响应壳)
Future<ChapterResponse> getChapters(int bookId) async {
final data = await DioClient.instance.get(
'/api/v1/Lexicon/GetChapters/$bookId',
);
return ChapterResponse.fromJson(data as Map<String, dynamic>);
}
/// 关卡列表
Future<LevelResponse> getLevels(int chapterId) async {
final data = await DioClient.instance.get(
'/api/v1/Lexicon/GetLevels/$chapterId',
);
return LevelResponse.fromJson(data as Map<String, dynamic>);
}
/// 开局取题
Future<LevelStartInfo> levelStart(int levelId) async {
final data = await DioClient.instance.post(
'/api/v1/Lexicon/LevelStart/$levelId',
);
return LevelStartInfo.fromJson(data as Map<String, dynamic>);
}
/// 提交答案(返回值与开局信息同构,但页面只关心成功与否)
Future<bool> answerQuestion({
required int levelId,
required int userLevelId,
required int lexiconId,
required String answer,
}) async {
final data = await DioClient.instance.post(
'/api/v1/Lexicon/Answer',
data: {
'levelId': levelId,
'userLevelId': userLevelId,
'lexiconId': lexiconId,
'answer': answer,
},
);
return data != null;
}
/// 关卡是否完成(完关后后端有短暂处理延迟,需轮询)
Future<bool> getLevelIsFinish(int levelId) async {
final data = _unwrap(
await DioClient.instance.get('/api/v1/Lexicon/GetLevelIsFinish/$levelId'),
);
return data == true;
}
/// 闯关小结
Future<LevelOverInfo> getLevelOverInfo(int userLevelId) async {
final data = await DioClient.instance.get(
'/api/v1/Lexicon/GetLevelOverInfo/$userLevelId',
);
return LevelOverInfo.fromJson(data as Map<String, dynamic>);
}
}
+300
View File
@@ -0,0 +1,300 @@
/// ============================================================
/// 【lexicon 模块模型】背单词:章节 / 关卡 / 题目 / 小结
/// —— 对标 Vue3 端 api/lexicon/type.ts
/// ============================================================
library;
/// 兼容 number | String 的 id 安全转换(对标 TS 的 number | string)
int safeLexiconId(dynamic v) {
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
double _safeDouble(dynamic v) {
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
/// 章节(词汇章节列表项)
class Chapter {
const Chapter({
this.id = 0,
this.title = '',
this.icon = '',
this.finishLexiconCount = 0,
this.levelCount = 0,
this.lexiconCount = 0,
this.finishProgress = 0,
this.cover = '',
this.index = '',
});
final int id;
final String title;
final String icon;
final int finishLexiconCount;
final int levelCount;
final int lexiconCount;
final double finishProgress; // 0~1
final String cover;
final String index; // 封面叠字序号(接口可能下发,Vue 端直接渲染 item.index)
factory Chapter.fromJson(Map<String, dynamic> json) {
return Chapter(
id: safeLexiconId(json['id']),
title: json['title'] as String? ?? '',
icon: json['icon'] as String? ?? '',
finishLexiconCount: (json['finishLexiconCount'] as num?)?.toInt() ?? 0,
levelCount: (json['levelCount'] as num?)?.toInt() ?? 0,
lexiconCount: (json['lexiconCount'] as num?)?.toInt() ?? 0,
finishProgress: _safeDouble(json['finishProgress']),
cover: json['cover'] as String? ?? '',
index: json['index']?.toString() ?? '',
);
}
}
/// 章节列表响应
class ChapterResponse {
const ChapterResponse({this.chapters = const [], this.totalFinishProgress = 0});
final List<Chapter> chapters;
final double totalFinishProgress; // 0~1
factory ChapterResponse.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return ChapterResponse(
chapters: (payload['chapters'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Chapter.fromJson)
.toList(),
totalFinishProgress: _safeDouble(payload['totalFinishProgress']),
);
}
}
/// 关卡
class Level {
const Level({
this.id = 0,
this.title = '',
this.lexiconCount = 0,
this.isOpen = false,
this.finishLexiconCount = 0,
this.finishProgress = 0,
});
final int id;
final String title;
final int lexiconCount;
final bool isOpen; // 是否解锁
final int finishLexiconCount;
final double finishProgress; // 0~1
factory Level.fromJson(Map<String, dynamic> json) {
return Level(
id: safeLexiconId(json['id']),
title: json['title'] as String? ?? '',
lexiconCount: (json['lexiconCount'] as num?)?.toInt() ?? 0,
isOpen: json['isOpen'] as bool? ?? false,
finishLexiconCount: (json['finishLexiconCount'] as num?)?.toInt() ?? 0,
finishProgress: _safeDouble(json['finishProgress']),
);
}
}
/// 关卡列表响应(含章节头部汇总)
class LevelResponse {
const LevelResponse({
this.id = 0,
this.title = '',
this.lexiconCount = 0,
this.finishLexiconCount = 0,
this.finishProgress = 0,
this.levels = const [],
});
final int id;
final String title;
final int lexiconCount;
final int finishLexiconCount;
final double finishProgress;
final List<Level> levels;
factory LevelResponse.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return LevelResponse(
id: safeLexiconId(payload['id']),
title: payload['title'] as String? ?? '',
lexiconCount: (payload['lexiconCount'] as num?)?.toInt() ?? 0,
finishLexiconCount:
(payload['finishLexiconCount'] as num?)?.toInt() ?? 0,
finishProgress: _safeDouble(payload['finishProgress']),
levels: (payload['levels'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Level.fromJson)
.toList(),
);
}
}
/// 答题页单词题目
class Question {
const Question({
this.id = 0,
this.americanEnglish = '',
this.americanPhonetic = '',
this.americanVoiceUrl = '',
this.imageDesc = '',
this.contextMatch = '',
this.english = '',
this.phonetic = '',
this.voiceUrl = '',
this.chineseDesc = '',
this.options = const [],
});
final int id;
final String americanEnglish;
final String americanPhonetic;
final String americanVoiceUrl;
final String imageDesc; // 配图
final String contextMatch; // 语境搭配(空则不可翻转)
final String english;
final String phonetic;
final String voiceUrl;
final String chineseDesc; // 正确答案
final List<String> options;
factory Question.fromJson(Map<String, dynamic> json) {
return Question(
id: safeLexiconId(json['id']),
americanEnglish: json['americanEnglish'] as String? ?? '',
americanPhonetic: json['americanPhonetic'] as String? ?? '',
americanVoiceUrl: json['americanVoiceUrl'] as String? ?? '',
imageDesc: json['imageDesc'] as String? ?? '',
contextMatch: json['contextMatch'] as String? ?? '',
english: json['english'] as String? ?? '',
phonetic: json['phonetic'] as String? ?? '',
voiceUrl: json['voiceUrl'] as String? ?? '',
chineseDesc: json['chineseDesc'] as String? ?? '',
options: (json['options'] as List<dynamic>? ?? const [])
.map((e) => e.toString())
.toList(),
);
}
}
/// 已作答单词(小结回顾用)
class Lexicon {
const Lexicon({
this.id = 0,
this.english = '',
this.phonetic = '',
this.voiceUrl = '',
this.chineseDesc = '',
this.isCorrect = false,
});
final int id;
final String english;
final String phonetic;
final String voiceUrl;
final String chineseDesc;
final bool isCorrect;
factory Lexicon.fromJson(Map<String, dynamic> json) {
return Lexicon(
id: safeLexiconId(json['id']),
english: json['english'] as String? ?? '',
phonetic: json['phonetic'] as String? ?? '',
voiceUrl: json['voiceUrl'] as String? ?? '',
chineseDesc: json['chineseDesc'] as String? ?? '',
isCorrect: json['isCorrect'] as bool? ?? false,
);
}
}
/// LevelStart 开局信息
class LevelStartInfo {
const LevelStartInfo({
this.id = 0,
this.userLevelId = 0,
this.finishLexiconCount = 0,
this.lexiconCount = 0,
this.questions = const [],
this.lexicons = const [],
});
final int id;
final int userLevelId;
final int finishLexiconCount;
final int lexiconCount;
final List<Question> questions;
final List<Lexicon> lexicons;
factory LevelStartInfo.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return LevelStartInfo(
id: safeLexiconId(payload['id']),
userLevelId: (payload['userLevelId'] as num?)?.toInt() ?? 0,
finishLexiconCount:
(payload['finishLexiconCount'] as num?)?.toInt() ?? 0,
lexiconCount: (payload['lexiconCount'] as num?)?.toInt() ?? 0,
questions: (payload['questions'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Question.fromJson)
.toList(),
lexicons: (payload['lexicons'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Lexicon.fromJson)
.toList(),
);
}
}
/// 闯关小结信息
class LevelOverInfo {
const LevelOverInfo({
this.id = 0,
this.userLevelId = 0,
this.lexiconCount = 0,
this.correctCount = 0,
this.wrongCount = 0,
this.nextLevelId = 0,
this.nextChapterId = 0,
this.lexicons = const [],
});
final int id;
final int userLevelId;
final int lexiconCount;
final int correctCount;
final int wrongCount;
final int nextLevelId; // 0 表示没有下一关
final int nextChapterId; // 0 表示没有下一章
final List<Lexicon> lexicons;
/// 正确率(0~100,四舍五入;无单词兜底 0)
int get rate =>
lexiconCount <= 0 ? 0 : (correctCount * 100 / lexiconCount).round();
factory LevelOverInfo.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return LevelOverInfo(
id: safeLexiconId(payload['id']),
userLevelId: (payload['userLevelId'] as num?)?.toInt() ?? 0,
lexiconCount: (payload['lexiconCount'] as num?)?.toInt() ?? 0,
correctCount: (payload['correctCount'] as num?)?.toInt() ?? 0,
wrongCount: (payload['wrongCount'] as num?)?.toInt() ?? 0,
nextLevelId: safeLexiconId(payload['nextLevelId']),
nextChapterId: safeLexiconId(payload['nextChapterId']),
lexicons: (payload['lexicons'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Lexicon.fromJson)
.toList(),
);
}
}
+68
View File
@@ -0,0 +1,68 @@
import 'package:new_learn/core/api/lexicon_book/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【lexicon_book 模块接口】词书接口 —— 对标 Vue3 端 api/lexiconBook/index.ts
///
/// getCurrentBook → GET GetCurrentLexiconBooks 当前学习的词书
/// getLearnDayCount→ GET GetLearnDayCount 坚持学习天数
/// getAllBooks → GET GetAllLexiconBooks 我的书库
/// startLearn → POST StartLearn/{bookId} 切换/开始学习某书
///
/// 说明:Vue 端这 4 个接口都带 hideErrorToast(静默失败),
/// 异常不在此层吞,交页面按需要 catch 空处理。
/// ============================================================
class LexiconBookApi {
LexiconBookApi._();
static final LexiconBookApi instance = LexiconBookApi._();
/// 后端响应壳 {code, message, data} 统一取 data
dynamic _unwrap(dynamic data) =>
data is Map<String, dynamic> && data['data'] != null
? data['data']
: data;
/// 当前学习的词书(未选择时后端可能返回 null)
Future<BookInfo?> getCurrentBook() async {
final data = _unwrap(
await DioClient.instance.get('/api/v1/LexiconBook/GetCurrentLexiconBooks'),
);
if (data is Map<String, dynamic>) return BookInfo.fromJson(data);
return null;
}
/// 坚持学习天数
Future<int> getLearnDayCount() async {
final data = _unwrap(
await DioClient.instance.get('/api/v1/LexiconBook/GetLearnDayCount'),
);
if (data is num) return data.toInt();
if (data is String) return int.tryParse(data) ?? 0;
return 0;
}
/// 我的书库全部词书
Future<List<BookInfo>> getAllBooks() async {
final data = _unwrap(
await DioClient.instance.get('/api/v1/LexiconBook/GetAllLexiconBooks'),
);
if (data is List) {
return data
.whereType<Map<String, dynamic>>()
.map(BookInfo.fromJson)
.toList();
}
return const [];
}
/// 切换 / 开始学习某书
Future<bool> startLearn(int bookId) async {
final data = _unwrap(
await DioClient.instance.post(
'/api/v1/LexiconBook/StartLearn/$bookId',
),
);
return data == true;
}
}
+53
View File
@@ -0,0 +1,53 @@
/// ============================================================
/// 【lexicon_book 模块模型】词书实体 —— 对标 Vue3 端 api/lexiconBook/type.ts
///
/// BookInfo:词书(背诵首页当前书 / 教材列表我的书库)
/// ============================================================
library;
/// 兼容 number | String 的 id 安全转换(对标 TS 的 number | string)
int _safeId(dynamic v) {
if (v is num) return v.toInt();
if (v is String) return int.tryParse(v) ?? 0;
return 0;
}
/// 词书信息
class BookInfo {
const BookInfo({
this.id = 0,
this.title = '',
this.cover = '',
this.poster = '',
this.slogan = '',
this.lexiconCount = 0,
this.totalCount = 0,
this.isStudying = false,
});
final int id;
final String title; // 书名
final String cover; // 封面图
final String poster; // 顶部海报图
final String slogan; // 海报标语
final int lexiconCount; // 已学词数
final int totalCount; // 总词数
final bool isStudying; // 是否学习中
/// 学习进度百分比(0~100,取整;totalCount 为 0 时兜底 0)
int get progressPercent =>
totalCount <= 0 ? 0 : (lexiconCount * 100 ~/ totalCount);
factory BookInfo.fromJson(Map<String, dynamic> json) {
return BookInfo(
id: _safeId(json['id']),
title: json['title'] as String? ?? '',
cover: json['cover'] as String? ?? '',
poster: json['poster'] as String? ?? '',
slogan: json['slogan'] as String? ?? '',
lexiconCount: (json['lexiconCount'] as num?)?.toInt() ?? 0,
totalCount: (json['totalCount'] as num?)?.toInt() ?? 0,
isStudying: json['isStudying'] as bool? ?? false,
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:new_learn/core/api/my_report/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【my_report 模块接口】我的报告 —— 对标 Vue3 端 api/myReport/index.ts
///
/// getReportPage —— 报告列表分页(页码分页)
/// GET /api/v1/UserReport/GetUserReportList
/// 参数:pageIndex / pageSize / reportType(可选)
/// ============================================================
class UserReportApi {
UserReportApi._();
static final UserReportApi instance = UserReportApi._();
/// 报告列表分页(页码分页:pageIndex / pageSize)
/// [reportType] 可选筛选条件,Vue 端默认不传
Future<UserReportPage> getReportPage({
int pageIndex = 1,
int pageSize = 10,
int? reportType,
}) async {
final query = <String, dynamic>{
'pageIndex': pageIndex,
'pageSize': pageSize,
};
if (reportType != null) query['reportType'] = reportType;
final data = await DioClient.instance.get(
'/api/v1/UserReport/GetUserReportList',
query: query,
);
return UserReportPage.fromJson(data as Map<String, dynamic>);
}
}
+91
View File
@@ -0,0 +1,91 @@
/// ============================================================
/// 【my_report 模块模型】我的报告 —— 对标 Vue3 端 api/myReport/type.ts
///
/// UserReportDto(单条报告)/ UserReportPage(分页响应)
///
/// 字段说明:
/// id 报告 ID(后端大整数,返回 string 类型,前端也用 String 存)
/// reportName 报告名称(如"数学分析报告")
/// reportType 报告类型
/// createTime 创建时间字符串(如 "2026-08-28 15:24:39" 或 ISO "T" 分隔)
/// reportUrl 报告详情 H5 页面 URL(点击列表项后 WebView 加载这个)
///
/// 序列化:手动 fromJson,做了类型兼容(后端 id 有时 string 有时 number,
/// 统一转 String;reportUrl 清理首尾空白和反引号)。
/// 与 my_point/model.dart 保持一致,不依赖 build_runner。
/// ============================================================
library;
/// 辅助:id 字段兼容 string / num 两种后端返回,统一转 String
String _asStringId(dynamic value) {
if (value == null) return '';
if (value is String) return value.trim();
if (value is num) return value.toInt().toString();
return value.toString();
}
/// 辅助:清理 reportUrl 首尾可能的反引号 / 空白(后端可能包了一层引号)
String _cleanUrl(String raw) {
final cleaned = raw.trim();
// 去掉首尾反引号(用户粘贴时可能加的,或后端真返回了)
if (cleaned.startsWith('`') && cleaned.endsWith('`')) {
return cleaned.substring(1, cleaned.length - 1).trim();
}
return cleaned;
}
/// 单条报告
class UserReportDto {
const UserReportDto({
this.id = '',
this.reportName = '',
this.reportType = 0,
this.createTime = '',
this.reportUrl = '',
});
final String id; // 后端大整数 ID,用 String 存避免精度丢失
final String reportName;
final int reportType;
final String createTime;
final String reportUrl;
factory UserReportDto.fromJson(Map<String, dynamic> json) {
return UserReportDto(
id: _asStringId(json['id']),
reportName: (json['reportName'] as String?) ?? '',
reportType: (json['reportType'] as num?)?.toInt() ?? 0,
createTime: (json['createTime'] as String?) ?? '',
reportUrl: _cleanUrl((json['reportUrl'] as String?) ?? ''),
);
}
}
/// 报告分页响应
class UserReportPage {
const UserReportPage({
this.data = const [],
this.total = 0,
this.pageIndex = 1,
this.pageSize = 10,
});
final List<UserReportDto> data;
final int total;
final int pageIndex;
final int pageSize;
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
factory UserReportPage.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return UserReportPage(
data: (payload['data'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(UserReportDto.fromJson)
.toList(),
total: (payload['total'] as num?)?.toInt() ?? 0,
pageIndex: (payload['pageIndex'] as num?)?.toInt() ?? 1,
pageSize: (payload['pageSize'] as num?)?.toInt() ?? 10,
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import 'package:new_learn/core/api/report/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【report 模块接口】入门测/出门测诊断报告 —— 对标 Vue3 端 api/report/index.ts
///
/// 注意:这个模块是"诊断报告包"那条链路的接口(studentInfo / reportComputation 页用),
/// 与 core/api/my_report(历史报告列表)不是同一个业务模块。
///
/// getCurrentReport() → 入门测报告(placementTest)
/// getCurrentAfterReport() → 出门测报告(exitTest)
/// getCurrentReportKnow() → 当前用户未掌握知识点(reportComputation AI 动画页用)
/// ============================================================
class ReportApi {
ReportApi._();
static final ReportApi instance = ReportApi._();
/// 获取当前用户入门测试报告 → GET /api/v1/UserReport/GetCurrentReport
Future<UserReportType> getCurrentReport() async {
final data = await DioClient.instance.get(
'/api/v1/UserReport/GetCurrentReport',
);
return UserReportType.fromJson(data as Map<String, dynamic>);
}
/// 获取当前用户出门测试报告 → GET /api/v1/UserReport/GetCurrentAfterReport
Future<UserReportType> getCurrentAfterReport() async {
final data = await DioClient.instance.get(
'/api/v1/UserReport/GetCurrentAfterReport',
);
return UserReportType.fromJson(data as Map<String, dynamic>);
}
/// 获取当前用户未掌握的知识点 → GET /api/v1/UserReport/GetCurrentReportKnow
Future<List<UserReportKnowType>> getCurrentReportKnow() async {
final data = await DioClient.instance.get(
'/api/v1/UserReport/GetCurrentReportKnow',
);
final list = data is List ? data : (data['data'] as List<dynamic>? ?? const []);
return list
.whereType<Map<String, dynamic>>()
.map(UserReportKnowType.fromJson)
.toList();
}
}
+80
View File
@@ -0,0 +1,80 @@
/// ============================================================
/// 【report 模块模型】入门测/出门测报告 —— 对标 Vue3 端 api/report/type.ts
///
/// 注意:这个模块是"诊断报告包"(报告状态流转那条链路用的),
/// 与 core/api/my_report(历史报告列表)不是同一个业务模块。
///
/// SubjectReportType → 单科目报告(科目名/图标/知识点数/报告URL)
/// UserReportType → 当前用户诊断报告整体(用户名/能力等级/科目列表)
/// UserReportKnowType → 当前用户未掌握的知识点(reportComputation 页用)
///
/// 序列化:手动 fromJson,与项目其他模块风格一致,不依赖 build_runner。
/// ============================================================
library;
/// 单科目报告信息
class SubjectReportType {
const SubjectReportType({
this.subjectName = '',
this.url = '',
this.icon = '',
this.knowledgePoint = 0,
});
final String subjectName;
final String url;
final String icon;
final int knowledgePoint;
factory SubjectReportType.fromJson(Map<String, dynamic> json) {
return SubjectReportType(
subjectName: (json['subjectName'] as String?) ?? '',
url: (json['url'] as String?) ?? '',
icon: (json['icon'] as String?) ?? '',
knowledgePoint: (json['knowledgePoint'] as num?)?.toInt() ?? 0,
);
}
}
/// 当前用户诊断报告整体
class UserReportType {
const UserReportType({
this.userName = '',
this.userLevel = '',
this.subjectReports = const [],
});
final String userName;
final String userLevel;
final List<SubjectReportType> subjectReports;
factory UserReportType.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
return UserReportType(
userName: (payload['userName'] as String?) ?? '',
userLevel: (payload['userLevel'] as String?) ?? '',
subjectReports: (payload['subjectReports'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(SubjectReportType.fromJson)
.toList(),
);
}
}
/// 当前用户未掌握的知识点(reportComputation AI加载动画页用)
class UserReportKnowType {
const UserReportKnowType({
this.subjectName = '',
this.knowCount = 0,
});
final String subjectName;
final int knowCount;
factory UserReportKnowType.fromJson(Map<String, dynamic> json) {
return UserReportKnowType(
subjectName: (json['subjectName'] as String?) ?? '',
knowCount: (json['knowCount'] as num?)?.toInt() ?? 0,
);
}
}
+66
View File
@@ -0,0 +1,66 @@
import 'package:new_learn/core/api/user_collect/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【user_collect 模块接口】我的收藏 + 错题本 —— 对标 Vue3 端 api/userCollect/index.ts
///
/// getSubjects —— 学科筛选项(顶部 Tab)
/// GET /api/v1/UserCollect/GetUserCollectSubject/{type}
///
/// getCollectList —— 收藏/错题分页列表(游标分页)
/// GET /api/v1/UserCollect/GetUserCollect
/// 参数:type(1收藏/2错题) / subjectId(可选) / pageIndex / pageSize / prevPageId
///
/// 游标分页说明(对齐 Vue useList):
/// 第一页 prevPageId=0,之后每页传上一页最后一条的 id;
/// 切学科/下拉刷新时 prevPageId 重置为 0。
/// ============================================================
class UserCollectApi {
UserCollectApi._();
static final UserCollectApi instance = UserCollectApi._();
/// 学科筛选项([type] 1=我的收藏 2=错题本)
Future<List<CollectTab>> getSubjects(int type) async {
final data = await DioClient.instance.get(
'/api/v1/UserCollect/GetUserCollectSubject/$type',
);
// 兼容裸数组 / {data: [...]} 响应壳
final List<dynamic> list = data is List
? data
: (data is Map<String, dynamic>
? (data['data'] as List<dynamic>? ?? const [])
: const []);
return list
.whereType<Map<String, dynamic>>()
.map(CollectTab.fromJson)
.toList();
}
/// 收藏/错题分页列表
///
/// [type] 1=我的收藏 2=错题本;[subjectId] 不传 = 全部学科;
/// [prevPageId] 游标:首页传 0,翻页传上一页最后一条 id。
Future<UserCollectPage> getCollectList({
required int type,
int? subjectId,
required int pageIndex,
int pageSize = 10,
int prevPageId = 0,
}) async {
final query = <String, dynamic>{
'type': type,
'pageIndex': pageIndex,
'pageSize': pageSize,
'prevPageId': prevPageId,
};
// 对齐 Vue useList:subjectId 为 null 时不带该参数
if (subjectId != null) query['subjectId'] = subjectId;
final data = await DioClient.instance.get(
'/api/v1/UserCollect/GetUserCollect',
query: query,
);
return UserCollectPage.fromJson(data as Map<String, dynamic>);
}
}
+124
View File
@@ -0,0 +1,124 @@
/// ============================================================
/// 【user_collect 模块模型】我的收藏 + 错题本 —— 对标 Vue3 端 api/userCollect/type.ts
///
/// CollectTab 学科筛选 Tab(GetUserCollectSubject 返回项)
/// UserCollectItem 列表项(收藏/错题通用,靠 type 区分)
/// UserCollectPage 分页响应(游标分页:pageIndex + prevPageId)
///
/// 说明:错题本页(myWrong)与收藏页(myCollect)共用同一套接口,
/// type=1 我的收藏 / type=2 错题本。
/// 序列化:手动 fromJson(兼容 {code, message, data: {...}} 响应壳),
/// 不依赖 build_runner。
/// ============================================================
library;
/// 兼容后端 id 字段:雪花 id 后端可能以 String 返回(防 JS 精度丢失),
/// 也可能直接是 num;统一转 int,无法解析返回 null。
int? _toInt(dynamic v) {
if (v == null) return null;
if (v is num) return v.toInt();
if (v is String && v.isNotEmpty) return int.tryParse(v);
return null;
}
/// 学科筛选 Tab
class CollectTab {
const CollectTab({
this.id,
required this.name,
this.remark = '',
this.status = 0,
});
/// 学科 id;null = 客户端插入的"全部"
final int? id;
final String name;
final String remark;
final int status;
factory CollectTab.fromJson(Map<String, dynamic> json) {
return CollectTab(
id: _toInt(json['id']),
name: json['name'] as String? ?? '',
remark: json['remark'] as String? ?? '',
status: _toInt(json['status']) ?? 0,
);
}
}
/// 收藏/错题列表项
class UserCollectItem {
const UserCollectItem({
this.id = 0,
this.questionId = 0,
this.recordQuestionId = 0,
this.questionStem = '',
this.subjectName = '',
this.packName = '',
this.createTime = '',
});
/// 列表明细 id(游标分页 prevPageId 取本页最后一条的 id)
final int id;
/// 题目 id(详情页参数 questionId)
final int questionId;
/// 答题记录 id(详情页参数 recordQuestionId)
final int recordQuestionId;
/// 题干 HTML(RichTextContent 渲染)
final String questionStem;
/// 学科名(SubjectTag 配色用)
final String subjectName;
/// 来源试卷/礼包名
final String packName;
/// 收藏/错题时间字符串
final String createTime;
factory UserCollectItem.fromJson(Map<String, dynamic> json) {
return UserCollectItem(
id: _toInt(json['id']) ?? 0,
questionId: _toInt(json['questionId']) ?? 0,
recordQuestionId: _toInt(json['recordQuestionId']) ?? 0,
questionStem: json['questionStem'] as String? ?? '',
subjectName: json['subjectName'] as String? ?? '',
packName: json['packName'] as String? ?? '',
createTime: json['createTime'] as String? ?? '',
);
}
}
/// 收藏/错题分页响应
class UserCollectPage {
const UserCollectPage({
this.data = const [],
this.total = 0,
this.pageIndex = 1,
this.pageSize = 10,
});
final List<UserCollectItem> data; // 当前页数据
final int total; // 总条数(顶部"X 道错题/收藏题目")
final int pageIndex;
final int pageSize;
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
factory UserCollectPage.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] is Map<String, dynamic>)
? json['data'] as Map<String, dynamic>
: json;
return UserCollectPage(
data: (payload['data'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(UserCollectItem.fromJson)
.toList(),
total: _toInt(payload['total']) ?? 0,
pageIndex: _toInt(payload['pageIndex']) ?? 1,
pageSize: _toInt(payload['pageSize']) ?? 10,
);
}
}
+67
View File
@@ -0,0 +1,67 @@
import 'package:new_learn/core/api/user_collect/model.dart';
import 'package:new_learn/core/api/user_note/model.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【user_note 模块接口】我的笔记 —— 对标 Vue3 端 api/userNote/index.ts
///
/// getSubjects —— 学科筛选项(顶部 Tab,无参)
/// GET /api/v1/UserNote/GetUserCollectSubject
///
/// getNoteList —— 笔记分页列表(游标分页)
/// GET /api/v1/UserNote/GetUserNotes
/// 参数:subjectId(可选) / pageIndex / pageSize / prevPageId
///
/// 列表不按笔记类型过滤;type(1题目/2讲义) 由每条数据自带,
/// 前端用卡片角标图标区分。
///
/// 注意:Vue 同文件里的 OSS 凭证/上传地址接口归属 OSS 模块
/// (Flutter 端见 core/network/oss_api.dart),不在此重复封装。
/// ============================================================
class UserNoteApi {
UserNoteApi._();
static final UserNoteApi instance = UserNoteApi._();
/// 学科筛选项
Future<List<CollectTab>> getSubjects() async {
final data = await DioClient.instance.get(
'/api/v1/UserNote/GetUserCollectSubject',
);
// 兼容裸数组 / {data: [...]} 响应壳
final List<dynamic> list = data is List
? data
: (data is Map<String, dynamic>
? (data['data'] as List<dynamic>? ?? const [])
: const []);
return list
.whereType<Map<String, dynamic>>()
.map(CollectTab.fromJson)
.toList();
}
/// 笔记分页列表
///
/// [subjectId] 不传 = 全部学科;[prevPageId] 游标:首页传 0,
/// 翻页传上一页最后一条 id。
Future<UserNotePage> getNoteList({
int? subjectId,
required int pageIndex,
int pageSize = 10,
int prevPageId = 0,
}) async {
final query = <String, dynamic>{
'pageIndex': pageIndex,
'pageSize': pageSize,
'prevPageId': prevPageId,
};
// 对齐 Vue useList:subjectId 为 null 时不带该参数
if (subjectId != null) query['subjectId'] = subjectId;
final data = await DioClient.instance.get(
'/api/v1/UserNote/GetUserNotes',
query: query,
);
return UserNotePage.fromJson(data as Map<String, dynamic>);
}
}
+90
View File
@@ -0,0 +1,90 @@
/// ============================================================
/// 【user_note 模块模型】我的笔记 —— 对标 Vue3 端 api/userNote/type.ts
///
/// UserNoteItem 笔记列表项(type 1=题目笔记 2=讲义笔记)
/// UserNotePage 分页响应(游标分页:pageIndex + prevPageId)
///
/// 学科筛选 Tab 复用收藏模块的 CollectTab(见 api.dart 的 import,
/// 与 Vue 端 api/userNote/index.ts 从 ../userCollect/type 引入一致)。
/// ============================================================
library;
/// 兼容后端 id 字段:雪花 id 后端可能以 String 返回(防 JS 精度丢失)
int? _noteToInt(dynamic v) {
if (v == null) return null;
if (v is num) return v.toInt();
if (v is String && v.isNotEmpty) return int.tryParse(v);
return null;
}
/// 我的笔记列表项
class UserNoteItem {
const UserNoteItem({
this.id = 0,
this.subjectName = '',
this.type = 0,
this.title = '',
this.content = '',
this.createTime = '',
});
/// 笔记 id(游标分页 prevPageId 取本页最后一条的 id;详情页参数 id)
final int id;
/// 学科名(SubjectTag 配色用)
final String subjectName;
/// 笔记类型:1=题目笔记 2=讲义笔记(卡片角标/详情页区分用)
final int type;
/// 笔记标题
final String title;
/// 笔记内容 HTML(RichTextContent 渲染)
final String content;
/// 记笔记时间字符串
final String createTime;
factory UserNoteItem.fromJson(Map<String, dynamic> json) {
return UserNoteItem(
id: _noteToInt(json['id']) ?? 0,
subjectName: json['subjectName'] as String? ?? '',
type: _noteToInt(json['type']) ?? 0,
title: json['title'] as String? ?? '',
content: json['content'] as String? ?? '',
createTime: json['createTime'] as String? ?? '',
);
}
}
/// 我的笔记分页响应
class UserNotePage {
const UserNotePage({
this.data = const [],
this.total = 0,
this.pageIndex = 1,
this.pageSize = 10,
});
final List<UserNoteItem> data;
final int total;
final int pageIndex;
final int pageSize;
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
factory UserNotePage.fromJson(Map<String, dynamic> json) {
final payload = (json['data'] is Map<String, dynamic>)
? json['data'] as Map<String, dynamic>
: json;
return UserNotePage(
data: (payload['data'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(UserNoteItem.fromJson)
.toList(),
total: _noteToInt(payload['total']) ?? 0,
pageIndex: _noteToInt(payload['pageIndex']) ?? 1,
pageSize: _noteToInt(payload['pageSize']) ?? 10,
);
}
}
+7 -2
View File
@@ -9,6 +9,7 @@ import 'package:new_learn/core/config/app_config.dart';
import 'package:new_learn/core/logger/app_talker.dart'; import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/providers/network_status_provider.dart'; import 'package:new_learn/core/providers/network_status_provider.dart';
import 'package:new_learn/core/providers/privacy_provider.dart'; import 'package:new_learn/core/providers/privacy_provider.dart';
import 'package:new_learn/core/report/report_redirect.dart';
import 'package:new_learn/core/router/app_router.dart'; import 'package:new_learn/core/router/app_router.dart';
import 'package:new_learn/core/services/local_notification_service.dart'; import 'package:new_learn/core/services/local_notification_service.dart';
import 'package:new_learn/core/utils/screen_adapt_template.dart'; // 复用 AppAdapt 适配配置 import 'package:new_learn/core/utils/screen_adapt_template.dart'; // 复用 AppAdapt 适配配置
@@ -89,8 +90,12 @@ class TabApp extends StatelessWidget {
// 这样冷启动时不会先看到登录页/主页闪一下,再被路由重定向到隐私页, // 这样冷启动时不会先看到登录页/主页闪一下,再被路由重定向到隐私页,
// 而是直接由全屏拦截页先接管。 // 而是直接由全屏拦截页先接管。
builder: (context, child) { builder: (context, child) {
final content = // ReportRedirectNotifier 必须包在 MaterialApp 里面(能拿到 GoRouterState)
PrivacyStartupGate(child: child ?? const SizedBox.shrink()); // 但在 PrivacyStartupGate 外层(隐私同意后的页面才能拦截)
final withRedirect = ReportRedirectNotifier(
child: child ?? const SizedBox.shrink(),
);
final content = PrivacyStartupGate(child: withRedirect);
final bootstrapped = _LocalNotificationBootstrapper(child: content); final bootstrapped = _LocalNotificationBootstrapper(child: content);
if (kReleaseMode) return bootstrapped; if (kReleaseMode) return bootstrapped;
return TalkerWrapper( return TalkerWrapper(
+65
View File
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【CenterToast】居中轻量提示 —— 对标 uni.showToast(icon: 'none')
///
/// 一、适用场景:
/// 表单校验失败("请填写账号"/"请填写密码")、轻量成功提示等
/// 不需要用户确认、1.2s 自动消失、不阻塞交互(IgnorePointer)。
///
/// 二、与 SnackBar / ConfirmBox 的分工:
/// CenterToast → 短暂轻量提示,自动消失,中间弹出
/// SnackBar → 底部提示(信息可能较长),可手动关闭
/// ConfirmBox → 需要用户决策的确认框,双按钮
///
/// 三、用法:
/// CenterToast.show(context, '请填写账号');
/// ============================================================
class CenterToast {
CenterToast._();
static OverlayEntry? _current;
static void show(
BuildContext context,
String msg, {
Duration duration = const Duration(milliseconds: 1200),
}) {
// 连续调用时先移除上一个,避免叠加
_current?.remove();
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (_) => Positioned.fill(
child: IgnorePointer(
child: Center(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 14.h),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
msg,
style: TextStyle(
fontSize: 14.sp,
color: Colors.white,
decoration: TextDecoration.none,
),
),
),
),
),
),
);
_current = entry;
Overlay.of(context).insert(entry);
Future<void>.delayed(duration, () {
if (entry.mounted && _current == entry) {
entry.remove();
_current = null;
}
});
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【SubjectTag】学科标签(对标 Vue components/SubjectTag/SubjectTag.vue)
///
/// 四学科配色(与 Vue subjectMap 一致),未知学科兜底数学蓝:
/// 数学 #3D7DF5 / #E5F0FF 物理 #A45CFF / #F5ECFF
/// 英语 #E5A301 / #FFF6E3 化学 #2DB985 / #E5FAF3
///
/// 描边:Vue 用 ::before + mask-composite 画 2rpx 的学科色渐变描边
/// (两端 60% 学科色、中间 60% 近白色);Flutter 用"外层渐变容器 +
/// 内层底色容器留 1px 边"的嵌套方案等价实现。
///
/// 原 class 模块私有组件,因错题本/我的收藏列表也需要,按项目规范
/// 提升为 core/components 全局公共组件。
/// ============================================================
class SubjectTag extends StatelessWidget {
const SubjectTag({super.key, required this.subject});
final String subject;
static const Map<String, _SubjectColor> _map = {
// 数学:描边 rgba(90,158,255,.6) → rgba(242,248,255,.6)
'数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF, 0x995A9EFF, 0x99F2F8FF),
// 物理:描边 rgba(182,127,255,.6) → rgba(252,246,255,.6)
'物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF, 0x99B67FFF, 0x99FCF6FF),
// 英语:描边 rgba(247,194,82,.6) → rgba(255,251,243,.6)
'英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3, 0x99F7C252, 0x99FFFBF3),
// 化学:描边 rgba(91,209,165,.6) → rgba(240,255,248,.6)
'化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3, 0x995BD1A5, 0x99F0FFF8),
};
@override
Widget build(BuildContext context) {
final t = subject.trim();
if (t.isEmpty) return const SizedBox.shrink();
final c =
_map[t] ??
const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF, 0x995A9EFF, 0x99F2F8FF);
// Vue border-radius: 12rpx
final outerRadius = BorderRadius.circular(6.r);
// Vue ::before padding: 2rpx(描边厚度)
final borderWidth = 1.w;
return Container(
// 外层:学科色横向渐变,露出的 1px 边即"渐变描边"
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(c.borderA), Color(c.borderB), Color(c.borderA)],
),
borderRadius: outerRadius,
),
child: Container(
margin: EdgeInsets.all(borderWidth),
// Vue padding: 4rpx 14rpx,扣除 2rpx 描边后内层 2rpx 12rpx
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.h),
decoration: BoxDecoration(
color: Color(c.bg),
borderRadius: BorderRadius.circular(6.r - borderWidth),
),
// Vue white-space: nowrap:学科名不换行
child: Text(
t,
softWrap: false,
style: TextStyle(fontSize: 11, color: Color(c.fg)),
),
),
);
}
}
class _SubjectColor {
const _SubjectColor(this.fg, this.bg, this.borderA, this.borderB);
final int fg; // 文字色
final int bg; // 底色
final int borderA; // 渐变描边两端色(60% 学科色)
final int borderB; // 渐变描边中间色(60% 近白)
}
+14 -3
View File
@@ -30,6 +30,7 @@ class CurrentUserExtension {
this.userAvatarReason = '', this.userAvatarReason = '',
this.isShowGuide = false, this.isShowGuide = false,
this.isShowReport = false, this.isShowReport = false,
this.isShowAfterReport = false,
this.id = 0, this.id = 0,
}); });
@@ -48,6 +49,9 @@ class CurrentUserExtension {
final String userAvatarReason; final String userAvatarReason;
final bool isShowGuide; final bool isShowGuide;
final bool isShowReport; final bool isShowReport;
/// 是否显示出门测试报告(默认 false,后端置 true 后,用户完成报告查看后置回 false)
final bool isShowAfterReport;
final int id; final int id;
/// JSON → 对象(服务器响应) /// JSON → 对象(服务器响应)
@@ -74,6 +78,7 @@ class CurrentUserExtension {
userAvatarReason: payload['userAvatarReason'] as String? ?? '', userAvatarReason: payload['userAvatarReason'] as String? ?? '',
isShowGuide: payload['isShowGuide'] as bool? ?? false, isShowGuide: payload['isShowGuide'] as bool? ?? false,
isShowReport: payload['isShowReport'] as bool? ?? false, isShowReport: payload['isShowReport'] as bool? ?? false,
isShowAfterReport: payload['isShowAfterReport'] as bool? ?? false,
id: _toInt(payload['id']), id: _toInt(payload['id']),
); );
} }
@@ -96,12 +101,17 @@ class CurrentUserExtension {
'userAvatarReason': userAvatarReason, 'userAvatarReason': userAvatarReason,
'isShowGuide': isShowGuide, 'isShowGuide': isShowGuide,
'isShowReport': isShowReport, 'isShowReport': isShowReport,
'isShowAfterReport': isShowAfterReport,
'id': id, 'id': id,
}; };
} }
/// 局部更新(目前只用得上 avatar:头像上传成功后替换 URL) /// 局部更新(目前只用得上 avatar / isShowReport / isShowAfterReport)
CurrentUserExtension copyWith({String? avatar}) { CurrentUserExtension copyWith({
String? avatar,
bool? isShowReport,
bool? isShowAfterReport,
}) {
return CurrentUserExtension( return CurrentUserExtension(
userId: userId, userId: userId,
userName: userName, userName: userName,
@@ -117,7 +127,8 @@ class CurrentUserExtension {
userAvatarStatus: userAvatarStatus, userAvatarStatus: userAvatarStatus,
userAvatarReason: userAvatarReason, userAvatarReason: userAvatarReason,
isShowGuide: isShowGuide, isShowGuide: isShowGuide,
isShowReport: isShowReport, isShowReport: isShowReport ?? this.isShowReport,
isShowAfterReport: isShowAfterReport ?? this.isShowAfterReport,
id: id, id: id,
); );
} }
+1 -1
View File
@@ -38,7 +38,7 @@ import 'package:talker_dio_logger/talker_dio_logger.dart';
/// ============================================================ /// ============================================================
class DioClient { class DioClient {
/// 网络日志总开关:true = 控制台打印请求/响应数据,false = 静默 /// 网络日志总开关:true = 控制台打印请求/响应数据,false = 静默
static const bool enableNetworkLog = false; static const bool enableNetworkLog = true;
// ---------- 1. 单例:整个 App 只有一个 Dio ---------- // ---------- 1. 单例:整个 App 只有一个 Dio ----------
DioClient._() DioClient._()
+44
View File
@@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:new_learn/core/api/auth/api.dart';
import 'package:new_learn/core/models/user_model.dart'; import 'package:new_learn/core/models/user_model.dart';
import 'package:new_learn/core/storage/user_storage.dart'; import 'package:new_learn/core/storage/user_storage.dart';
@@ -86,6 +87,49 @@ class CurrentUserNotifier extends AsyncNotifier<CurrentUserExtension?> {
// 也还是最新头像。 // 也还是最新头像。
await UserStorage.instance.saveUserInfo(updated); await UserStorage.instance.saveUserInfo(updated);
} }
/// 强制从服务器拉最新用户信息(对齐 Vue App.vue 的 fetchCurrentUserExtension(true))
///
/// 使用场景:
/// 1. App 回前台(WidgetsBindingObserver resumed):拿最新 isShowReport / isShowAfterReport;
/// 2. 报告页调完 ChangeShowReport / ChangeShowAfterReport 后:刷新全局状态;
/// 3. 登录成功后:确保后端最新数据。
Future<CurrentUserExtension?> refresh() async {
try {
final ext = await UserApi.getCurrentUserExtension();
state = AsyncData(ext);
await UserStorage.instance.saveUserInfo(ext);
return ext;
} catch (_) {
// 网络失败:保持现有状态,不要清空(可能本来就是 AsyncData)
return state.value;
}
}
/// 只改本地报告标记为 false(studentInfo subjectReports 为空时降级处理)
///
/// 对应 Vue studentInfo.vue 的:
/// userInfo.isShowReport = false; uni.setStorageSync('userInfo', userInfo);
/// app.setUserInfo(Object.assign({}, app.globalData.userInfo, { isShowReport: false }));
///
/// 注意:不走后端接口!因为 subjectReports 为空意味着后端可能也没报告数据,
/// 直接本地置 false 避免拦截器下次又打回来。
Future<void> markShowReportFalse() async {
final current = state.value;
if (current == null) return;
final updated = current.copyWith(isShowReport: false);
state = AsyncData(updated);
await UserStorage.instance.saveUserInfo(updated);
}
/// 只改本地出门测试报告标记为 false(降级场景用)
Future<void> markShowAfterReportFalse() async {
final current = state.value;
if (current == null) return;
final updated = current.copyWith(isShowAfterReport: false);
state = AsyncData(updated);
await UserStorage.instance.saveUserInfo(updated);
}
} }
/// 登录态变化信号(给 go_router 的 refreshListenable 用) /// 登录态变化信号(给 go_router 的 refreshListenable 用)
+184
View File
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/providers/user_provider.dart';
/// ============================================================
/// 【报告状态全局拦截 + App 生命周期监听】
///
/// 对标 Vue App.vue 的 checkAndRedirectToReport() + onShow force 拉 userInfo。
///
/// 一、拦截时机(两处):
/// 1. userProvider 状态变化(set() / refresh())→ _checkAndRedirect()
/// :登录成功 / refresh 后都自动检查;
/// 2. App 回前台(WidgetsBindingObserver.resumed)→ force refresh() → 自动检查
/// :从胶囊面板/后台回来时拉最新 isShowReport;
///
/// 二、拦截条件(全部满足才跳):
/// ✅ 有登录态(DioClient.accessToken != null)
/// ✅ isShowReport=true 或 isShowAfterReport=true
/// ✅ 当前路由在 tab 页面(动态判断,不硬编码列表)
/// —— go_router 的 StatefulShellRoute.indexedStack 内的路由就是 tab 页
///
/// 三、动态判断 tab 页面的方法(不硬编码):
/// 通过 GoRouterState 的 matchedLocation,判断是否能匹配到 ShellBranch 内的路径
/// (即 /, /recite, /class, /my 这四个——但这些路径是由路由配置决定的,
/// 所以判断逻辑跟着路由配置走,别人改 tab 路径这里自动生效)。
///
/// ============================================================
/// 是否为 tab 路由(动态判断:4 个 tab 的路径由路由配置决定)
///
/// go_router 的 StatefulShellBranch 内配置了 4 条路径,
/// 直接从路由表读取所有 ShellBranch 的 path 集合,而不是硬编码。
/// 但 ShellBranch 的 routes 里只有 GoRoute,path 是字段。
/// 所以这里采用一个更稳的方式:判断当前是否在 StatefulShellRoute 里,
/// 或者直接在 app_router.dart 里暴露一个 tabPaths 常量。
///
/// 目前路由表固定,这里用 tabPaths 常量(从 app_router 导出)。
import 'package:new_learn/core/router/app_router.dart';
/// 拦截器:当 userProvider 变化时检查是否需要跳报告页
class ReportRedirectNotifier extends ConsumerStatefulWidget {
const ReportRedirectNotifier({super.key, required this.child});
final Widget child;
@override
ConsumerState<ReportRedirectNotifier> createState() =>
_ReportRedirectNotifierState();
}
class _ReportRedirectNotifierState extends ConsumerState<ReportRedirectNotifier>
with WidgetsBindingObserver {
/// 防止重复跳转标记
bool _isRedirecting = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
talker.debug('[ReportRedirect] initState 🔥 被创建了');
// 监听 userProvider 后续变化
ref.listenManual(currentUserProvider, (prev, next) {
talker.debug('[ReportRedirect] listenManual 触发!prev=$prev next=$next');
_checkAndRedirect();
});
// 立即检查一次!listenManual 只监听变化不会主动触发,
// 登录后 userProvider 已经是 AsyncData 状态了,必须主动调。
WidgetsBinding.instance.addPostFrameCallback((_) {
talker.debug(
'[ReportRedirect] addPostFrameCallback → 主动调 _checkAndRedirect',
);
_checkAndRedirect();
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
/// App 生命周期变化:resumed 时 force 拉最新 userInfo
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed) {
_onAppResumed();
}
}
Future<void> _onAppResumed() async {
// 只有已登录才 force 拉
if (DioClient.instance.accessToken == null) return;
talker.debug('[ReportRedirect] App resumed → force refresh userInfo');
await ref.read(currentUserProvider.notifier).refresh();
_checkAndRedirect();
}
/// 核心拦截逻辑:判断当前路由 + 报告标记 → 决定跳不跳
void _checkAndRedirect() {
talker.debug('[ReportRedirect] ===== _checkAndRedirect 开始 ====');
if (_isRedirecting) {
talker.debug('[ReportRedirect] _isRedirecting=true,跳过');
return;
}
final user = ref.read(currentUserProvider).value;
if (user == null) {
talker.debug('[ReportRedirect] user=null(未登录),放行');
return;
}
// TODO: 调试报告用,把后端字段叠加调试开关,上线前删掉下面三行
// const _debugExitTest = false;
// const _debugPlacementTest = false;
const _debugExitTest = false; // true = 强制调试出门测
const _debugPlacementTest = false; // true = 强制调试入门测
talker.debug(
'[ReportRedirect] user.isShowReport=${user.isShowReport} '
'user.isShowAfterReport=${user.isShowAfterReport} '
'debugExit=$_debugExitTest debugPlacement=$_debugPlacementTest',
);
if (!user.isShowReport &&
!user.isShowAfterReport &&
!_debugExitTest &&
!_debugPlacementTest) {
talker.debug('[ReportRedirect] ❌ 无报告标记+无调试开关,放行');
return;
}
final isExitTest = user.isShowAfterReport || _debugExitTest;
final reportType = isExitTest
? 'exitTest'
: (user.isShowReport || _debugPlacementTest ? 'placementTest' : null);
if (reportType == null) {
talker.debug('[ReportRedirect] ❌ reportType=null,放行');
return;
}
talker.debug('[ReportRedirect] ✅ 通过标记检查,reportType=$reportType');
// 当前路由是否在 tab 页面(直接从全局 appRouter 拿,不需要 context)
String location = appRouter.state?.matchedLocation ?? '';
talker.debug('[ReportRedirect] appRouter.state matchedLocation=$location');
final inTab = _isInTabRoute(location);
talker.debug(
'[ReportRedirect] location=$location kTabPaths=$kTabPaths inTab=$inTab',
);
if (!inTab) {
talker.debug('[ReportRedirect] ❌ 不在 tab 页面,跳过拦截');
return;
}
talker.debug('[ReportRedirect] ✅✅✅ 触发跳转 → studentInfo?type=$reportType');
_isRedirecting = true;
try {
appRouter.go('/studentInfo', extra: {'type': reportType});
talker.debug('[ReportRedirect] appRouter.go 调用成功');
} catch (e) {
talker.error('[ReportRedirect] appRouter.go 崩了!', e);
}
// 重置标记(go_router 会重建 Widget,这个 State 会被 dispose)
Future.delayed(const Duration(milliseconds: 100), () {
_isRedirecting = false;
});
}
/// 判断当前路径是否在 tab 路由里(动态,不硬编码)
///
/// go_router 的 StatefulShellRoute 下的 4 个 ShellBranch 里,
/// 第一个 GoRoute 的 path 就是 tab 路径。我们从 app_router 导出的
/// tabPaths 集合判断,别人改 tab 路径这里自动生效。
bool _isInTabRoute(String location) {
return kTabPaths.contains(location);
}
@override
Widget build(BuildContext context) => widget.child;
}
+104 -4
View File
@@ -6,14 +6,33 @@ import 'package:new_learn/features/auth/legal_document_page.dart';
import 'package:new_learn/features/auth/legal_documents.dart'; import 'package:new_learn/features/auth/legal_documents.dart';
import 'package:new_learn/features/auth/login_page.dart'; import 'package:new_learn/features/auth/login_page.dart';
import 'package:new_learn/features/auth/privacy_consent_page.dart'; import 'package:new_learn/features/auth/privacy_consent_page.dart';
import 'package:new_learn/features/auth/register_page.dart';
import 'package:new_learn/features/class/all_student_view.dart'; import 'package:new_learn/features/class/all_student_view.dart';
import 'package:new_learn/features/class/class_page.dart'; import 'package:new_learn/features/class/class_page.dart';
import 'package:new_learn/features/class/my_interact_page.dart'; import 'package:new_learn/features/class/my_interact_page.dart';
import 'package:new_learn/features/class/my_update_detail_page.dart'; import 'package:new_learn/features/class/my_update_detail_page.dart';
import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/features/my/my_page.dart'; import 'package:new_learn/features/my/my_page.dart';
import 'package:new_learn/features/my/note/note_page.dart';
import 'package:new_learn/features/conquerPackage/captureList/index.dart';
import 'package:new_learn/features/my/my_point/my_point_page.dart'; import 'package:new_learn/features/my/my_point/my_point_page.dart';
import 'package:new_learn/features/my/question_list/question_list_page.dart';
import 'package:new_learn/features/my/my_report/my_report_page.dart';
import 'package:new_learn/features/my/my_report/report_detail_page.dart';
import 'package:new_learn/features/my/exam/exam_page.dart';
import 'package:new_learn/features/my/settings/settings_page.dart';
import 'package:new_learn/features/my/settings/about_app_page.dart';
import 'package:new_learn/core/api/lexicon/model.dart';
import 'package:new_learn/features/recite/chapters_page.dart';
import 'package:new_learn/features/recite/level_over_page.dart';
import 'package:new_learn/features/recite/level_start_page.dart';
import 'package:new_learn/features/recite/levels_page.dart';
import 'package:new_learn/features/recite/recite_args.dart';
import 'package:new_learn/features/recite/recite_page.dart'; import 'package:new_learn/features/recite/recite_page.dart';
import 'package:new_learn/features/recite/textbook_list_page.dart';
import 'package:new_learn/features/report/report_computation_page.dart';
import 'package:new_learn/features/report/report_web_view_page.dart';
import 'package:new_learn/features/report/student_info_page.dart';
import 'package:new_learn/features/task/task_page.dart'; import 'package:new_learn/features/task/task_page.dart';
import 'package:new_learn/core/router/main_tab_page.dart'; import 'package:new_learn/core/router/main_tab_page.dart';
import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/network/dio_client.dart';
@@ -64,6 +83,12 @@ import 'package:new_learn/core/providers/user_provider.dart';
/// 必须在创建 GoRouter 前全局开启,见 main.dart 的 main()。 /// 必须在创建 GoRouter 前全局开启,见 main.dart 的 main()。
/// ============================================================ /// ============================================================
/// 【kTabPaths】四个底部 tab 路径集合(动态来源,别人改 tab 路由时同步改这里即可)
///
/// 全局拦截器 ReportRedirectNotifier 用它判断"当前是否在 tab 页面",
/// 避免硬编码分散在多个地方。
const kTabPaths = <String>{'/', '/recite', '/class', '/my'};
/// 全局路由表(对标 Vue 的 router/index.ts) /// 全局路由表(对标 Vue 的 router/index.ts)
final GoRouter appRouter = GoRouter( final GoRouter appRouter = GoRouter(
navigatorKey: navigatorKey:
@@ -122,6 +147,42 @@ final GoRouter appRouter = GoRouter(
MyUpdateDetailPage(initialPost: state.extra as UserPostDto), MyUpdateDetailPage(initialPost: state.extra as UserPostDto),
), ),
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()), GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
// 我的笔记
GoRoute(path: '/myNote', builder: (_, _) => const NotePage()),
// 错题本(type=2)/ 我的收藏(type=1)共用 QuestionListPage
GoRoute(
path: '/myWrong',
builder: (_, _) => const QuestionListPage(type: 2),
),
GoRoute(
path: '/myCollect',
builder: (_, _) => const QuestionListPage(type: 1),
),
GoRoute(path: '/myReport', builder: (_, _) => const MyReportPage()),
GoRoute(path: '/myExam', builder: (_, _) => const ExamPage()),
GoRoute(
path: '/report-detail',
builder: (_, state) {
// extra 格式:{'title': '...', 'url': '...'}
final extra = state.extra as Map<String, dynamic>? ?? {};
return ReportDetailPage(
title: extra['title'] as String? ?? '我的报告',
url: extra['url'] as String? ?? '',
);
},
),
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()),
// ---------- 诊断报告包三个页面(不在 ShellBranch 内,push 时不显示 tabbar) ----------
GoRoute(path: '/studentInfo', builder: (_, _) => const StudentInfoPage()),
GoRoute(
path: '/reportWebView',
builder: (_, _) => const ReportWebViewPage(),
),
GoRoute(
path: '/reportComputation',
builder: (_, _) => const ReportComputationPage(),
),
// ---------- 首次启动隐私协议页 ---------- // ---------- 首次启动隐私协议页 ----------
GoRoute( GoRoute(
path: '/privacy-consent', path: '/privacy-consent',
@@ -141,8 +202,44 @@ final GoRouter appRouter = GoRouter(
content: kPrivacyPolicyContent, content: kPrivacyPolicyContent,
), ),
), ),
// ---------- 登录页 ---------- // ---------- 登录/注册页(未登录可访问) ----------
GoRoute(path: '/login', builder: (_, _) => const LoginPage()), GoRoute(path: '/login', builder: (_, _) => const LoginPage()),
// ---------- 攻克包 - 截图名单 ----------
GoRoute(
path: '/conquerPackage/captureList',
builder: (_, state) => CaptureListPage(courseId: state.extra as String),
),
GoRoute(path: '/register', builder: (_, _) => const RegisterPage()),
// ---------- 背诵模块二级页(不在 ShellBranch 内,push 时不显示 tabbar) ----------
// 教材列表
GoRoute(
path: '/recite/textbooks',
builder: (_, _) => const TextbookListPage(),
),
// 词汇章节(bookId 走路径参数,替代 Vue 缓存)
GoRoute(
path: '/recite/chapters/:bookId',
builder: (_, state) => ChaptersPage(
bookId: int.parse(state.pathParameters['bookId']!),
),
),
// 关卡列表(Chapter 对象走 extra)
GoRoute(
path: '/recite/levels',
builder: (_, state) => LevelsPage(chapter: state.extra as Chapter),
),
// 答题页(LevelStartArgs 显式传参,替代 curChapterInfo 缓存)
GoRoute(
path: '/recite/level-start',
builder: (_, state) =>
LevelStartPage(args: state.extra as LevelStartArgs),
),
// 学习小结
GoRoute(
path: '/recite/level-over',
builder: (_, state) => LevelOverPage(args: state.extra as LevelOverArgs),
),
// app_router.dart 的 routes 里加,不用管跳转逻辑 // app_router.dart 的 routes 里加,不用管跳转逻辑
// GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()), // GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()),
// ---------- 以后新业务页在这里加,一行一个 ---------- // ---------- 以后新业务页在这里加,一行一个 ----------
@@ -150,7 +247,7 @@ final GoRouter appRouter = GoRouter(
); );
/// 登录守卫(对标 vue-router beforeEach): /// 登录守卫(对标 vue-router beforeEach):
/// 没登录 → 强制去 /login;已登录还访问 /login → 踢回首页 /// 没登录 → 强制去 /login;已登录还访问 /login 或 /register → 踢回首页
String? _guardLogin(BuildContext context, GoRouterState state) { String? _guardLogin(BuildContext context, GoRouterState state) {
final acceptedPrivacy = privacyAcceptedNotifier.value; final acceptedPrivacy = privacyAcceptedNotifier.value;
final loggedIn = DioClient.instance.accessToken != null; final loggedIn = DioClient.instance.accessToken != null;
@@ -159,11 +256,14 @@ String? _guardLogin(BuildContext context, GoRouterState state) {
state.matchedLocation == '/user-agreement' || state.matchedLocation == '/user-agreement' ||
state.matchedLocation == '/privacy-policy'; state.matchedLocation == '/privacy-policy';
final atLogin = state.matchedLocation == '/login'; final atLogin = state.matchedLocation == '/login';
final atRegister = state.matchedLocation == '/register';
// 未登录可白名单:隐私协议页 / 协议文档 / 登录页 / 注册页
final atAuthFree = atLogin || atRegister || atLegalDocument;
if (!acceptedPrivacy && !atPrivacyConsent && !atLegalDocument) { if (!acceptedPrivacy && !atPrivacyConsent && !atLegalDocument) {
return '/privacy-consent'; return '/privacy-consent';
} }
if (acceptedPrivacy && atPrivacyConsent) return loggedIn ? '/' : '/login'; if (acceptedPrivacy && atPrivacyConsent) return loggedIn ? '/' : '/login';
if (!loggedIn && !atLogin && !atLegalDocument) return '/login'; if (!loggedIn && !atAuthFree && !atPrivacyConsent) return '/login';
if (loggedIn && atLogin) return '/'; if (loggedIn && (atLogin || atRegister)) return '/';
return null; return null;
} }
+68
View File
@@ -4,6 +4,44 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:new_learn/core/models/user_model.dart'; import 'package:new_learn/core/models/user_model.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// ============================================================
/// 【LoginHistoryItem】登录历史单条记录 —— 对齐 Vue loginHistory
///
/// account → 登录账号(唯一标识,重复登录时去重用)
/// userName → 登录后拿到的昵称(展示用,可能为空)
/// password → 记住的密码(明文,存在 secure_storage 里一条 JSON)
/// 空串 "" 表示本次没勾选记住密码
/// rememberPassword → 是否勾选了记住密码(决定密码是否回填)
/// ============================================================
class LoginHistoryItem {
const LoginHistoryItem({
required this.account,
required this.userName,
required this.password,
required this.rememberPassword,
});
final String account;
final String userName;
final String password;
final bool rememberPassword;
Map<String, dynamic> toJson() => {
'account': account,
'userName': userName,
'password': password,
'rememberPassword': rememberPassword,
};
factory LoginHistoryItem.fromJson(Map<String, dynamic> json) =>
LoginHistoryItem(
account: json['account'] as String? ?? '',
userName: json['userName'] as String? ?? '',
password: json['password'] as String? ?? '',
rememberPassword: json['rememberPassword'] as bool? ?? false,
);
}
/// ============================================================ /// ============================================================
/// 【UserStorage】用户本地存储(token 安全存储 + 用户信息缓存) /// 【UserStorage】用户本地存储(token 安全存储 + 用户信息缓存)
/// ///
@@ -118,6 +156,36 @@ class UserStorage {
Future<void> clearRememberedPassword() => Future<void> clearRememberedPassword() =>
_secure.delete(key: _rememberedPasswordKey); _secure.delete(key: _rememberedPasswordKey);
// ---------- 登录历史(多条,最多 3 条,对齐 Vue loginHistory) ----------
static const String _loginHistoryKey = 'login_history';
static const int _maxHistoryCount = 3;
/// 读取登录历史(没存过返回空列表)
Future<List<LoginHistoryItem>> readLoginHistory() async {
final raw = await _secure.read(key: _loginHistoryKey);
if (raw == null) return [];
try {
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => LoginHistoryItem.fromJson(e as Map<String, dynamic>))
.toList();
} catch (_) {
return []; // 缓存损坏时当没历史处理
}
}
/// 保存登录历史(整体替换存,最多保留 3 条)
Future<void> saveLoginHistory(List<LoginHistoryItem> items) async {
final trimmed = items.length > _maxHistoryCount
? items.sublist(0, _maxHistoryCount)
: items;
await _secure.write(
key: _loginHistoryKey,
value: jsonEncode(trimmed.map((e) => e.toJson()).toList()),
);
}
/// 一键退出登录:清 Token + 用户信息 /// 一键退出登录:清 Token + 用户信息
/// (记住的账号密码保留,方便用户下次登录) /// (记住的账号密码保留,方便用户下次登录)
Future<void> clearAll() async { Future<void> clearAll() async {
+28
View File
@@ -0,0 +1,28 @@
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【学科图标常量】
///
/// 对标 Vue utils/constant.js 的 SUBJECT_ICON_MAP。
/// 我的报告(my_report)、我的考试(exam)等模块共用,
/// 通过学科名(如「数学」「英语」)动态取对应图标。
/// ============================================================
/// 学科名 → 图标资源路径
const Map<String, String> kSubjectIconMap = {
'语文': '/StaticResource/2026-06-11/shuxue1781163770612.png',
'化学': '/StaticResource/2026-06-11/huaxue1781163828443.png',
'英语': '/StaticResource/2026-06-11/yingyu1781163725441.png',
'物理': '/StaticResource/2026-06-11/wuli1781148438346.png',
'数学': '/StaticResource/2026-06-11/shuxue1781163770612.png',
'生物': '/StaticResource/2026-06-11/shengwu1781163791768.png',
'历史': '/StaticResource/2026-06-11/lishi1781163811822.png',
'政治': '/StaticResource/2026-06-11/zhengzhi1781163750422.png',
'地理': '/StaticResource/2026-06-11/dili1781163844311.png',
};
/// 取学科图标完整 URL(无匹配时用数学兜底,对标 Vue getSubjectIcon)
String subjectIconUrl(String subjectName) {
final path = kSubjectIconMap[subjectName] ?? kSubjectIconMap['数学']!;
return ImgUrl.build(path);
}
File diff suppressed because it is too large Load Diff
+429
View File
@@ -0,0 +1,429 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/auth/api.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/components/confirm_box.dart';
/// ============================================================
/// 【RegisterPage】注册页 —— 对照 Vue 版 pages/login/register.vue
///
/// 一、表单字段(3 个,带校验):
/// 账号 6-16 位字母或数字 → /^[a-zA-Z0-9]{6,16}$/
/// 密码 6-16 位字母或数字 → /^[a-zA-Z0-9]{6,16}$/
/// 确认密码 必须与密码一致
///
/// 二、注册流程:
/// isFormValid → 协议勾选 → 调 UserApi.register({account, password})
/// → 成功 Toast "注册成功" → 1 秒后返回登录页
///
/// 三、与 Vue 的对应关系:
/// validateField / validateAll → Flutter 版字段级校验(_validateXxx)
/// isFormValid computed → Flutter 版 _isFormValid getter
/// debounce 防重复提交 → Flutter 用 _loading 标记防连点
/// 密码 MD5 → UserApi.register 内部已处理
///
/// 四、UI 风格:
/// 输入框对齐登录页(灰底 #F9F9F9 + 圆角 24.r),顶部 AppBar 标题"注册账号"。
/// ============================================================
/// 账号/密码校验正则:6-16 位字母或数字(对齐 Vue register.vue 的 validateField)
final _kAccountRegex = RegExp(r'^[a-zA-Z0-9]{6,16}$');
class RegisterPage extends ConsumerStatefulWidget {
const RegisterPage({super.key});
@override
ConsumerState<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends ConsumerState<RegisterPage> {
// ---------- 表单控制器 ----------
final TextEditingController _accountController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController =
TextEditingController();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
bool _hasAgreed = false;
bool _loading = false;
// ---------- 错误信息(字段级,与 Vue errors 对齐) ----------
String _accountError = '';
String _passwordError = '';
String _confirmPasswordError = '';
@override
void dispose() {
_accountController.dispose();
_passwordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
// ==================== 校验(对齐 Vue validateField) ====================
String _validateAccount() {
final v = _accountController.text.trim();
if (v.isEmpty) return '请填写账号';
if (!_kAccountRegex.hasMatch(v)) return '6-16 位字母或数字';
return '';
}
String _validatePassword() {
final v = _passwordController.text.trim();
if (v.isEmpty) return '请填写密码';
if (!_kAccountRegex.hasMatch(v)) return '6-16 位字母或数字';
// 密码变化时重校验确认密码
if (_confirmPasswordController.text.isNotEmpty) {
_confirmPasswordError = _confirmPasswordController.text == v
? ''
: '两次输入的密码不一致';
}
return '';
}
String _validateConfirmPassword() {
final v = _confirmPasswordController.text;
if (v.isEmpty) return '请再次输入密码';
if (v != _passwordController.text) return '两次输入的密码不一致';
return '';
}
/// 表单是否完整有效(对齐 Vue isFormValid computed)
bool get _isFormValid {
return _kAccountRegex.hasMatch(_accountController.text.trim()) &&
_kAccountRegex.hasMatch(_passwordController.text.trim()) &&
_confirmPasswordController.text.isNotEmpty &&
_confirmPasswordController.text == _passwordController.text;
}
// ==================== 注册逻辑 ====================
Future<void> _handleRegister() async {
if (_loading) return;
// 全量校验
_accountError = _validateAccount();
_passwordError = _validatePassword();
_confirmPasswordError = _validateConfirmPassword();
setState(() {});
if (!_isFormValid) return;
// 未勾选协议 → 弹确认框(对齐 Vue register.vue 的协议弹窗)
if (!_hasAgreed) {
final agreed = await showConfirmBox(
context,
title: '用户协议及隐私条款',
content:
'感谢您选择AI爱学蝶变!我们非常重视您的个人信息和隐私保护。'
'为了更好地保障您的个人权益,在您使用我们的产品前请务必审慎阅读'
'《隐私政策》和《用户协议》内的所有条款。同意并继续开始使用我们的产品和服务!',
cancelText: '不同意',
confirmText: '同意并继续',
);
if (!agreed || !mounted) return;
setState(() => _hasAgreed = true);
}
setState(() => _loading = true);
try {
await UserApi.register(
account: _accountController.text.trim(),
password: _passwordController.text,
);
if (!mounted) return;
_toast('注册成功');
// 1 秒后回到登录页(context.go 替换当前栈,最稳不依赖 canPop)
Future<void>.delayed(const Duration(seconds: 1), () {
if (mounted) context.go('/login');
});
} on ApiException catch (e) {
if (mounted) _toast(e.message);
} catch (e) {
if (mounted) _toast('注册失败,请稍后重试');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _toast(String msg) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
);
}
// ==================== 导航 ====================
void _goToAgreement() => context.push('/user-agreement');
void _goToPrivacy() => context.push('/privacy-policy');
// ==================== UI ====================
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text('注册账号', style: TextStyle(color: Color(0xFF323233))),
iconTheme: const IconThemeData(color: Color(0xFF323233)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () =>
context.canPop() ? context.pop() : context.go('/login'),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 25.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: 20.h),
// 账号
_buildField(
label: '账号',
controller: _accountController,
hintText: '请输入您的账号',
errorText: _accountError,
onChanged: (_) =>
setState(() => _accountError = _validateAccount()),
),
SizedBox(height: 12.h),
// 密码
_buildField(
label: '密码',
controller: _passwordController,
hintText: '请输入您的密码',
obscureText: _obscurePassword,
errorText: _passwordError,
onChanged: (_) => setState(() {
_passwordError = _validatePassword();
}),
onToggleObscure: () =>
setState(() => _obscurePassword = !_obscurePassword),
),
SizedBox(height: 12.h),
// 确认密码
_buildField(
label: '确认密码',
controller: _confirmPasswordController,
hintText: '请再次输入密码',
obscureText: _obscureConfirmPassword,
errorText: _confirmPasswordError,
onChanged: (_) => setState(
() => _confirmPasswordError = _validateConfirmPassword(),
),
onToggleObscure: () => setState(
() => _obscureConfirmPassword = !_obscureConfirmPassword,
),
),
SizedBox(height: 25.h),
// 协议勾选
_buildAgreementRow(),
SizedBox(height: 20.h),
// 注册按钮
_buildRegisterButton(),
SizedBox(height: 20.h),
// 已有账号,去登录
_buildFooterLink(),
],
),
),
),
);
}
/// 通用输入字段(带 label + 灰底圆角 + 错误提示)
Widget _buildField({
required String label,
required TextEditingController controller,
required String hintText,
required String errorText,
bool obscureText = false,
ValueChanged<String>? onChanged,
VoidCallback? onToggleObscure,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 输入框容器(灰底 #F9F9F9 + 圆角 24.r,对齐登录页)
Container(
height: 48.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: const Color(0xFFF9F9F9),
borderRadius: BorderRadius.circular(24.r),
),
child: Row(
children: [
// label 文字(Vue register-page__label 140rpx 宽)
SizedBox(
width: 70.w, // 140rpx → .w
child: Text(
label,
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF323233),
),
),
),
Expanded(
child: TextField(
controller: controller,
obscureText: obscureText,
onChanged: onChanged,
style: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF323233),
),
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: hintText,
hintStyle: TextStyle(
fontSize: 14.sp,
color: const Color(0xFFC0C4CC),
),
),
),
),
if (onToggleObscure != null)
GestureDetector(
onTap: onToggleObscure,
child: Icon(
obscureText
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: const Color(0xFFC0C4CC),
size: 24,
),
),
],
),
),
// 错误提示:固定高度占位,有错误时显示文字,无错误时空白 → 输入框位置不动
SizedBox(
height: 22.h,
child: errorText.isNotEmpty
? Padding(
padding: EdgeInsets.only(left: 16.w, top: 6.h),
child: Text(
errorText,
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFFEE0A24),
),
),
)
: null,
),
],
);
}
Widget _buildAgreementRow() {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
InkWell(
onTap: () => setState(() => _hasAgreed = !_hasAgreed),
child: Icon(
_hasAgreed
? Icons.check_circle_rounded
: Icons.radio_button_unchecked,
size: 16.w,
color: _hasAgreed
? const Color(0xFF3D7DF5)
: const Color(0xFFC0C4CC),
),
),
SizedBox(width: 6.w),
Expanded(
child: RichText(
text: TextSpan(
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)),
children: [
const TextSpan(text: '我已阅读并同意'),
TextSpan(
text: '《用户协议》',
style: const TextStyle(color: Color(0xFF3D7DF5)),
recognizer: TapGestureRecognizer()..onTap = _goToAgreement,
),
const TextSpan(text: '和'),
TextSpan(
text: '《隐私政策》',
style: const TextStyle(color: Color(0xFF3D7DF5)),
recognizer: TapGestureRecognizer()..onTap = _goToPrivacy,
),
],
),
),
),
],
);
}
Widget _buildRegisterButton() {
return SizedBox(
width: double.infinity,
height: 48.h,
child: FilledButton(
onPressed: _loading ? null : _handleRegister,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF3D7DF5),
disabledBackgroundColor: _isFormValid
? const Color(0xFF3D7DF5).withValues(alpha: 0.6)
: const Color(0xFFC0C4CC), // 表单无效时整体灰色,对齐 Vue btn--disabled
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24.r),
),
),
child: _loading
? SizedBox(
width: 18.w,
height: 18.w,
child: const CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
),
)
: Text(
'注册',
style: TextStyle(fontSize: 18.sp, color: Colors.white),
),
),
);
}
Widget _buildFooterLink() {
return Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: () => context.canPop() ? context.pop() : context.go('/login'),
child: Text(
'已有账号,去登录',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF3D7DF5)),
),
),
);
}
}
@@ -6,6 +6,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/model.dart'; import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/image_preview_viewer.dart'; import 'package:new_learn/core/components/image_preview_viewer.dart';
import 'package:new_learn/core/components/resolve_problem_box.dart'; import 'package:new_learn/core/components/resolve_problem_box.dart';
import 'package:new_learn/core/components/subject_tag.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart'; import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/utils/img_url.dart'; import 'package:new_learn/core/utils/img_url.dart';
@@ -236,35 +237,6 @@ class CardHeader extends StatelessWidget {
} }
} }
/// 学科标签(对标 SubjectTag)
class SubjectTag extends StatelessWidget {
const SubjectTag({super.key, required this.subject});
final String subject;
static const Map<String, _SubjectColor> _map = {
'数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF),
'物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF),
'英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3),
'化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3),
};
@override
Widget build(BuildContext context) {
final t = subject.trim();
if (t.isEmpty) return const SizedBox.shrink();
final c = _map[t] ?? const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF);
return Container(
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h),
decoration: BoxDecoration(
color: Color(c.bg),
borderRadius: BorderRadius.circular(6.r),
),
child: Text(t, style: TextStyle(fontSize: 11, color: Color(c.fg))),
);
}
}
/// 纯文字笔记(album-desc,最多 2 行) /// 纯文字笔记(album-desc,最多 2 行)
class AlbumDesc extends StatelessWidget { class AlbumDesc extends StatelessWidget {
const AlbumDesc({super.key, required this.text}); const AlbumDesc({super.key, required this.text});
@@ -613,9 +585,3 @@ class _CommentInputState extends State<CommentInput> {
); );
} }
} }
class _SubjectColor {
const _SubjectColor(this.fg, this.bg);
final int fg;
final int bg;
}
@@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class CaptureListPage extends StatelessWidget {
const CaptureListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Align 对齐布局 Demo11111')),
// ---------- 下方即为核心代码,可直接复制到 Scaffold body ----------
body: Container(
width: 300.w,
height: 300.h,
color: Colors.grey[300],
),
);
}
}
@@ -0,0 +1,943 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:new_learn/core/api/conquerPackage/captureList/api.dart';
import 'package:new_learn/core/api/conquerPackage/captureList/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【CaptureListPage】攻克列表页 —— 对标 Vue pages/conquerPackage/captureList/index.vue
///
/// 入参:courseId(课程 id)
///
/// 接口:
/// - CaptureListApi.getCoursePack(courseId) → 列表 + remark
/// - CaptureListApi.startOvercome(packId) → 开始/继续攻克,跳转对应页面
///
/// 换算规则(本工程统一):Vue 的 `N rpx` → Flutter 的 `(N/2).w/.h/.sp/.r`
/// ============================================================
/// 攻克记录状态枚举(与后端 status 对齐:0未开始/1成功/2失败/3判题中)
enum CaptureRecordStatus {
none(0), // 未开始
success(1), // 成功
fail(2), // 失败
processing(3); // 判题中
final int value;
const CaptureRecordStatus(this.value);
factory CaptureRecordStatus.fromInt(int? v) {
return CaptureRecordStatus.values.firstWhere(
(e) => e.value == v,
orElse: () => CaptureRecordStatus.none,
);
}
}
/// 攻克包状态枚举(与后端 status 对齐)
enum CapturePackStatus {
notStarted(0), // 未开始
inProgress(1), // 进行中
conquered(2), // 已攻克
failed(3), // 攻克失败
laterStudy(4), // 后续学习
judging(5); // 判题中
final int value;
const CapturePackStatus(this.value);
factory CapturePackStatus.fromInt(int? v) {
return CapturePackStatus.values.firstWhere(
(e) => e.value == v,
orElse: () => CapturePackStatus.notStarted,
);
}
}
/// 页面内部包装模型(在 CourseInfo 基础上增加 UI 状态 isShowCaptureInfo)
class _CapturePackViewModel {
final CourseInfo info;
bool isShowCaptureInfo;
_CapturePackViewModel(this.info, {this.isShowCaptureInfo = false});
String get id => '${info.id}';
String get name => info.name;
String get baseSubjectName => info.baseSubjectName;
int get overcomeStandard => info.overcomeStandard;
int get maxOvercomeCount => info.maxOvercomeCount;
int get recordCount => info.recordCount;
CapturePackStatus get status => CapturePackStatus.fromInt(info.status);
List<CourseRecord> get records => info.records;
}
class CaptureListPage extends StatefulWidget {
const CaptureListPage({super.key, required this.courseId});
final String courseId;
@override
State<CaptureListPage> createState() => _CaptureListPageState();
}
class _CaptureListPageState extends State<CaptureListPage> {
// ========== 颜色常量 ==========
static const Color _bgColor = Color(0xFFF5F8FA);
static const Color _titleColor = Color(0xFF1D2B47);
static const Color _subtitleColor = Color(0xFF495A7A);
static const Color _primary = Color(0xFF3D7DF5);
static const Color _success = Color(0xFF00CC96);
static const Color _error = Color(0xFFFF4D4D);
static const Color _processing = Color(0xFFF5963D);
static const Color _barBg = Color(0xFFE3ECFF);
static const Color _mutedText = Color(0xFF687FAD);
// ========== 状态 ==========
String _remark = '';
bool _navigating = false; // 防止连续点击重复请求/跳转
List<String> _expandedPackIds = []; // 展开的包 id(刷新时保留展开状态)
int _requestId = 0; // 请求序号,防止并发请求错乱
@override
void initState() {
super.initState();
}
// ========== 接口 ==========
/// 分页加载:CommonPagedList 回调。接口无分页,当作一页返回,hasNext=false。
Future<PagingResult<_CapturePackViewModel>> _loadPage(int pageKey) async {
final requestId = ++_requestId;
final expandedIds = _expandedPackIds.isNotEmpty
? [..._expandedPackIds]
: <String>[];
// courseId 为空直接返回空列表
if (widget.courseId.isEmpty) {
if (!mounted) return const PagingResult(data: [], hasNext: false);
setState(() => _remark = '');
return const PagingResult(data: [], hasNext: false);
}
final res = await CaptureListApi.instance.getCoursePack(widget.courseId);
if (requestId != _requestId) {
// 已被新请求覆盖,丢弃结果(返回空让库忽略也可)
return const PagingResult(data: [], hasNext: false);
}
final list = _restoreExpandedState(res.packs, expandedIds);
if (!mounted) return PagingResult(data: list, hasNext: false);
setState(() {
_remark = res.remark;
_expandedPackIds = list
.where((item) => item.isShowCaptureInfo)
.map((item) => item.id)
.toList();
});
// 只有一页
return PagingResult(data: list, hasNext: false);
}
// ========== 展开状态管理(对标 Vue saveExpandedState / restoreExpandedState) ==========
/// 恢复展开状态 + 计算 recordCount(status != 0 的记录数)
List<_CapturePackViewModel> _restoreExpandedState(
List<CourseInfo> list,
List<String> expandedIds,
) {
final expandedIdSet = expandedIds.toSet();
return list.map((info) {
final records = info.records;
final validCount = records.where((r) => r.status != 0).length;
// 构造一个新的 CourseInfo(recordCount 取有效记录数)
final adjustedInfo = CourseInfo(
id: info.id,
name: info.name,
baseSubjectName: info.baseSubjectName,
overcomeStandard: info.overcomeStandard,
estimatedTime: info.estimatedTime,
maxOvercomeCount: info.maxOvercomeCount,
recordCount: validCount,
records: records,
status: info.status,
statusName: info.statusName,
);
final hasValidRecords = records.any((r) =>
r.status == 1 || r.status == 2 || r.status == 3);
final shouldExpand =
expandedIdSet.contains('${info.id}') || hasValidRecords;
return _CapturePackViewModel(adjustedInfo,
isShowCaptureInfo: shouldExpand);
}).toList();
}
/// 切换攻克详情展开/收起
void _toggleCaptureInfo(_CapturePackViewModel item) {
if (item.records.isEmpty) return;
final validRecords = item.records.where((r) => r.status != 0).toList();
if (validRecords.isEmpty) return;
setState(() {
item.isShowCaptureInfo = !item.isShowCaptureInfo;
if (item.isShowCaptureInfo) {
if (!_expandedPackIds.contains(item.id)) {
_expandedPackIds.add(item.id);
}
} else {
_expandedPackIds.remove(item.id);
}
});
}
// ========== 点击事件 ==========
/// 点击按钮 → 开始/继续攻克(对接 startOvercome 接口)
Future<void> _navigateToCaptureInfo(_CapturePackViewModel item) async {
if (item.status == CapturePackStatus.judging) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('该攻克正在判题中,暂不能操作'), behavior: SnackBarBehavior.floating),
);
return;
}
if (_navigating) return;
_navigating = true;
// 显示加载
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
try {
final res = await CaptureListApi.instance.startOvercome(item.id);
if (!mounted) return;
Navigator.of(context).pop(); // 关闭加载
// 跳转分支(对标 Vue navigateToCaptureInfo)
if (res.hasHandout) {
// 有讲义 → 课前课中页
_navigateTo('/conquer/beforeDuringClass', {
'status': '1',
'packId': item.id,
'courseId': widget.courseId,
'recordId': '${res.recordId}',
if (res.nowStudyObjectType == 11)
'nowStudyObjectId': '${res.nowStudyObjectId}',
});
return;
}
if (res.hasQuestion) {
// 有题目 → 过渡页 或 习题页
final pagePath = res.nowStudyObjectType == 0
? '/conquer/transitionPage'
: '/conquer/classExercises';
_navigateTo(pagePath, {
'status': '3',
'courseId': widget.courseId,
'packId': item.id,
'recordId': '${res.recordId}',
if (res.nowStudyObjectType == 12)
'nowStudyObjectId': '${res.nowStudyObjectId}',
});
return;
}
// 既没讲义也没题目
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('该攻克暂无讲义或题目'), behavior: SnackBarBehavior.floating),
);
} catch (e) {
if (mounted) {
Navigator.of(context).pop(); // 关闭加载
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('加载失败:${e.toString()}'), behavior: SnackBarBehavior.floating),
);
}
} finally {
_navigating = false;
}
}
/// 页面跳转(占位 —— 待路由统一后替换为正式路由名)
void _navigateTo(String path, Map<String, String> params) {
// TODO(路由): 替换为项目统一的路由跳转方式
debugPrint('navigateTo: $path $params');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('跳转 $path ${params.toString()}'), behavior: SnackBarBehavior.floating),
);
}
/// 点击报告(跳转报告页)
void _getReport(CourseRecord record, _CapturePackViewModel pack) {
if (CaptureRecordStatus.fromInt(record.status) ==
CaptureRecordStatus.processing) return;
// TODO(路由): 跳转报告页
debugPrint('跳转报告页 recordId=${record.id}, packId=${pack.id}');
}
// ========== 工具方法 ==========
String _numberToChinese(int num) {
const chineseNum = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
return chineseNum[num.clamp(0, 9)];
}
String _formatDuration(int seconds) {
if (seconds <= 0) return '0秒';
final h = seconds ~/ 3600;
final m = (seconds % 3600) ~/ 60;
final s = seconds % 60;
if (h > 0) return '${h}时${m}分';
if (m > 0) return '${m}分${s}秒';
return '${s}秒';
}
// ========== 构建 ==========
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _bgColor,
body: Stack(
children: [
const Positioned.fill(child: ColoredBox(color: _bgColor)),
// 顶部背景图
Positioned(
top: 0,
left: 0,
right: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-05-26/my-header-bg-default1779775984375.png',
),
fit: BoxFit.fitWidth,
width: double.infinity,
placeholder: (context, url) => const SizedBox.shrink(),
errorWidget: (context, url, error) => const SizedBox.shrink(),
),
),
// 主内容
SafeArea(
child: Column(
children: [
// 导航栏
SizedBox(
height: 44.h,
child: Stack(
children: [
const Center(
child: Text(
'攻克列表',
style: TextStyle(
color: Color.fromRGBO(0, 0, 0, 0.8),
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
Positioned(
left: 16.w,
top: 0,
bottom: 0,
child: IconButton(
icon: const Icon(Icons.arrow_back_ios,
color: Color.fromRGBO(0, 0, 0, 0.8), size: 20),
onPressed: () => Navigator.of(context).maybePop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
),
],
),
),
// 规则卡片
_buildRuleView(),
SizedBox(height: 19.h),
// 列表
Expanded(
child: CommonPagedList<_CapturePackViewModel>(
load: _loadPage,
buildItem: (context, item, index) => _buildCaptureItem(item),
padding: EdgeInsets.symmetric(horizontal: 14.w),
emptyWidget: _buildEmpty(),
noMoreText: '', // 只有一页,不显示"没有更多"
),
),
],
),
),
],
),
);
}
// ============================================================
// 子组件构建方法
// ============================================================
Widget _buildRuleView() {
return Container(
margin: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 0),
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 16.h),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16.r),
border: Border.all(color: Colors.white, width: 1.w),
boxShadow: [
BoxShadow(
color: const Color.fromARGB(255, 255, 255, 255).withOpacity(0.05),
blurRadius: 20.r,
offset: Offset(0, 10.h),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRuleTitle(),
SizedBox(height: 16.h),
_buildRuleContent(),
],
),
);
}
Widget _buildRuleTitle() {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build('/StaticResource/2026-04-10/capture-list-rule.png'),
width: 18.w,
height: 18.w,
fit: BoxFit.fitWidth,
),
SizedBox(width: 6.w),
Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: EdgeInsets.only(bottom: 2.h),
child: Text(
'攻克规则说明',
style: TextStyle(
fontSize: 17.sp,
fontWeight: FontWeight.bold,
color: _titleColor,
),
),
),
Positioned(
bottom: -1.h,
right: 0,
child: Container(
width: 59.5.w,
height: 9.h,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0x003D7DF5),
Color(0x331D9CFF),
Color(0x808AECFD),
],
),
borderRadius: BorderRadius.circular(50.r),
),
),
),
],
),
],
);
}
Widget _buildRuleContent() {
if (_remark.isEmpty) {
return Text('暂无规则说明',
style: TextStyle(fontSize: 14.sp, color: _subtitleColor));
}
final lines = _remark.split('\n');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: lines.asMap().entries.map((entry) {
final isLast = entry.key == lines.length - 1;
return Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 10.h),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 4.w,
height: 4.w,
margin: EdgeInsets.fromLTRB(6.5.w, 7.5.h, 13.w, 0),
decoration: const BoxDecoration(
color: _primary,
shape: BoxShape.circle,
),
),
Expanded(
child: Text(
entry.value,
style: TextStyle(fontSize: 14.sp, color: _subtitleColor, height: 1.5),
),
),
],
),
);
}).toList(),
);
}
Widget _buildEmpty() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build('/StaticResource/2026-04-11/noCoursesss.png'),
width: 200.w,
height: 200.w,
),
SizedBox(height: 16.h),
Text('暂无攻克', style: TextStyle(fontSize: 14.sp, color: _mutedText)),
],
),
);
}
Widget _buildCaptureItem(_CapturePackViewModel item) {
return Container(
margin: EdgeInsets.only(bottom: 14.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r),
border: Border.all(color: Colors.white, width: 1.w),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFAFEFF), Color(0xFFEDF7FF)],
),
),
child: Column(
children: [
_buildCaptureInfo(item),
_buildCaptureProgress(item),
],
),
);
}
Widget _buildCaptureInfo(_CapturePackViewModel item) {
return Padding(
padding: EdgeInsets.fromLTRB(14.w, 18.5.h, 14.w, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCaptureTitle(item.name),
SizedBox(height: 10.h),
_buildSubjectTag(item.baseSubjectName),
],
),
),
SizedBox(width: 10.w),
_buildStandardBox(item.overcomeStandard),
],
),
);
}
Widget _buildCaptureTitle(String name) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 3.w,
height: 14.h,
margin: EdgeInsets.only(right: 7.5.w),
decoration: BoxDecoration(
color: _primary,
borderRadius: BorderRadius.circular(10.r),
),
),
Expanded(
child: Text(
name,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: _titleColor,
),
),
),
],
);
}
Widget _buildSubjectTag(String subject) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 1.w, vertical: 1.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.r),
gradient: const LinearGradient(
colors: [Color(0xFF5A9EFF), Color(0xFFF2F8FF), Color(0xFF5A9EFF)],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h),
decoration: BoxDecoration(
color: const Color(0xFFE5F0FF),
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
subject,
style: TextStyle(
fontSize: 12.sp,
color: _primary,
fontWeight: FontWeight.w500,
),
),
),
);
}
Widget _buildStandardBox(int standard) {
return Container(
width: 60.w,
height: 51.h,
padding: EdgeInsets.all(2.w),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.r),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF60D0C0), Color(0xFF6EE9DB)],
),
),
child: Column(
children: [
Expanded(
flex: 1,
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
'攻克标准',
style: TextStyle(fontSize: 12.sp, color: Colors.white),
),
),
),
),
Expanded(
child: Container(
margin: EdgeInsets.fromLTRB(1.5.w, 0, 1.5.w, 1.5.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(6.r),
bottomRight: Radius.circular(6.r),
),
),
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CachedNetworkImage(
imageUrl:
ImgUrl.build('/StaticResource/2026-04-10/capture-list-success.png'),
width: 14.w,
height: 14.w,
fit: BoxFit.fitWidth,
),
SizedBox(width: 5.5.w),
Text(
'$standard%',
style: TextStyle(
fontSize: 15.sp, color: _success, fontWeight: FontWeight.bold),
),
],
),
),
),
),
),
],
),
);
}
Widget _buildCaptureProgress(_CapturePackViewModel item) {
return Container(
margin: EdgeInsets.only(top: 10.5.h),
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 16.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
children: [
_buildProgressTop(item),
if (item.isShowCaptureInfo) _buildProgressInfo(item),
],
),
);
}
Widget _buildProgressTop(_CapturePackViewModel item) {
return Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => _toggleCaptureInfo(item),
behavior: HitTestBehavior.opaque,
child: Row(
children: [
Text('攻克次数', style: TextStyle(fontSize: 12.sp, color: _mutedText)),
SizedBox(width: 10.w),
_buildProgressBars(item),
SizedBox(width: 10.w),
_buildProgressCount(item),
],
),
),
),
_buildButtonGroup(item),
],
);
}
Widget _buildProgressBars(_CapturePackViewModel item) {
return Row(
children: List.generate(item.maxOvercomeCount, (idx) {
final record = idx < item.records.length ? item.records[idx] : null;
Color color;
switch (CaptureRecordStatus.fromInt(record?.status)) {
case CaptureRecordStatus.success:
color = _success;
break;
case CaptureRecordStatus.fail:
color = _error;
break;
case CaptureRecordStatus.processing:
color = _processing;
break;
default:
color = _barBg;
}
return Container(
width: 20.w,
height: 4.h,
margin: EdgeInsets.only(right: idx == item.maxOvercomeCount - 1 ? 0 : 4.w),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4.r),
),
);
}),
);
}
Widget _buildProgressCount(_CapturePackViewModel item) {
final hasRecords = item.recordCount > 0 && item.records.isNotEmpty;
return Row(
children: [
Text(
'${item.recordCount}/${item.maxOvercomeCount}',
style: TextStyle(fontSize: 12.sp, color: _mutedText),
),
if (hasRecords)
Padding(
padding: EdgeInsets.only(left: 4.5.w),
child: Icon(
item.isShowCaptureInfo
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 12.sp,
color: _mutedText,
),
),
],
);
}
Widget _buildButtonGroup(_CapturePackViewModel item) {
switch (item.status) {
case CapturePackStatus.notStarted:
return _buildSolidButton('开始攻克', () => _navigateToCaptureInfo(item));
case CapturePackStatus.inProgress:
final text = item.records.isNotEmpty ? '继续攻克' : '开始攻克';
return _buildSolidButton(text, () => _navigateToCaptureInfo(item));
case CapturePackStatus.conquered:
return _buildOutlineButton('已攻克', _success, null, opacity: 0.5);
case CapturePackStatus.failed:
return _buildOutlineButton('攻克失败', _error,
() => _navigateToCaptureInfo(item),
opacity: 0.5);
case CapturePackStatus.laterStudy:
return Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => _navigateToCaptureInfo(item),
child: Text(
'后续学习',
style: TextStyle(fontSize: 11.sp, color: _primary),
),
),
SizedBox(width: 8.w),
_buildSolidButton('重新攻克', () => _navigateToCaptureInfo(item)),
],
);
case CapturePackStatus.judging:
return _buildOutlineButton('判题中', _success, null, opacity: 0.5);
}
}
Widget _buildOutlineButton(String text, Color color, VoidCallback? onTap,
{double opacity = 1.0}) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Opacity(
opacity: opacity,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
height: 30.h,
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: color, width: 1.w),
borderRadius: BorderRadius.circular(30.r),
color: onTap == null ? Colors.transparent : color.withOpacity(0.08),
),
child: Text(
text,
style: TextStyle(fontSize: 13.sp, color: color, fontWeight: FontWeight.w500),
),
),
),
);
}
Widget _buildSolidButton(String text, VoidCallback? onTap,
{double opacity = 1.0}) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Opacity(
opacity: opacity,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
height: 30.h,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30.r),
color: _primary,
),
child: Text(
text,
style: TextStyle(
fontSize: 13.sp, color: Colors.white, fontWeight: FontWeight.w500),
),
),
),
);
}
Widget _buildProgressInfo(_CapturePackViewModel item) {
final validRecords =
item.records.where((r) => r.status != 0).toList();
if (validRecords.isEmpty) return const SizedBox.shrink();
return Container(
margin: EdgeInsets.only(top: 16.h),
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.5.h),
decoration: BoxDecoration(
color: const Color(0xFFFAFBFD),
borderRadius: BorderRadius.circular(6.r),
border: Border.all(color: _barBg, width: 0.5.w),
),
child: Column(
children: validRecords.asMap().entries.map((entry) {
final infoIndex = entry.key;
final record = entry.value;
return GestureDetector(
onTap: () => _getReport(record, item),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: EdgeInsets.symmetric(vertical: 5.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
'第${_numberToChinese(infoIndex + 1)}次攻克',
style: TextStyle(fontSize: 12.sp, color: _mutedText),
),
SizedBox(width: 13.w),
Text(
_formatDuration(0), // TODO: 后端 record 没有用时字段,需确认
style: TextStyle(fontSize: 12.sp, color: _subtitleColor),
),
],
),
if (CaptureRecordStatus.fromInt(record.status) ==
CaptureRecordStatus.processing)
Text('判题中',
style: TextStyle(fontSize: 12.sp, color: _processing))
else
_buildInfoRight(record, item),
],
),
),
);
}).toList(),
),
);
}
Widget _buildInfoRight(CourseRecord record, _CapturePackViewModel pack) {
final isSuccess = record.accuracyRate >= pack.overcomeStandard;
final bgColor =
isSuccess ? const Color(0xFFE8FAF6) : const Color(0xFFFFF0F0);
final textColor = isSuccess ? _success : _error;
final iconPath = isSuccess
? '/StaticResource/2026-04-22/capture-list-success.png'
: '/StaticResource/2026-04-22/capture-list-fail.png';
return Container(
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 1.5.h),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(4.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(iconPath),
width: 14.w,
height: 14.w,
fit: BoxFit.fitWidth,
),
SizedBox(width: 5.5.w),
Text(
'${record.accuracyRate}%',
style: TextStyle(
fontSize: 12.sp, color: textColor, fontWeight: FontWeight.w500),
),
],
),
);
}
}
+435
View File
@@ -0,0 +1,435 @@
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/exam/api.dart';
import 'package:new_learn/core/api/exam/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/core/utils/subject_icons.dart';
/// ============================================================
/// 【ExamPage】我的考试列表页 —— 对标 Vue pages/myPackage/exam/exam.vue
///
/// 结构:
/// AppBar + Tab 栏(全部/各学科,胶囊样式,动态拉取)
/// + 分页列表(CommonPagedList,下拉刷新/触底加载)
/// + 卡片(学科图标 + 考试名 + 时间 + 得分渐变文字)
///
/// 点击卡片 → 跳考试报告详情
/// (先留 TODO,后续考试详情页做了再接入路由)
/// ============================================================
class ExamPage extends StatefulWidget {
const ExamPage({super.key});
@override
State<ExamPage> createState() => _ExamPageState();
}
class _ExamPageState extends State<ExamPage> {
final List<ExamSubjectTab> _tabs = [];
ExamSubjectTab _currentTab = const ExamSubjectTab(id: null, name: '全部');
bool _tabsLoading = true;
@override
void initState() {
super.initState();
_tabs.add(const ExamSubjectTab(id: null, name: '全部'));
_loadTabs();
}
Future<void> _loadTabs() async {
try {
final list = await ExamApi.instance.getSubjects();
if (!mounted) return;
setState(() {
_tabs
..clear()
..add(const ExamSubjectTab(id: null, name: '全部'))
..addAll(list);
_currentTab = _tabs.first;
_tabsLoading = false;
});
} catch (e) {
talker.error('获取考试科目Tab失败', e);
if (!mounted) return;
setState(() {
_tabs
..clear()
..add(const ExamSubjectTab(id: null, name: '全部'));
_currentTab = _tabs.first;
_tabsLoading = false;
});
}
}
/// 分页加载回调
Future<PagingResult<ExamDto>> _loadPage(int pageKey) async {
final res = await ExamApi.instance.search(
baseSubjectId: _currentTab.id,
pageIndex: pageKey,
pageSize: 10,
);
// hasNext:当前页返回数据满 pageSize 且总数更大
final hasMore =
res.data.length >= res.pageSize && res.total > pageKey * res.pageSize;
return PagingResult(data: res.data, hasNext: hasMore);
}
void _onTapTab(ExamSubjectTab tab) {
if (_currentTab.id == tab.id) return;
setState(() => _currentTab = tab);
// tab 切换后,通过 ValueKey 让 CommonPagedList 重建 State,自动首屏加载新数据
}
void _goToExamDetail(ExamDto item) {
// TODO: 接入考试报告详情页路由,对应 Vue /pages/examPackage/examReport/index?recordId=${item.id}
talker.debug('点击考试详情: ${item.taskName}, id=${item.id}');
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: const Text('我的考试'),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1D2B47),
elevation: 0,
centerTitle: true,
bottom: PreferredSize(
preferredSize: Size.fromHeight(44.h),
child: _buildTabBar(),
),
),
body: _tabsLoading
? const Center(child: CircularProgressIndicator())
: Container(
color: const Color(0xFFF5F8FA),
child: CommonPagedList<ExamDto>(
key: ValueKey('exam-list-${_currentTab.id}'),
load: _loadPage,
padding: EdgeInsets.fromLTRB(12.w, 24.h, 12.w, 0),
// separatorBuilder: (_, __) => SizedBox(height: 14.h),
noMoreText: '- AI 爱学蝶变提醒你 没有更多了 -',
emptyWidget: _buildEmpty(),
buildItem: (ctx, item, index) {
return _ExamCard(
item: item,
onTap: () => _goToExamDetail(item),
);
},
),
),
);
}
// ===== Tab 栏(对标 up-tabs + 自定义 activeStyle/inactiveStyle)=====
Widget _buildTabBar() {
return Container(
padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 12.h),
color: Colors.white,
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
children: _tabs.map((tab) {
final isActive = tab.id == _currentTab.id;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w),
child: GestureDetector(
onTap: () => _onTapTab(tab),
behavior: HitTestBehavior.opaque,
child: Container(
height: 37.h,
width: 54.w,
// padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: isActive
? const Color(0xFF3D7DF5)
: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(999),
),
alignment: Alignment.center,
child: Text(
tab.name,
style: TextStyle(
fontSize: 15.spMin,
color: isActive ? Colors.white : const Color(0xFF687FAD),
fontWeight: FontWeight.w400,
),
),
),
),
);
}).toList(),
),
),
);
}
// ===== 空状态(对标 DataEmpty + exam_empty_bg)=====
Widget _buildEmpty() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-06-10/exam_empty_bg1781078431804.png',
),
width: 200.w,
fit: BoxFit.fitWidth,
errorWidget: (_, _, _) => SizedBox(width: 200.w, height: 200.h),
),
SizedBox(height: 12.h),
Text(
'暂无内容',
style: TextStyle(
fontSize: 14.spMin,
color: const Color(0xFF687FAD),
),
),
],
),
);
}
}
// ==================== 考试卡片 ====================
class _ExamCard extends StatelessWidget {
const _ExamCard({required this.item, required this.onTap});
final ExamDto item;
final VoidCallback onTap;
static const String _cardBgBook =
'/StaticResource/2026-09-10/book_icon_final1789026573675.png';
// 书图标设计尺寸(目标渲染尺寸)
static const double _bookWidth = 124;
static const double _bookHeight = 124;
// 卡片尺寸
static const double _cardHeight = 92;
static const double _cardRadius = 24;
// ⚠ book_icon.png 自带透明 padding,下面这些偏移用于补偿图片留白
// 让书图案的左下角对齐卡片左下角。换图时需重新调整。
static const double _bookOffsetLeft = -14; // 左补留白(宽度加大了,偏移也加大)
static const double _bookOffsetBottom = 6; // 底补留白
static const double _bookExtraHeight = 0; // 放大补偿,让图案达到目标高度
String get _timeRange {
// 日期相同只展示 endTime 的时分,去除秒
final startParts = item.startTime.split(' ');
final startDate = startParts.isNotEmpty ? startParts[0] : '';
final startHhMm = startParts.length >= 2
? startParts[1].split(':').take(2).join(':')
: '';
final endParts = item.endTime.split(' ');
final endHhMm = endParts.length >= 2
? endParts[1].split(':').take(2).join(':')
: '';
return '$startDate $startHhMm - $endHhMm';
}
@override
Widget build(BuildContext context) {
// 学科图标:按 baseSubjectName 动态取,无匹配兜底数学
final subjectIcon = subjectIconUrl(item.baseSubjectName);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
// ⚠ 书图/卡片尺寸统一用 .w:宽高同基准,锁定盒子宽高比,
// 避免手机(.w按375稿/.h按812稿)与pad(设备尺寸)缩放系数不同导致
// BoxFit.contain 缩放分支翻转、书图被压扁。
height: (_bookHeight + _bookExtraHeight).w, // 整体占位 = 书图容器高度(溢出卡片)
child: Stack(
clipBehavior: Clip.none,
children: [
// 底层:白色圆角卡片(垂直居中)
Positioned(
left: 0,
right: 0,
top: (_bookHeight + _bookExtraHeight - _cardHeight).w / 2,
child: Container(
height: _cardHeight.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(_cardRadius.r),
boxShadow: const [
BoxShadow(
color: Color(0x0A1D2B47),
blurRadius: 20,
offset: Offset(0, 6),
),
],
),
),
),
// 中层:左侧书形装饰图
// 图片自带透明 padding,用 _bookOffsetLeft/Bottom/ExtraHeight 补偿
Positioned(
left: _bookOffsetLeft.w,
bottom: _bookOffsetBottom.w,
width: _bookWidth.w,
height: (_bookHeight + _bookExtraHeight).w,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(_cardBgBook),
fit: BoxFit.contain,
alignment: Alignment.bottomLeft,
errorWidget: (_, __, ___) =>
SizedBox(width: _bookWidth.w, height: _bookHeight.w),
),
),
// 学科小图标(叠在书图上半部分,书图 110 宽 / 139 高容器)
Positioned(
left: 22.w,
top: 14.w,
child: CachedNetworkImage(
imageUrl: subjectIcon,
width: 56.w,
height: 56.w,
errorWidget: (_, _, _) => Icon(
Icons.menu_book,
size: 56.w,
color: const Color(0xFF3D7DF5),
),
),
),
// 上层:内容
Positioned.fill(
child: Row(
children: [
// 左侧书图区域的占位宽(内容从书图右边开始)
SizedBox(width: 90.w),
// 中间 + 右侧
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Row(
children: [
Expanded(child: _buildMid()),
_buildRight(),
],
),
),
),
],
),
),
// 学科标签(覆盖在书图底部胶囊上)
Positioned(
left: -10.w,
bottom: 26.w,
width: _bookWidth.w,
child: Center(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 14.w,
vertical: 6.w,
),
decoration: BoxDecoration(
color: const Color(0xFFE8F2FF),
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
item.baseSubjectName,
style: TextStyle(
fontSize: 16.spMin,
fontWeight: FontWeight.w600,
color: const Color(0xFF3D7DF5),
height: 1.2,
),
),
),
),
),
],
),
),
);
}
Widget _buildMid() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.taskName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.spMin,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
SizedBox(height: 8.h),
Text(
_timeRange,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.spMin,
fontWeight: FontWeight.w400,
color: const Color(0xFF687FAD),
),
),
],
);
}
Widget _buildRight() {
return Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
// 得分
_gradientScoreText('${item.userScore}', 32.spMin, FontWeight.w700),
Text(
' / ${item.totalScore}',
style: TextStyle(
fontSize: 16.spMin,
fontWeight: FontWeight.w400,
color: const Color(0xFF687FAD),
),
),
SizedBox(width: 10.w),
Icon(
Icons.arrow_forward_ios,
size: 16.w,
color: const Color(0xFF3D7DF5),
),
],
);
}
/// 渐变得分文字:linear-gradient(270deg, #2DC9FF -2.95%, #2B73F9 73.86%)
Widget _gradientScoreText(String text, double fontSize, FontWeight weight) {
return ShaderMask(
shaderCallback: (rect) => ui.Gradient.linear(
rect.topRight,
rect.topLeft,
const [Color(0xFF2DC9FF), Color(0xFF2B73F9)],
const [-0.0295, 0.7386],
TileMode.clamp,
),
child: Text(
text,
style: TextStyle(
fontSize: fontSize,
fontWeight: weight,
color: Colors.white,
height: 1,
),
),
);
}
}
+94 -134
View File
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/confirm_box.dart'; import 'package:new_learn/core/components/confirm_box.dart';
import 'package:new_learn/core/components/image_preview_viewer.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:new_learn/core/config/app_env.dart'; import 'package:new_learn/core/config/app_env.dart';
import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/network/dio_client.dart';
@@ -97,12 +96,20 @@ class MyPage extends ConsumerWidget {
// 第一组:学习功能菜单 // 第一组:学习功能菜单
_MenuCard( _MenuCard(
items: [ items: [
_MenuItem(
imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png',
title: '我的考试',
onTap: () {
context.push('/myExam');
},
),
_MenuItem( _MenuItem(
imageUrl: imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png', '${AppEnv.imgBaseUrl}/StaticResource/2026-06-10/exam1781070655873.png',
title: '我的报告', title: '我的报告',
onTap: () { onTap: () {
// TODO: 跳转到我的报告页 context.push('/myReport');
}, },
), ),
_MenuItem( _MenuItem(
@@ -110,7 +117,7 @@ class MyPage extends ConsumerWidget {
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/note.png', '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/note.png',
title: '我的笔记', title: '我的笔记',
onTap: () { onTap: () {
// TODO: 跳转到我的笔记页 context.push('/myNote');
}, },
), ),
_MenuItem( _MenuItem(
@@ -118,7 +125,7 @@ class MyPage extends ConsumerWidget {
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/error.png', '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/error.png',
title: '错题本', title: '错题本',
onTap: () { onTap: () {
// TODO: 跳转到错题本页 context.push('/myWrong');
}, },
), ),
_MenuItem( _MenuItem(
@@ -126,7 +133,7 @@ class MyPage extends ConsumerWidget {
'${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/collect.png', '${AppEnv.imgBaseUrl}/StaticResource/2026-04-01/collect.png',
title: '我的收藏', title: '我的收藏',
onTap: () { onTap: () {
// TODO: 跳转到我的收藏页 context.push('/myCollect');
}, },
), ),
_MenuItem( _MenuItem(
@@ -158,6 +165,18 @@ class MyPage extends ConsumerWidget {
), ),
], ],
), ),
SizedBox(height: 16.h),
// 第三组:设置(Vue my.vue #ifdef APP-PLUS 专属,Flutter 始终显示)
_MenuCard(
items: [
_MenuItem(
imageUrl:
'${AppEnv.imgBaseUrl}/StaticResource/2026-08-20/setting1787192171875.png',
title: '设置',
onTap: () => context.push('/settings'),
),
],
),
SizedBox(height: 24.h), SizedBox(height: 24.h),
], ],
), ),
@@ -218,95 +237,19 @@ class _UserHeader extends ConsumerStatefulWidget {
} }
class _UserHeaderState extends ConsumerState<_UserHeader> { class _UserHeaderState extends ConsumerState<_UserHeader> {
static const String _avatarHeroTag = 'my-page-avatar'; /// 点击头像 → 直接触发选图上传(对齐 Vue my.vue changeAvatar 逻辑,无预览中间步骤)
/// 刚选中的本地新头像(null = 还没选)
PickedImage? _pickedAvatar;
/// 点击头像:
/// 1. 没头像 → 直接更换;
/// 2. 有头像 → 先弹动作表,支持"预览头像 / 更换头像"。
Future<void> _onAvatarTap() async { Future<void> _onAvatarTap() async {
if (!_hasPreviewableAvatar) {
await _pickAndUploadAvatar();
return;
}
final action = await showModalBottomSheet<String>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (dialogContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.visibility_outlined),
title: const Text('预览头像'),
onTap: () => Navigator.of(dialogContext).pop('preview'),
),
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('更换头像'),
onTap: () => Navigator.of(dialogContext).pop('change'),
),
const SizedBox(height: 8),
],
),
),
);
if (!mounted) return;
if (action == 'preview') {
await _previewAvatar();
return;
}
if (action == 'change') {
await _pickAndUploadAvatar(); await _pickAndUploadAvatar();
} }
}
bool get _hasPreviewableAvatar { /// 选择头像并上传(对齐 Vue my.vue 的 changeAvatar 完整链路):
return _pickedAvatar != null || widget.avatar.isNotEmpty; /// 1. showPickSheet 选图(相册+拍照,内部已含权限请求)
} /// 2. ImageProcessor 压缩 + EXIF 方向修正
/// 3. 显示 loading 遮罩(对应 Vue uni.showLoading '上传中...')
Future<void> _previewAvatar() async { /// 4. OssUploader.uploadImage 直传 OSS
final picked = _pickedAvatar; /// 5. UserApi.updateUserAvatar 调业务接口更新头像字段
if (picked?.bytes != null) { /// 6. 弹审核确认框(对应 Vue proxy.$confirm)
await ImagePreviewViewer.showSingle( /// 注意:不上传后立刻刷新用户信息,头像保持旧的,等后端审核通过后自然生效
context,
imageProvider: MemoryImage(picked!.bytes!),
heroTag: _avatarHeroTag,
label: '头像预览',
);
return;
}
if (widget.avatar.isNotEmpty) {
await ImagePreviewViewer.showSingle(
context,
imageProvider: NetworkImage(widget.avatar),
heroTag: _avatarHeroTag,
label: '头像预览',
);
}
}
/// 选择头像:拍照/相册 → OSS 直传 → 调业务接口更新头像字段 → 更新全局用户
///
/// 这里和 UniApp 旧项目保持一致,完整链路是:
/// 1. 先选图片;
/// 2. 本地压缩/方向修正;
/// 3. 上传到 OSS,拿到最终图片 url;
/// 4. 调 /api/v1/User/UpdateUserAvatar,把服务器里的 avatar 字段改掉;
/// 5. 更新 currentUserProvider + 本地缓存,让界面立刻刷新。
///
/// 为什么"我的"页这里不再重新请求用户信息接口:
/// 登录成功时已经把完整用户信息拉下来放进了 currentUserProvider;
/// 头像上传成功后,本次只有 avatar 这一个字段发生变化,
/// 所以前端直接做一次局部更新就够了,没必要再额外请求整份用户资料。
Future<void> _pickAndUploadAvatar() async { Future<void> _pickAndUploadAvatar() async {
final picked = await PickerService.instance.showPickSheet( final picked = await PickerService.instance.showPickSheet(
context, context,
@@ -315,19 +258,15 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
if (picked == null || picked.isEmpty) return; if (picked == null || picked.isEmpty) return;
final image = picked.first; final image = picked.first;
// 先显示本地临时预览,让用户选完图后有即时反馈。
setState(() => _pickedAvatar = image);
// ① 读图片字节:Web 走 bytes;原生走 photo_manager 纯 Dart 的 originBytes // ① 读图片字节(Web 走 bytes;原生走 originBytes)
// (不 import dart:io,Web 才能编译)
final bytes = image.bytes ?? await image.entity?.originBytes; final bytes = image.bytes ?? await image.entity?.originBytes;
if (bytes == null || bytes.isEmpty) { if (bytes == null || bytes.isEmpty) {
_toast('读取图片失败,请重试'); _toast('读取图片失败,请重试');
return; return;
} }
// ② 压缩 + EXIF 方向修正(后台 isolate 跑,不卡页面) // ② 压缩 + EXIF 方向修正
// 头像只需 512 长边;null = 尺寸超出 14~4000 像素硬限制
final processed = await ImageProcessor.process( final processed = await ImageProcessor.process(
bytes: bytes, bytes: bytes,
fileName: image.name, fileName: image.name,
@@ -338,7 +277,10 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
return; return;
} }
// ③ OSS 直传:申请路径 → 拿 STS → V4 签名 → PUT(拦截器自动带 Token) // ③ 显示 loading 遮罩(对应 Vue uni.showLoading)
if (!mounted) return;
_showLoading('上传中...');
try { try {
final fileName = _replaceExtension( final fileName = _replaceExtension(
image.name ?? 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg', image.name ?? 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg',
@@ -348,6 +290,8 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
'头像处理:${processed.originalBytes}B -> ${processed.processedBytes}B ' '头像处理:${processed.originalBytes}B -> ${processed.processedBytes}B '
'(${processed.contentType}, $fileName)', '(${processed.contentType}, $fileName)',
); );
// ④ OSS 直传
final url = await OssUploader.uploadImage( final url = await OssUploader.uploadImage(
bytes: processed.bytes, bytes: processed.bytes,
fileName: fileName, fileName: fileName,
@@ -355,30 +299,60 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
scene: UploadScene.avatar, scene: UploadScene.avatar,
); );
// ④ 通知业务接口"当前用户头像已改成这个 OSS 地址"。 // ⑤ 业务接口更新头像字段(对应 Vue UpdateUserAvatar({ avatar: url }))
// 这一步对应 UniApp 的 UpdateUserAvatar({ avatar: url }),
// 目的是把服务器里当前用户资料的 avatar 字段真正更新掉。
await UserApi.updateUserAvatar(url); await UserApi.updateUserAvatar(url);
// ⑤ 头像字段只改本地当前用户信息即可,不需要"我的"页再重新请求接口。 // ⑥ 关闭 loading,弹审核确认框(对应 Vue proxy.$confirm)
// updateAvatar(url) 内部会做两件事:
// - 更新 currentUserProvider 的内存状态;
// - 把更新后的用户对象写回 SharedPreferences。
// 因此所有 ref.watch(currentUserProvider) 的页面都会一起刷新。
await ref.read(currentUserProvider.notifier).updateAvatar(url);
// 上传成功后清掉本地临时预览,改为展示服务器返回的头像 URL。
// 否则界面会一直优先显示 _pickedAvatar,看起来像"方向没修正",
// 实际上你看到的是本地原图/相册缩略图,不是处理后的上传结果。
if (mounted) {
setState(() => _pickedAvatar = null);
}
if (!mounted) return; if (!mounted) return;
_toast('头像上传成功'); _hideLoading();
await showConfirmBox(
context,
title: '上传成功,等待审核完成会自动生效',
confirmText: '确定',
cancelText: '取消',
);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
_toast('头像上传失败:$e'); _hideLoading();
_toast('笔记上传失败,请检查网络后重试');
}
}
/// 显示全局 loading 遮罩(对应 Vue uni.showLoading)
void _showLoading(String message) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => PopScope(
canPop: false,
child: Center(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 32.w, vertical: 24.h),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16.h),
Text(
message,
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
],
),
),
),
),
);
}
void _hideLoading() {
if (Navigator.of(context, rootNavigator: true).canPop()) {
Navigator.of(context, rootNavigator: true).pop();
} }
} }
@@ -389,26 +363,13 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
); );
} }
/// 头像主体:按「本地新图 → 网络图 → 默认首字圆」三级渲染 /// 头像主体:网络头像 → 默认首字圆 二级渲染
Widget _buildAvatar() { Widget _buildAvatar() {
final size = 62.w; final size = 62.w;
final picked = _pickedAvatar;
// 一级:刚选中的本地头像 // 一级:服务器返回的网络头像(加载失败自动降级到默认头像)
if (picked != null) {
return Hero(
tag: _avatarHeroTag,
child: ClipOval(
child: PickerService.buildThumb(picked, width: size, height: size),
),
);
}
// 二级:服务器返回的网络头像(加载失败自动降级到默认头像)
if (widget.avatar.isNotEmpty) { if (widget.avatar.isNotEmpty) {
return Hero( return ClipOval(
tag: _avatarHeroTag,
child: ClipOval(
child: Image.network( child: Image.network(
widget.avatar, widget.avatar,
width: size, width: size,
@@ -416,11 +377,10 @@ class _UserHeaderState extends ConsumerState<_UserHeader> {
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildDefaultAvatar(), errorBuilder: (_, _, _) => _buildDefaultAvatar(),
), ),
),
); );
} }
// 三级:默认头像(首字圆) // 二级:默认头像(首字圆)
return _buildDefaultAvatar(); return _buildDefaultAvatar();
} }
@@ -0,0 +1,310 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/my_report/api.dart';
import 'package:new_learn/core/api/my_report/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/core/utils/subject_icons.dart';
/// ============================================================
/// 【MyReportPage】我的报告列表 —— 对标 Vue pages/myPackage/myReport/myReport.vue
///
/// 一、数据来源:
/// 报告分页 → UserReportApi.instance.getReportPage → CommonPagedList
///
/// 二、UI 结构:
/// Scaffold 白色 AppBar + 背景 #F5F8FA 的列表区域
/// 卡片背景图 + 左对齐 icon/标签 + 中间文字 + 右侧箭头
///
/// 三、点击卡片 → 跳 /report-detail(WebView 详情页)
/// reportUrl 为空 → SnackBar "报告暂未生成"
///
/// 四、先只做 phone 布局,Pad 后续再说
/// ============================================================
/// 取学科名(报告名前 2 个字,对标 Vue getSubjectName)
String _extractSubjectName(UserReportDto item) {
final name = item.reportName.trim();
if (name.length < 2) return name;
return name.substring(0, 2);
}
/// 格式化创建时间(对标 Vue getReportTime)
String _formatCreateTime(String raw) {
if (raw.isEmpty) return '';
final normalized = raw.replaceAll('T', ' ');
return normalized.length > 16 ? normalized.substring(0, 16) : normalized;
}
class MyReportPage extends ConsumerStatefulWidget {
const MyReportPage({super.key});
@override
ConsumerState<MyReportPage> createState() => _MyReportPageState();
}
class _MyReportPageState extends ConsumerState<MyReportPage> {
static const int _pageSize = 10;
@override
void initState() {
super.initState();
talker.debug('打开页面:我的报告');
}
/// 分页加载回调
Future<PagingResult<UserReportDto>> _loadPage(int pageIndex) async {
final page = await UserReportApi.instance.getReportPage(
pageIndex: pageIndex,
pageSize: _pageSize,
);
final hasNext = page.data.length >= _pageSize;
return PagingResult(data: page.data, hasNext: hasNext);
}
/// 点击卡片:跳 WebView 详情页
void _onTapCard(UserReportDto item) {
if (item.reportUrl.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('报告暂未生成')));
return;
}
context.push(
'/report-detail',
extra: {
'title': item.reportName.isEmpty ? '我的报告' : item.reportName,
'url': item.reportUrl,
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: const Text('我的报告', style: TextStyle(color: Color(0xFF1D2B47))),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
),
),
body: Container(
color: const Color(0xFFF5F8FA),
child: CommonPagedList<UserReportDto>(
load: _loadPage,
padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 0),
// separatorBuilder: (_, _) => SizedBox(height: 14.h),
buildItem: (context, item, index) {
return _ReportCard(item: item, onTap: () => _onTapCard(item));
},
emptyWidget: Transform.translate(
offset: Offset(0, -80.h),
child: const Center(
child: Text('暂无内容', style: TextStyle(color: Color(0xFF687FAD))),
),
),
),
),
);
}
}
/// ============================================================
/// 【_ReportCard】报告卡片
///
/// 左侧装饰与我的考试(exam)卡片一致:
/// 白色圆角卡片 + 左侧溢出书图(book_icon)+ 学科图标叠在书图上 +
/// 底部学科名胶囊。书图/卡片尺寸统一用 .w 锁定宽高比,适配 pad。
///
/// 内容区:中间 [报告名 + 时间],右侧 [箭头]。
/// ============================================================
class _ReportCard extends StatelessWidget {
const _ReportCard({required this.item, required this.onTap});
final UserReportDto item;
final VoidCallback onTap;
static const String _cardBgBook =
'/StaticResource/2026-09-10/book_icon_final1789026573675.png';
// 书图标 / 卡片尺寸(与 exam 卡片一致,统一 .w 锁比例)
static const double _bookWidth = 124;
static const double _bookHeight =
124; // 卡片间距 = _bookHeight - _cardHeight(书图向上探出的高度)
static const double _cardHeight = 92;
static const double _cardRadius = 24;
// ⚠ book_icon.png 自带透明 padding,偏移用于补偿留白
static const double _bookOffsetLeft = -14;
static const double _bookOffsetBottom = -10;
static const double _bookExtraHeight = 0;
@override
Widget build(BuildContext context) {
final subjectName = _extractSubjectName(item);
final iconUrl = subjectIconUrl(subjectName);
final timeText = _formatCreateTime(item.createTime);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox(
height: (_bookHeight + _bookExtraHeight).w,
child: Stack(
clipBehavior: Clip.none,
children: [
// 底层:白色圆角卡片(底部对齐,书图只向上溢出)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: _cardHeight.w,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(_cardRadius.r),
boxShadow: const [
BoxShadow(
color: Color(0x0A1D2B47),
blurRadius: 20,
offset: Offset(0, 6),
),
],
),
),
),
// 中层:左侧书形装饰图
Positioned(
left: _bookOffsetLeft.w,
bottom: _bookOffsetBottom.w,
width: _bookWidth.w,
height: (_bookHeight + _bookExtraHeight).w,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(_cardBgBook),
fit: BoxFit.contain,
alignment: Alignment.bottomLeft,
errorWidget: (_, __, ___) =>
SizedBox(width: _bookWidth.w, height: _bookHeight.w),
),
),
// 学科小图标(叠在书图上半部分)
Positioned(
left: 22.w,
top: 14.w,
child: CachedNetworkImage(
imageUrl: iconUrl,
width: 56.w,
height: 56.w,
errorWidget: (_, _, _) => Icon(
Icons.menu_book,
size: 56.w,
color: const Color(0xFF3D7DF5),
),
),
),
// 上层:内容(与卡片同高,底部对齐)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: _cardHeight.w,
child: Row(
children: [
// 左侧书图区域占位
SizedBox(width: 90.w),
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Row(
children: [
Expanded(child: _buildMid(timeText)),
_buildRight(),
],
),
),
),
],
),
),
// 学科标签(覆盖在书图底部胶囊上,卡片贴底)
Positioned(
left: -10.w,
bottom: 10.w,
width: _bookWidth.w,
child: Center(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 14.w,
vertical: 6.w,
),
decoration: BoxDecoration(
color: const Color(0xFFE8F2FF),
borderRadius: BorderRadius.circular(12.r),
),
child: Text(
subjectName,
style: TextStyle(
fontSize: 16.spMin,
fontWeight: FontWeight.w600,
color: const Color(0xFF3D7DF5),
height: 1.2,
),
),
),
),
),
],
),
),
);
}
// 中间:报告名 + 时间
Widget _buildMid(String timeText) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.reportName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.spMin,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
SizedBox(height: 8.h),
Text(
timeText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.spMin,
fontWeight: FontWeight.w400,
color: const Color(0xFF687FAD),
),
),
],
);
}
// 右侧:箭头
Widget _buildRight() {
return Icon(
Icons.chevron_right,
color: const Color(0xFF4B6B99),
size: 22.sp,
);
}
}
@@ -0,0 +1,278 @@
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/logger/app_talker.dart';
/// ============================================================
/// 【ReportDetailPage】报告详情 WebView 页 —— 对标 Vue reportWebView.vue
///
/// 一、为什么用 flutter_inappwebview 而不是官方 webview_flutter:
/// ✅ 需要 JS → Flutter 回调(addJavaScriptHandler)
/// ✅ 需要 Flutter → JS 主动调用(callAsyncJavaScript / evaluateJavascript)
/// ✅ 比 webview_flutter 功能更全,后续扩展方便
///
/// 二、路由参数(通过 GoRouteState.extra 传 Map):
/// context.push('/report-detail', extra: {'title': '...', 'url': '...'})
/// - title:AppBar 标题
/// - url:WebView 加载的 H5 报告 URL
///
/// 三、JS Bridge 完整指南(H5 ↔ Flutter 双向通信):
///
/// ┌─────────────── H5 调 Flutter ───────────────┐
/// │ │
/// │ Flutter 注册 handler(见 _registerJsHandlers)│
/// │ controller.addJavaScriptHandler( │
/// │ handlerName: 'closePage', │
/// │ callback: (args) { ... } │
/// │ ); │
/// │ │
/// │ H5 侧调用: │
/// │ // 关闭当前 WebView 页 │
/// │ window.flutter_inappwebview │
/// │ .callHandler('closePage'); │
/// │ │
/// │ // 带参数调用: │
/// │ window.flutter_inappwebview │
/// │ .callHandler('onReportAction', { │
/// │ action: 'download', │
/// │ data: 'xxx' │
/// │ }); │
/// │ │
/// │ H5 兼容写法(推荐,同时支持 UniApp web-view): │
/// │ function callFlutter(handlerName, args) { │
/// │ // Flutter inappwebview │
/// │ if (window.flutter_inappwebview) { │
/// │ window.flutter_inappwebview │
/// │ .callHandler(handlerName, args); │
/// │ } │
/// │ // UniApp web-view(可选,按需扩展) │
/// │ // #ifdef APP-PLUS │
/// │ // plus.webview.currentWebview()... │
/// │ // #endif │
/// │ } │
/// │ callFlutter('closePage'); │
/// │ │
/// └───────────────────────────────────────────────┘
///
/// ┌─────────────── Flutter 调 H5 ───────────────┐
/// │ │
/// │ // 主动调 H5 全局函数: │
/// │ await controller.callAsyncJavaScript( │
/// │ functionBody: │
/// │ 'window.onFlutterMessage("hello");', │
/// │ ); │
/// │ │
/// │ // 或者执行任意 JS 代码: │
/// │ await controller.evaluateJavascript( │
/// │ source: 'document.title', │
/// │ ); │
/// │ │
/// └───────────────────────────────────────────────┘
///
/// 四、支持的 JS handler 列表(H5 可调用这些名字):
///
/// handlerName 参数 作用
/// ──────────────────────────────────────────────────
/// closePage 无 关闭当前 WebView 页
/// onReportAction {action,data} H5 主动通知 Flutter 做某事
/// action 可取:
/// 'share' → 触发分享(TODO: 接 share_plus)
/// 'download' → 下载/保存报告
/// 'refresh' → Flutter 刷列表后回退
///
/// 五、平台配置(如需):
/// Android:AndroidManifest.xml 需 INTERNET 权限(flutter 默认已有)
/// iOS:Info.plist 需 NSAppTransportSecurity 允许 HTTP(如果报告页是 HTTPS 可忽略)
/// ============================================================
class ReportDetailPage extends StatefulWidget {
const ReportDetailPage({super.key, required this.title, required this.url});
final String title;
final String url;
@override
State<ReportDetailPage> createState() => _ReportDetailPageState();
}
class _ReportDetailPageState extends State<ReportDetailPage> {
InAppWebViewController? _webController;
/// WebView 加载进度(0~1),用于底部蓝色进度条
double _loadingProgress = 0;
@override
void initState() {
super.initState();
talker.debug('打开报告详情:${widget.title} → ${widget.url}');
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
title: Text(
widget.title,
style: const TextStyle(color: Color(0xFF1D2B47)),
),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
// 先尝试 WebView 内部 history 回退,不行再 pop 页面
onPressed: () async {
final controller = _webController;
if (controller != null && await controller.canGoBack()) {
await controller.goBack();
} else if (context.mounted) {
context.canPop() ? context.pop() : context.go('/my');
}
},
),
// 底部加载进度条(可选,提升体验)
bottom: PreferredSize(
preferredSize: const Size.fromHeight(2),
child: _loadingProgress < 1.0
? LinearProgressIndicator(
value: _loadingProgress,
backgroundColor: Colors.transparent,
valueColor: const AlwaysStoppedAnimation(Color(0xFF3D7DF5)),
)
: const SizedBox.shrink(),
),
),
body: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(widget.url)),
initialSettings: InAppWebViewSettings(
// 允许 JS(报告页大概率需要)
javaScriptEnabled: true,
// 支持内联视频播放
allowsInlineMediaPlayback: true,
// 允许文件访问
allowFileAccess: true,
// 自适应内容(防止移动端 H5 在 WebView 里显示过宽)
useHybridComposition: true,
),
onWebViewCreated: (controller) {
_webController = controller;
// ✅【关键】注册 JS Bridge:H5 通过 window.flutter_inappwebview.callHandler 调这些
_registerJsHandlers(controller);
talker.debug('WebView created, JS handlers registered');
},
onLoadStart: (controller, url) {
setState(() => _loadingProgress = 0);
talker.debug('WebView loading start: $url');
},
onLoadStop: (controller, url) async {
setState(() => _loadingProgress = 1);
talker.debug('WebView loading stop: $url');
// TODO: 可选:页面加载完后主动调 H5 的初始化函数
// await controller.callAsyncJavaScript(
// functionBody: 'window.onFlutterReady?.();',
// );
},
onProgressChanged: (controller, progress) {
setState(() => _loadingProgress = progress / 100.0);
},
// WebView 内部请求拦截(可选:如需给 H5 接口加 token,可在这里处理)
// shouldInterceptRequest: (controller, request) async {
// final url = request.url?.toString() ?? '';
// if (url.contains('/api/')) {
// // 给 H5 的接口请求追加 Authorization header
// return request.copyWith(
// headers: {
// ...request.headers ?? {},
// 'Authorization': 'Bearer ${DioClient.instance.accessToken}',
// },
// );
// }
// return null;
// },
),
);
}
// ============================================================
// JS Bridge 注册 —— 所有 handler 在这里统一定义
//
// H5 调用方式:window.flutter_inappwebview.callHandler('closePage')
// 或带参数:window.flutter_inappwebview.callHandler('onReportAction', {action:'xxx'})
//
// 注意:handler 必须在 onWebViewCreated 里注册,页面加载前就要就绪。
// ============================================================
void _registerJsHandlers(InAppWebViewController controller) {
// ---- 1. closePage:关闭当前 WebView 页 ----
// H5 调用:window.flutter_inappwebview.callHandler('closePage')
controller.addJavaScriptHandler(
handlerName: 'closePage',
callback: (args) {
talker.debug('[JS→Flutter] closePage called, args: $args');
if (context.mounted) {
context.canPop() ? context.pop() : context.go('/my');
}
},
);
// ---- 2. onReportAction:H5 通知 Flutter 执行某动作 ----
// H5 调用:
// window.flutter_inappwebview.callHandler('onReportAction', {
// action: 'download',
// data: 'report_xxx.pdf'
// })
controller.addJavaScriptHandler(
handlerName: 'onReportAction',
callback: (args) {
// args 是 H5 传过来的参数数组,第一个元素通常是对象
final payload = args.isNotEmpty ? args.first : null;
talker.debug('[JS→Flutter] onReportAction called, payload: $payload');
// 解析 action 字段
String? action;
String? data;
if (payload is Map) {
action = payload['action']?.toString();
data = payload['data']?.toString();
}
switch (action) {
case 'share':
// TODO: 接 share_plus 分享报告链接
talker.debug('[JS→Flutter] → share, data: $data');
break;
case 'download':
// TODO: 下载/保存报告文件
talker.debug('[JS→Flutter] → download, data: $data');
break;
case 'refresh':
// H5 处理完后要求 Flutter 刷列表并回退
talker.debug('[JS→Flutter] → refresh');
if (context.mounted) {
context.canPop() ? context.pop(true) : context.go('/myReport');
}
break;
default:
talker.debug('[JS→Flutter] → unknown action: $action');
}
},
);
// ---- 3. 更多 handler 按需添加 ----
// 模板(复制下面这几行,改 handlerName 和 callback 即可):
//
// controller.addJavaScriptHandler(
// handlerName: 'yourHandlerName',
// callback: (args) {
// // TODO: 你的处理逻辑
// },
// );
}
@override
void dispose() {
_webController?.dispose();
super.dispose();
}
}
+372
View File
@@ -0,0 +1,372 @@
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/user_collect/model.dart';
import 'package:new_learn/core/api/user_note/api.dart';
import 'package:new_learn/core/api/user_note/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/components/rich_text_content.dart';
import 'package:new_learn/core/components/subject_tag.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【NotePage】我的笔记 —— 对标 Vue pages/myPackage/note/note.vue
///
/// 页面结构(对齐 Vue,从上到下):
/// 1. 学科筛选 Tab("全部" + 接口返回学科,胶囊样式,点击切学科重载)
/// 2. #F5F8FA 列表区:CommonPagedList 游标分页
/// 卡片 = SubjectTag + 时间 + title + 内容富文本(限高渐隐)
/// + 右上角笔记类型角标
///
/// 与收藏/错题列表的差异:
/// ① 没有顶部总数头;
/// ② 列表不按类型过滤,type 由每条数据自带:
/// 1=题目笔记(note-stem.png)2=讲义笔记(note-teaching.png),
/// 仅用角标图标区分,详情页再按 type 渲染不同内容;
/// ③ 标题字段 title、正文字段 content。
///
/// 分页:pageKey 当 pageIndex;prevPageId 闭包维护——首页/刷新重置为 0,
/// 每页请求后更新为本页最后一条 id;切学科用 ValueKey 重建列表回首页。
/// ============================================================
class NotePage extends StatefulWidget {
const NotePage({super.key});
@override
State<NotePage> createState() => _NotePageState();
}
class _NotePageState extends State<NotePage> {
static const int _pageSize = 10;
/// 学科 Tab 列表("全部"为客户端插入,id=null)
List<CollectTab> _tabs = const [CollectTab(name: '全部')];
/// 当前选中的学科 id(null = 全部)
int? _subjectId;
/// 游标:本页最后一条 id,首页/刷新/切学科时重置为 0
int _prevPageId = 0;
@override
void initState() {
super.initState();
talker.debug('打开页面:我的笔记');
_loadTabs();
}
/// 拉取学科筛选项;失败兜底只有"全部"(对齐 Vue catch 逻辑)
Future<void> _loadTabs() async {
try {
final tabs = await UserNoteApi.instance.getSubjects();
if (!mounted) return;
setState(() {
_tabs = [const CollectTab(name: '全部'), ...tabs];
});
} catch (e) {
talker.error('获取笔记学科Tab失败', e);
if (!mounted) return;
setState(() {
_tabs = const [CollectTab(name: '全部')];
});
}
}
/// 分页加载(CommonPagedList 回调)
Future<PagingResult<UserNoteItem>> _load(int pageKey) async {
// 首页/下拉刷新:游标重置(对齐 Vue useList pageNo===1 时 prevPageId=0)
if (pageKey == 1) _prevPageId = 0;
final res = await UserNoteApi.instance.getNoteList(
subjectId: _subjectId,
pageIndex: pageKey,
pageSize: _pageSize,
prevPageId: _prevPageId,
);
// 更新游标为本页最后一条 id
_prevPageId = res.data.isNotEmpty ? res.data.last.id : 0;
return PagingResult<UserNoteItem>(
data: res.data,
hasNext: res.total > pageKey * _pageSize,
);
}
/// 切学科:ValueKey 变化后 CommonPagedList 整体重建,自动回到第一页
void _onTabTap(CollectTab tab) {
if (_subjectId == tab.id) return;
setState(() {
_subjectId = tab.id;
_prevPageId = 0;
});
}
/// 点击卡片 → 笔记详情页
/// TODO: 详情页待接入,需要按 item.type 区分:
/// type=1 题目笔记 / type=2 讲义笔记,详情页内容结构不同;
/// Vue 跳转:/pages/myPackage/noteDetail/index?id=${item.id}&type=${item.type}
void _goDetail(UserNoteItem item) {
talker.debug('笔记详情待接入:id=${item.id} type=${item.type}(1题目笔记/2讲义笔记)');
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
surfaceTintColor: Colors.white,
elevation: 0,
title: const Text('我的笔记', style: TextStyle(color: Color(0xFF1D2B47))),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
),
),
body: Column(
children: [
_buildTabBar(),
// 列表区(#F5F8FA 灰底)
Expanded(
child: Container(
color: const Color(0xFFF5F8FA),
child: CommonPagedList<UserNoteItem>(
// 切学科时重建列表,状态自动回第一页
key: ValueKey('note_list_$_subjectId'),
// Vue list-refresh-wrap padding-top:32rpx + listWrapper 28rpx
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
emptyWidget: _buildEmpty(),
load: _load,
buildItem: (context, item, index) =>
_NoteCard(item: item, onTap: () => _goDetail(item)),
),
),
),
],
),
);
}
/// 学科筛选胶囊 Tab 栏(横向滚动)
Widget _buildTabBar() {
return Container(
color: Colors.white,
height: 56.h, // 上下 padding 12.h + 胶囊 32.h(对齐 Vue 24rpx+64rpx)
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 12.w),
itemCount: _tabs.length,
separatorBuilder: (_, _) => SizedBox(width: 12.w),
itemBuilder: (context, i) {
final tab = _tabs[i];
final active = _subjectId == tab.id;
return Center(
child: GestureDetector(
onTap: () => _onTabTap(tab),
child: Container(
height: 32.h,
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: active
? const Color(0xFF3D7DF5)
: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(999),
),
child: Text(
tab.name,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w400,
color: active ? Colors.white : const Color(0xFF687FAD),
),
),
),
),
);
},
),
);
}
/// 空状态:空态图 + 文案(对齐 Vue DataEmpty noNotess.png)
Widget _buildEmpty() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build('/StaticResource/2026-04-11/noNotess.png'),
width: 160.w,
fit: BoxFit.contain,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
SizedBox(height: 12.h),
Text(
'暂无笔记',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
),
],
),
);
}
}
/// 单条笔记卡片:SubjectTag + 时间 + title + 内容富文本 + 右上角类型角标
class _NoteCard extends StatelessWidget {
const _NoteCard({required this.item, required this.onTap});
final UserNoteItem item;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: 14.h,
), // Vue listItem margin-bottom: 28rpx
child: Stack(
clipBehavior: Clip.none, // 允许类型角标超出卡片顶部
children: [
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16.r),
// 去掉点击时默认的灰色水波纹/高亮(Vue 端卡片无按压反馈)
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
child: Padding(
// Vue padding: t-32 l/r-28 b-28
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:学科标签 + 时间
Row(
children: [
SubjectTag(subject: item.subjectName),
SizedBox(width: 12.w),
Text(
item.createTime,
style: TextStyle(
fontSize: 12.sp, // Vue font-24
color: const Color(0xFFB4C2E0),
),
),
],
),
SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx
// 笔记标题
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp, // Vue font-32
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
SizedBox(
height: 16.h,
), // Vue question-stem-wrap margin-top: 32rpx
// 笔记内容富文本(限高渐隐)
_NoteContentBox(html: item.content),
],
),
),
),
),
// 右上角类型角标:Vue note-type-icon top:-4rpx right:28rpx 56rpx
Positioned(top: -2.h, right: 14.w, child: _buildTypeIcon()),
],
),
);
}
/// 笔记类型角标:type=1 题目笔记(note-stem)/ 2 讲义笔记(note-teaching)
Widget _buildTypeIcon() {
final icon = item.type == 2
? '/StaticResource/2026-04-07/note-teaching.png'
: '/StaticResource/2026-04-07/note-stem.png';
return CachedNetworkImage(
imageUrl: ImgUrl.build(icon),
width: 28.w,
height: 28.h,
fit: BoxFit.contain,
placeholder: (_, _) => SizedBox(width: 28.w, height: 28.h),
errorWidget: (_, _, _) => SizedBox(width: 28.w, height: 28.h),
);
}
}
/// 笔记内容容器:#FAFBFD 底 + #E3ECFF 边框,内容限高 70.h(Vue 140rpx),
/// 超出部分底部渐隐遮罩(与收藏/错题列表题干样式一致)
class _NoteContentBox extends StatelessWidget {
const _NoteContentBox({required this.html});
final String html;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(12.w), // Vue padding: 24rpx
decoration: BoxDecoration(
color: const Color(0xFFFAFBFD),
borderRadius: BorderRadius.circular(8.r), // Vue 16rpx
border: Border.all(color: const Color(0xFFE3ECFF)),
),
child: Stack(
children: [
// 限高裁剪:OverflowBox 让富文本按自然高度布局,超出 70.h 的部分裁掉
ClipRect(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 70.h),
child: OverflowBox(
alignment: Alignment.topCenter,
minHeight: 0,
maxHeight: double.infinity,
child: RichTextContent(
html: html,
textStyle: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: const Color(0xFF495A7A),
height: 1.5,
),
),
),
),
),
// 底部渐隐遮罩(Vue mask-layer 60rpx):
// 终点色与底色 #FAFBFD 一致,内容不足时遮罩盖在底色上视觉不可见
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 30.h,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00FAFBFD), Color(0xFFFAFBFD)],
),
borderRadius: BorderRadius.circular(8.r),
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,528 @@
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/user_collect/api.dart';
import 'package:new_learn/core/api/user_collect/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/components/rich_text_content.dart';
import 'package:new_learn/core/components/subject_tag.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/core/utils/screen_adapt_template.dart';
/// ============================================================
/// 【QuestionListPage】我的收藏 / 错题本 列表页
/// —— 对标 Vue pages/myPackage/myCollect/myCollect.vue
/// 和 pages/myPackage/myWrong/myWrong.vue
///
/// 两个页面结构完全一致(同一套接口 UserCollect),仅 [type] 不同:
/// type=1 我的收藏(/myCollect) type=2 错题本(/myWrong)
/// 标题 / 总数文案 / 顶部背景图 / 空状态 / 装饰色带 由 [_config] 按 type 切换。
///
/// 页面结构(对齐 Vue,从上到下):
/// 1. 学科筛选 Tab("全部" + 接口返回学科,胶囊样式,点击切学科重载)
/// 2. 总数头:主题背景图 + 大号总数 + 装饰色带 + 文案(道错题/收藏题目)
/// 3. #F5F8FA 列表区:CommonPagedList 游标分页
/// 卡片 = SubjectTag + 时间 + packName + 题干富文本(限高渐隐)
///
/// 分页要点(游标分页,对齐 Vue useList):
/// pageKey 当 pageIndex;prevPageId 在闭包里维护——首页/刷新重置为 0,
/// 每页请求后更新为本页最后一条 id;切学科用 ValueKey 重建列表(天然回首页)。
///
/// 题干限高:Vue 用 DOM 测量后加渐隐遮罩;Flutter 用 OverflowBox 限高 70.h
/// 裁剪 + 底部固定渐隐遮罩(遮罩终点色与题干底色色值一致,内容不足时不可见)。
/// ============================================================
class QuestionListPage extends StatefulWidget {
const QuestionListPage({super.key, required this.type});
/// 1=我的收藏 2=错题本
final int type;
@override
State<QuestionListPage> createState() => _QuestionListPageState();
}
class _QuestionListPageState extends State<QuestionListPage> {
static const int _pageSize = 10;
/// 学科 Tab 列表("全部"为客户端插入,id=null)
List<CollectTab> _tabs = const [CollectTab(name: '全部')];
/// 当前选中的学科 id(null = 全部)
int? _subjectId;
/// 顶部总数(来自分页响应 total)
int _total = 0;
/// 游标:本页最后一条 id,首页/刷新/切学科时重置为 0
int _prevPageId = 0;
_PageConfig get _config =>
widget.type == 1 ? _PageConfig.collect : _PageConfig.wrong;
@override
void initState() {
super.initState();
talker.debug('打开页面:${widget.type == 1 ? '我的收藏' : '错题本'}');
_loadTabs();
}
/// 拉取学科筛选项;失败兜底只有"全部"(对齐 Vue catch 逻辑)
Future<void> _loadTabs() async {
try {
final tabs = await UserCollectApi.instance.getSubjects(widget.type);
if (!mounted) return;
setState(() {
_tabs = [const CollectTab(name: '全部'), ...tabs];
});
} catch (e) {
talker.error('获取学科Tab失败', e);
if (!mounted) return;
setState(() {
_tabs = const [CollectTab(name: '全部')];
});
}
}
/// 分页加载(CommonPagedList 回调):只写请求 + 返回数据
Future<PagingResult<UserCollectItem>> _load(int pageKey) async {
// 首页/下拉刷新:游标重置(对齐 Vue useList pageNo===1 时 prevPageId=0)
if (pageKey == 1) _prevPageId = 0;
final res = await UserCollectApi.instance.getCollectList(
type: widget.type,
subjectId: _subjectId,
pageIndex: pageKey,
pageSize: _pageSize,
prevPageId: _prevPageId,
);
// 更新游标为本页最后一条 id
_prevPageId = res.data.isNotEmpty ? res.data.last.id : 0;
if (mounted) {
setState(() => _total = res.total);
}
return PagingResult<UserCollectItem>(
data: res.data,
hasNext: res.total > pageKey * _pageSize,
);
}
/// 切学科:ValueKey 变化后 CommonPagedList 整体重建,自动回到第一页
void _onTabTap(CollectTab tab) {
if (_subjectId == tab.id) return;
setState(() {
_subjectId = tab.id;
_prevPageId = 0;
_total = 0;
});
}
/// 点击卡片 → 题目解析详情页
/// TODO: 详情页待接入——错题本跳 questionAnalysis、收藏跳 collectAnalysis,
/// 参数:questionId=${item.questionId}
/// recordQuestionId=${item.recordQuestionId}
/// listDetailId=${item.id}
void _goDetail(UserCollectItem item) {
talker.debug(
'题目详情待接入:type=${widget.type} questionId=${item.questionId} '
'recordQuestionId=${item.recordQuestionId} listDetailId=${item.id}',
);
}
@override
Widget build(BuildContext context) {
final cfg = _config;
final isPad =
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
surfaceTintColor: Colors.white,
elevation: 0,
title: Text(
cfg.title,
style: const TextStyle(color: Color(0xFF1D2B47)),
),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
),
),
body: Column(
children: [
// 1. 学科筛选 Tab(白底)
_buildTabBar(),
// 2. 总数头(背景图 + 大号数字)
_buildTotalHeader(cfg, isPad),
// 3. 列表区(#F5F8FA 灰底)
Expanded(
child: Container(
color: const Color(0xFFF5F8FA),
child: CommonPagedList<UserCollectItem>(
// 切学科时重建列表,状态自动回第一页
key: ValueKey('question_list_${widget.type}_$_subjectId'),
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
emptyWidget: _buildEmpty(cfg),
load: _load,
buildItem: (context, item, index) => _QuestionCard(
item: item,
onTap: () => _goDetail(item),
),
),
),
),
],
),
);
}
/// 学科筛选胶囊 Tab 栏(横向滚动)
Widget _buildTabBar() {
return Container(
color: Colors.white,
height: 56.h, // 上下 padding 12.h + 胶囊 32.h(对齐 Vue 24rpx+64rpx)
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 12.w),
itemCount: _tabs.length,
separatorBuilder: (_, __) => SizedBox(width: 12.w),
itemBuilder: (context, i) {
final tab = _tabs[i];
final active = _subjectId == tab.id;
return Center(
child: GestureDetector(
onTap: () => _onTabTap(tab),
child: Container(
height: 32.h,
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: active
? const Color(0xFF3D7DF5)
: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(999),
),
child: Text(
tab.name,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w400,
color: active
? Colors.white
: const Color(0xFF687FAD),
),
),
),
),
);
},
),
);
}
/// 总数头:背景图(widthFix)+ 大号总数 + 装饰色带 + 文案
Widget _buildTotalHeader(_PageConfig cfg, bool isPad) {
final numFontSize = isPad ? 52.5.sp : 35.sp;
final labelFontSize = isPad ? 21.sp : 14.sp;
return Stack(
children: [
// 背景图:宽度撑满,高度按图比例(对齐 Vue mode="widthFix")
CachedNetworkImage(
imageUrl: ImgUrl.build(isPad ? cfg.topBgPad : cfg.topBgPhone),
width: double.infinity,
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
// 数字 + 色带 + 文案(绝对定位叠在图上)
Positioned(
top: isPad ? 45.h : 16.5.h, // Vue padding-top: 90rpx / 33rpx
left: 30.w, // Vue padding-left: 60rpx
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 大号数字 + 底部装饰色带
Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
bottom: isPad ? -7.h : -4.h,
child: Container(
height: 9.h, // Vue 18rpx
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50.r),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: cfg.underlineColors,
stops: const [0, 0.36, 1],
),
),
),
),
Text(
'$_total',
style: TextStyle(
fontSize: numFontSize,
fontWeight: isPad ? FontWeight.w500 : FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
],
),
SizedBox(width: 10.w), // Vue margin-l-20
// 文案(Vue margin-t-30:相对数字略微下沉)
Padding(
padding: EdgeInsets.only(top: 15.h),
child: Text(
cfg.totalLabel,
style: TextStyle(
fontSize: labelFontSize,
fontWeight: FontWeight.w400,
color: const Color(0xFF687FAD),
),
),
),
],
),
),
],
);
}
/// 空状态:空态图 + 文案(对齐 Vue DataEmpty)
Widget _buildEmpty(_PageConfig cfg) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(cfg.emptyImg),
width: 160.w,
fit: BoxFit.contain,
placeholder: (_, __) => const SizedBox.shrink(),
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
SizedBox(height: 12.h),
Text(
cfg.emptyText,
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
),
],
),
);
}
}
/// 单条收藏/错题卡片:SubjectTag + 时间 + packName + 题干富文本
///
/// 按压反馈:按下缩放到 0.97、抬起回弹,背景始终白色
/// (不用 InkWell 灰底,改用 GestureDetector 驱动 AnimatedScale)。
class _QuestionCard extends StatefulWidget {
const _QuestionCard({required this.item, required this.onTap});
final UserCollectItem item;
final VoidCallback onTap;
@override
State<_QuestionCard> createState() => _QuestionCardState();
}
class _QuestionCardState extends State<_QuestionCard> {
/// 是否处于按压状态(控制 0.97 缩放)
bool _pressed = false;
void _setPressed(bool v) {
if (_pressed != v) setState(() => _pressed = v);
}
@override
Widget build(BuildContext context) {
final item = widget.item;
return Padding(
padding: EdgeInsets.only(bottom: 14.h), // Vue listItem margin-bottom: 28rpx
child: GestureDetector(
onTapDown: (_) => _setPressed(true),
onTapUp: (_) => _setPressed(false),
onTapCancel: () => _setPressed(false),
onTap: widget.onTap,
child: AnimatedScale(
scale: _pressed ? 0.97 : 1.0,
duration: const Duration(milliseconds: 100),
curve: Curves.easeOut,
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
child: Padding(
// Vue padding: t-32 l/r-28 b-28
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 14.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:学科标签 + 时间
Row(
children: [
SubjectTag(subject: item.subjectName),
const Spacer(),
Text(
item.createTime,
style: TextStyle(
fontSize: 12.sp, // Vue font-24
color: const Color(0xFFB4C2E0),
),
),
],
),
SizedBox(height: 14.h), // Vue pack-name margin-top: 28rpx
// 试卷/礼包名
Text(
item.packName,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16.sp, // Vue font-32
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
SizedBox(height: 16.h), // Vue question-stem-wrap margin-top: 32rpx
// 题干富文本(限高渐隐)
_StemBox(html: item.questionStem),
],
),
),
),
),
),
);
}
}
/// 题干容器:#FAFBFD 底 + #E3ECFF 边框,内容限高 70.h(Vue 140rpx),
/// 超出部分底部渐隐遮罩
class _StemBox extends StatelessWidget {
const _StemBox({required this.html});
final String html;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.all(12.w), // Vue padding: 24rpx
decoration: BoxDecoration(
color: const Color(0xFFFAFBFD),
borderRadius: BorderRadius.circular(8.r), // Vue 16rpx
border: Border.all(color: const Color(0xFFE3ECFF)),
),
child: Stack(
children: [
// 限高裁剪:OverflowBox 让富文本按自然高度布局,超出 70.h 的部分裁掉
ClipRect(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 70.h),
child: OverflowBox(
alignment: Alignment.topCenter,
minHeight: 0,
maxHeight: double.infinity,
child: RichTextContent(
html: html,
textStyle: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w400,
color: const Color(0xFF495A7A),
height: 1.5,
),
),
),
),
),
// 底部渐隐遮罩(Vue mask-layer 60rpx):
// 终点色与底色 #FAFBFD 一致,内容不足时遮罩盖在底色上视觉不可见
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 30.h,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x00FAFBFD), Color(0xFFFAFBFD)],
),
borderRadius: BorderRadius.circular(8.r),
),
),
),
),
],
),
);
}
}
/// 页面差异化配置(按 type 切换文案/图片/配色)
class _PageConfig {
const _PageConfig({
required this.title,
required this.totalLabel,
required this.topBgPhone,
required this.topBgPad,
required this.emptyImg,
required this.emptyText,
required this.underlineColors,
});
final String title;
final String totalLabel;
final String topBgPhone;
final String topBgPad;
final String emptyImg;
final String emptyText;
final List<Color> underlineColors;
/// 我的收藏(type=1):橙色系装饰
static final _PageConfig collect = _PageConfig(
title: '我的收藏',
totalLabel: '收藏题目',
topBgPhone: '/StaticResource/2026-04-08/my-collect-top-bg.png',
topBgPad: '/StaticResource/2026-06-02/my-collect-top-bg-pad1780378706432.png',
emptyImg: '/StaticResource/2026-04-11/contentEmptys.png',
emptyText: '暂无收藏',
// Vue: linear-gradient(90deg, #F59C3D00 0%, #FFCA1D33 36%, #FDAD8A80 100%)
underlineColors: const [
Color(0x00F59C3D),
Color(0x33FFCA1D),
Color(0x80FDAD8A),
],
);
/// 错题本(type=2):蓝色系装饰
static final _PageConfig wrong = _PageConfig(
title: '错题本',
totalLabel: '道错题',
topBgPhone: '/StaticResource/2026-04-08/my-wrong-top-bg.png',
topBgPad: '/StaticResource/2026-06-02/my-wrong-top-bg-pad1780372865693.png',
emptyImg: '/StaticResource/2026-04-11/noDatas.png',
emptyText: '暂无错题',
// Vue: linear-gradient(90deg, #3D7DF500 0%, #1D9CFF33 36%, #8AECFD80 100%)
underlineColors: const [
Color(0x003D7DF5),
Color(0x331D9CFF),
Color(0x808AECFD),
],
);
}
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/version/app_version_service.dart';
/// ============================================================
/// 【AboutAppPage】关于APP页 —— 对齐 Vue pages/myPackage/aboutApp/index.vue
///
/// 菜单:隐私政策 / 用户协议 / 版本号(只读)
/// 底部:APP备案号
/// ============================================================
class AboutAppPage extends ConsumerStatefulWidget {
const AboutAppPage({super.key});
@override
ConsumerState<AboutAppPage> createState() => _AboutAppPageState();
}
class _AboutAppPageState extends ConsumerState<AboutAppPage> {
String _version = '';
static const _recordNumber = '京ICP备2026019420号-2A';
@override
void initState() {
super.initState();
_loadVersion();
}
Future<void> _loadVersion() async {
final service = AppVersionService.instance;
if (service.versionName.isEmpty) {
await service.init();
}
if (mounted) setState(() => _version = service.versionName);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F8FA),
appBar: AppBar(
title: const Text('关于APP'),
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
),
),
body: ListView(
padding: EdgeInsets.all(12.w),
children: [
// 隐私政策
_buildRow(
title: '隐私政策',
trailing: const Icon(
Icons.chevron_right,
size: 22,
color: Color(0xFF7FB0F2),
),
onTap: () => context.push('/privacy-policy'),
showBorder: true,
),
// 用户协议
_buildRow(
title: '用户协议',
trailing: const Icon(
Icons.chevron_right,
size: 22,
color: Color(0xFF7FB0F2),
),
onTap: () => context.push('/user-agreement'),
showBorder: true,
),
// 版本号(只读,无跳转)
_buildRow(
title: '版本号',
trailing: Text(
_version,
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF687FAD)),
),
onTap: null,
showBorder: false,
),
SizedBox(height: 48.h),
// 备案号
Center(
child: Text(
'APP备案编号:$_recordNumber',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF687FAD)),
),
),
],
),
);
}
Widget _buildRow({
required String title,
required Widget trailing,
required VoidCallback? onTap,
required bool showBorder,
}) {
return InkWell(
onTap: onTap,
child: Column(
children: [
Container(
height: 52.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
color: Colors.white,
child: Row(
children: [
Text(
title,
style: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF1D2B47),
),
),
const Spacer(),
trailing,
],
),
),
if (showBorder)
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Container(height: 1, color: const Color(0xFFEDF3F7)),
),
],
),
);
}
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/auth/api.dart';
import 'package:new_learn/core/components/app_update_dialog.dart';
import 'package:new_learn/core/components/confirm_box.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/storage/user_storage.dart';
import 'package:new_learn/core/version/app_version_service.dart';
/// ============================================================
/// 【SettingsPage】设置页 —— 对齐 Vue pages/myPackage/settings/index.vue
///
/// 三栏菜单:
/// 检查更新(右侧显示当前版本号,手动触发版本比对弹窗)
/// 关于APP (TODO: 跳转关于页)
/// 注销账号 (Vue 端受 isAuditMode 控制,Flutter 暂先显示)
///
/// 样式:白色圆角卡片 + 分隔线,和 my_page 的 _MenuCard 风格一致
/// ============================================================
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key});
@override
ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
String _version = '';
@override
void initState() {
super.initState();
_loadVersion();
}
Future<void> _loadVersion() async {
final service = AppVersionService.instance;
if (service.versionName.isEmpty) {
await service.init();
}
if (mounted) {
setState(() => _version = service.versionName);
}
}
Future<void> _handleCheckUpdate() async {
// 手动检查更新:复用 AppVersionService + AppUpdateDialog
try {
final service = AppVersionService.instance;
await service.init();
final update = await service.checkForUpdate();
if (!mounted) return;
if (update == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已是最新版本')));
return;
}
await AppUpdateDialog.show(
context,
update,
isForce: service.isForceUpdate(),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('检查更新失败:$e')));
}
}
Future<void> _handleCloseAccount() async {
final ok = await showConfirmBox(
context,
title: '注销提醒',
content: '账号注销后,将会删除账号的全部数据,不可恢复。请确认是否注销账号?',
confirmText: '确定注销',
cancelText: '我再想想',
);
if (!ok) return;
try {
await UserApi.closeAccount();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('注销失败:$e')));
return;
}
// 清理本地登录态 → 路由守卫自动踢回登录页
DioClient.instance.accessToken = null;
await UserStorage.instance.clearAll();
if (!mounted) return;
ref.read(currentUserProvider.notifier).set(null);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F8FA),
appBar: AppBar(
title: const Text('设置'),
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.canPop() ? context.pop() : context.go('/my'),
),
),
body: ListView(
padding: EdgeInsets.all(12.w),
children: [
// 检查更新
_buildRow(
title: '检查更新',
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_version.isNotEmpty)
Text(
'v$_version',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF687FAD),
),
),
SizedBox(width: 4.w),
const Icon(
Icons.chevron_right,
size: 22,
color: Color(0xFF7FB0F2),
),
],
),
onTap: _handleCheckUpdate,
),
_buildDivider(),
// 关于APP
_buildRow(
title: '关于APP',
trailing: const Icon(
Icons.chevron_right,
size: 22,
color: Color(0xFF7FB0F2),
),
onTap: () => context.push('/about-app'),
),
_buildDivider(),
// 注销账号
_buildRow(
title: '注销账号',
trailing: const Icon(
Icons.chevron_right,
size: 22,
color: Color(0xFF7FB0F2),
),
onTap: _handleCloseAccount,
),
],
),
);
}
Widget _buildDivider() {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Container(height: 1, color: const Color(0xFFEDF3F7)),
);
}
Widget _buildRow({
required String title,
required Widget trailing,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
child: Container(
height: 52.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
color: Colors.white,
child: Row(
children: [
Text(
title,
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF1D2B47)),
),
const Spacer(),
trailing,
],
),
),
);
}
}
+453
View File
@@ -0,0 +1,453 @@
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/widgets/recite_circle_progress.dart';
import 'package:new_learn/features/recite/widgets/recite_progress.dart';
/// ============================================================
/// 【ChaptersPage】词汇章节 —— 对标 Vue pages/recitePackage/lexicon/lexicon.vue
///
/// 入参 bookId(路由 path 参数,对标 Vue onLoad options.bookId,默认 1)。
/// 数据:getChapters(bookId),失败 toast「页面加载失败,请重试」。
/// 点章节 → /recite/levels(extra 传整个 Chapter,替代 eventChannel);
/// 从关卡链路返回后重新拉取(对标 Vue onShow 刷新)。
/// ============================================================
class ChaptersPage extends StatefulWidget {
const ChaptersPage({super.key, required this.bookId});
final int bookId;
@override
State<ChaptersPage> createState() => _ChaptersPageState();
}
class _ChaptersPageState extends State<ChaptersPage> {
final List<Chapter> _list = [];
double _rate = 0;
bool _loading = false;
bool get _isPad =>
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
@override
void initState() {
super.initState();
_getList();
}
Future<void> _getList() async {
if (mounted) setState(() => _loading = true);
try {
final res = await LexiconApi.instance.getChapters(widget.bookId);
if (!mounted) return;
setState(() {
_rate = res.totalFinishProgress * 100;
_list
..clear()
..addAll(res.chapters);
});
} catch (_) {
if (mounted) {
CenterToast.show(
context,
'页面加载失败,请重试',
duration: const Duration(milliseconds: 2000),
);
}
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _onClickCell(Chapter item) async {
await context.push('/recite/levels', extra: item);
if (mounted) _getList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
),
body: Stack(
children: [
// 顶部背景大图
Positioned(
top: 0,
left: 0,
right: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-01/img_index_bgtop.png',
),
fit: BoxFit.fitWidth,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
// 白色内容卡(mp margin-top 19%,pad 30%)
Positioned.fill(
top: 0,
child: SafeArea(
child: Column(
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final topGap =
constraints.maxHeight * (_isPad ? 0.30 : 0.19);
return Padding(
// Vue: -8rpx 让卡片向两侧溢出
padding: EdgeInsets.only(top: topGap),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(_isPad ? 30.r : 24.r),
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
_buildContent(),
// 鳄鱼装饰(top:-35rpx left:30rpx;pad 加大)
Positioned(
top: -17.5.h,
left: _isPad ? 30.w : 15.w,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-01/img_index_crocodile.png',
),
width: _isPad ? 160.w : 109.w,
fit: BoxFit.fitWidth,
placeholder: (_, _) =>
const SizedBox.shrink(),
errorWidget: (_, _, _) =>
const SizedBox.shrink(),
),
),
// 总进度圆环(top:-35rpx right:50rpx)
Positioned(
top: -17.5.h,
right: _isPad ? 50.w : 25.w,
child: _CircleTotalProgress(rate: _rate),
),
],
),
),
);
},
),
),
],
),
),
),
],
),
);
}
Widget _buildContent() {
return Padding(
// Vue chapter-list: 40rpx 横向,50rpx 顶
padding: EdgeInsets.fromLTRB(20.w, 25.h, 20.w, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(),
Expanded(
child: _loading
? Center(
child: Row(
mainAxisSize: MainAxisSize.min,
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),
),
),
],
),
)
: ListView.builder(
padding: EdgeInsets.only(top: 20.h),
itemCount: _list.length,
itemBuilder: (_, i) =>
_ChapterItem(item: _list[i], onTap: _onClickCell),
),
),
],
),
);
}
/// 词汇章节标题 + 短渐变线
Widget _buildTitle() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'词汇章节',
style: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF333333),
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 2.h),
Container(
width: 62.w,
height: 4.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2.r),
gradient: const LinearGradient(
colors: [Color(0x661BC26E), Color(0x667ED8AA)],
),
),
),
],
);
}
}
/// 总进度圆环(白色衬底圆 + 绿色环 + 总进度文案)
class _CircleTotalProgress extends StatelessWidget {
const _CircleTotalProgress({required this.rate});
final double rate;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 67.5.w,
height: 67.5.w,
child: Stack(
alignment: Alignment.center,
children: [
// 白圆衬底(Vue 135rpx,top/left -8rpx)
Container(
width: 67.5.w,
height: 67.5.w,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
),
ReciteCircleProgress(
percent: rate,
size: 60,
strokeWidth: 4,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${rate.toInt()}',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.w600,
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: 2.h),
Text(
'总进度',
style: TextStyle(fontSize: 10.sp, color: const Color(0xFF999999)),
),
],
);
}
}
/// 章节列表项(左封面叠字 + 右侧渐变信息卡)
class _ChapterItem extends StatelessWidget {
const _ChapterItem({required this.item, required this.onTap});
final Chapter item;
final ValueChanged<Chapter> onTap;
@override
Widget build(BuildContext context) {
final percent = (item.finishProgress * 100).toInt();
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => onTap(item),
child: Container(
height: 100.h,
margin: EdgeInsets.symmetric(vertical: 10.h),
child: Stack(
children: [
// 渐变信息卡(左留 96 给封面;高度 90 贴底)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: 90.h,
child: Container(
padding: EdgeInsets.fromLTRB(96.w, 7.5.h, 14.w, 10.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6.r),
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFFE1FAED), Colors.white],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
color: const Color(0xFF333333),
),
),
const Spacer(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
'已完成',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
SizedBox(width: 3.w),
Text(
'$percent%',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF1BC26E),
),
),
],
),
Row(
children: [
Text(
'${item.finishLexiconCount}',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF1BC26E),
),
),
Text(
'/${item.lexiconCount}',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
],
),
],
),
SizedBox(height: 6.h),
SegmentedProgressBar(progress: item.finishProgress * 100),
],
),
),
),
// 左侧封面(height 100,叠白色 “index title”)
Positioned(
left: 0,
top: 0,
bottom: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(item.cover),
fit: BoxFit.fitHeight,
placeholder: (_, _) => SizedBox(width: 96.w),
errorWidget: (_, _, _) => SizedBox(width: 96.w),
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: 12.5.w,
top: 9.h,
right: 4.w,
child: Text(
item.index.isEmpty ? item.title : '${item.index} ${item.title}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11.sp, color: Colors.white),
),
),
],
),
),
);
}
}
+512
View File
@@ -0,0 +1,512 @@
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),
],
),
);
}
}
+912
View File
@@ -0,0 +1,912 @@
import 'dart:async';
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_loading.dart';
import 'package:new_learn/features/recite/widgets/voice_anim_button.dart';
/// ============================================================
/// 【LevelStartPage】闯关答题 —— 对标 Vue levelStart.vue
///
/// 完整流程:
/// 1. getLevels(chapterId) 拿章节标题 + levelStart(levelId) 拿题;
/// 2. 进场 1s 后自动播英式发音;
/// 3. 提交答案(loading「正在提交」,失败重置选择允许重选)→ 标对错 →
/// contextMatch 非空自动翻转看语境;
/// 4. 非末题:4s 后切下一题(瞬时翻回 + splice + 自动发音);
/// 5. 末题:4s 后显示「加载中...」,轮询 GetLevelIsFinish(200ms/次,
/// 最多 30 次 ≈ 6s,替代 Vue 无限 while 死循环)→ 取小结跳转。
///
/// 音频:ReciteAudioManager 同时只播一个;dispose 时 stopAll(cleanupAllAudio)。
/// ============================================================
class LevelStartPage extends StatefulWidget {
const LevelStartPage({super.key, required this.args});
final LevelStartArgs args;
@override
State<LevelStartPage> createState() => _LevelStartPageState();
}
class _LevelStartPageState extends State<LevelStartPage>
with SingleTickerProviderStateMixin {
String _title = '';
final List<Question> _questions = [];
final List<Lexicon> _answered = [];
int _userLevelId = 0;
int _finishLexiconCount = 0;
int _lexiconCount = 0;
bool _loading = true;
String _selectAnswer = '';
String _correctAnswer = '';
/// 答题后到下一题的延迟(Vue intervalTime = 4000)
static const Duration _interval = Duration(milliseconds: 4000);
/// 轮询最多 30 次 ×200ms ≈ 6s
static const int _maxPollAttempts = 30;
static const Duration _pollInterval = Duration(milliseconds: 200);
Timer? _transitionTimer;
bool _submitting = false;
late final AnimationController _flipCtl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
bool get _isPad =>
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
Question? get _current => _questions.isEmpty ? null : _questions.first;
bool get _canFlip {
final q = _current;
return q != null && q.contextMatch.isNotEmpty;
}
bool get _hasResult => _correctAnswer.isNotEmpty;
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
try {
final res = await LexiconApi.instance.getLevels(widget.args.chapterId);
final info = await LexiconApi.instance.levelStart(widget.args.levelId);
if (!mounted) return;
setState(() {
if (res.title.isNotEmpty) _title = res.title;
_questions.addAll(info.questions);
_answered.addAll(info.lexicons);
_finishLexiconCount = info.finishLexiconCount;
_lexiconCount = info.lexiconCount;
_userLevelId = info.userLevelId;
_loading = false;
});
} catch (_) {
if (mounted) {
setState(() => _loading = false);
CenterToast.show(context, '页面加载失败,请重试');
}
return;
}
// Vue:挂载 1s 后自动播英式发音
_transitionTimer = Timer(const Duration(seconds: 1), () {
if (mounted) _playVoice(isAmerican: false);
});
}
// ---------------- 发音 ----------------
void _playVoice({required bool isAmerican}) {
final q = _current;
if (q == null) return;
// 对标 Vue:hasActiveAudio 时直接忽略
if (ReciteAudioManager.instance.hasActive) return;
ReciteAudioManager.instance.play(
isAmerican ? q.americanVoiceUrl : q.voiceUrl,
);
}
// ---------------- 翻转 ----------------
void _toggleFlip() {
if (_flipCtl.isAnimating) return;
if (_flipCtl.value > 0.5) {
_flipCtl.reverse();
} else {
_flipCtl.forward();
}
}
// ---------------- 答题 ----------------
Future<void> _onSelectAnswer(String answer) async {
if (_selectAnswer.isNotEmpty || _submitting) return; // 防重复提交
final q = _current;
if (q == null) return;
setState(() => _selectAnswer = answer);
ReciteLoading.show(context, '正在提交');
_submitting = true;
bool ok = false;
try {
ok = await LexiconApi.instance.answerQuestion(
levelId: widget.args.levelId,
userLevelId: _userLevelId,
lexiconId: q.id,
answer: answer,
);
} catch (_) {
// Vue catch:重置 selectAnswer,允许重新作答
if (mounted) setState(() => _selectAnswer = '');
} finally {
_submitting = false;
if (mounted) ReciteLoading.hide(context);
}
if (!ok || !mounted) return;
setState(() {
_correctAnswer = q.chineseDesc;
_answered.add(
Lexicon(
id: q.id,
english: q.english,
phonetic: q.phonetic,
voiceUrl: q.voiceUrl,
chineseDesc: q.chineseDesc,
isCorrect: answer == q.chineseDesc,
),
);
_finishLexiconCount++;
});
// 答对后自动翻到语境面(Vue:未翻转且可翻 → 翻)
if (_canFlip && _flipCtl.value < 0.5) {
_flipCtl.forward();
}
if (_questions.length <= 1) {
_scheduleFinish();
} else {
_scheduleNextQuestion();
}
}
/// 非末题:4s 后清状态 → 瞬时翻回正面 → 换下一题 → 自动发音
void _scheduleNextQuestion() {
_transitionTimer?.cancel();
_transitionTimer = Timer(_interval, () {
if (!mounted) return;
setState(() {
_correctAnswer = '';
_selectAnswer = '';
if (_finishLexiconCount < _lexiconCount) {
_questions.removeAt(0);
}
});
_flipCtl.value = 0; // 无动画复位(Vue isAnimation=false 那一套)
_playVoice(isAmerican: false);
});
}
/// 末题:4s 后轮询完关 → 取小结 → redirect 小结页
void _scheduleFinish() {
_transitionTimer?.cancel();
_transitionTimer = Timer(_interval, () async {
if (!mounted) return;
ReciteLoading.show(context, '加载中...');
final finished = await _pollingIsFinish();
if (!mounted) return;
if (!finished) {
ReciteLoading.hide(context);
CenterToast.show(context, '闯关结果获取失败,请重试');
return;
}
try {
final over = await LexiconApi.instance.getLevelOverInfo(_userLevelId);
if (!mounted) return;
ReciteLoading.hide(context);
// 对标 Vue uni.redirectTo(替换当前栈)
context.pushReplacement(
'/recite/level-over',
extra: LevelOverArgs(info: over, chapterId: widget.args.chapterId),
);
} catch (_) {
if (mounted) {
ReciteLoading.hide(context);
CenterToast.show(context, '获取闯关结束信息失败');
}
}
});
}
/// 轮询关卡是否完成(加超时保护:最多 30 次 ≈ 6s)
Future<bool> _pollingIsFinish() async {
for (var i = 0; i < _maxPollAttempts; i++) {
try {
if (await LexiconApi.instance.getLevelIsFinish(widget.args.levelId)) {
return true;
}
} catch (_) {
// 单次失败继续轮询(对标 Vue finally sleep 后继续)
}
await Future<void>.delayed(_pollInterval);
}
return false;
}
@override
void dispose() {
_transitionTimer?.cancel();
_flipCtl.dispose();
ReciteAudioManager.instance.stopAll(); // 对标 cleanupAllAudio
super.dispose();
}
// ================= UI =================
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
elevation: 0,
iconTheme: const IconThemeData(color: Color(0xFF333333)),
),
body: Stack(
children: [
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: _loading
? const Center(
child: CircularProgressIndicator(color: Color(0xFF1BC26E)),
)
: Column(
children: [
Padding(
padding: EdgeInsets.only(top: 4.h),
child: Text(
_title,
style: TextStyle(
fontSize: 17.sp,
color: Colors.black,
),
),
),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(
_isPad ? 40.w : 20.w,
10.h,
_isPad ? 40.w : 20.w,
20.h,
),
child: _current == null
? SizedBox(
height: 300.h,
child: Center(
child: Text(
'暂无题目',
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF999999),
),
),
),
)
: Column(
children: [
_AnswerProgress(
finishLexiconCount: _finishLexiconCount,
lexiconCount: _lexiconCount,
isPad: _isPad,
),
SizedBox(height: 23.h),
_QuestionCard(
question: _current!,
selectAnswer: _selectAnswer,
correctAnswer: _correctAnswer,
hasResult: _hasResult,
flipCtl: _flipCtl,
canFlip: _canFlip,
isPad: _isPad,
onToggleFlip: _toggleFlip,
onPlayVoice: (v) =>
_playVoice(isAmerican: v),
onSelectAnswer: _onSelectAnswer,
),
],
),
),
),
],
),
),
],
),
);
}
}
/// ============== 顶部答题进度(轨道 + 小鳄鱼 + 移动小猴子) ==============
class _AnswerProgress extends StatelessWidget {
const _AnswerProgress({
required this.finishLexiconCount,
required this.lexiconCount,
required this.isPad,
});
final int finishLexiconCount;
final int lexiconCount;
final bool isPad;
@override
Widget build(BuildContext context) {
final progress = lexiconCount > 0
? (finishLexiconCount / lexiconCount).clamp(0.0, 1.0)
: 0.0;
final trackHeight = isPad ? 20.h : 16.h;
final monkeyWidth = isPad ? 31.5.w : 26.5.w;
return Padding(
padding: EdgeInsets.symmetric(horizontal: 11.w),
child: LayoutBuilder(
builder: (context, constraints) {
final trackWidth = constraints.maxWidth;
final monkeyLeft = trackWidth * progress - monkeyWidth / 2;
return SizedBox(
height: trackHeight + 24.h,
child: Stack(
clipBehavior: Clip.none,
children: [
// 进度 > 10% 时左侧出现小鳄鱼
if (progress > 0.1)
Positioned(
left: -7.5.w,
top: -5.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-06-03/crocodile1780457600126.png',
),
width: isPad ? 57.5.w : 49.w,
fit: BoxFit.fitWidth,
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
// 轨道
Positioned(
left: 0,
right: 0,
top: 8.h,
child: SizedBox(
height: trackHeight,
child: ClipRRect(
borderRadius: BorderRadius.circular(trackHeight / 2),
child: Stack(
children: [
Container(color: const Color(0xFFF5F5F5)),
FractionallySizedBox(
widthFactor: progress,
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [
Color(0xFF1BC26E),
Color(0xFF7ED8AA),
],
),
),
),
),
],
),
),
),
),
// 随进度移动的小猴子
Positioned(
left: monkeyLeft,
top: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-02/img_answer_progress.png',
),
width: monkeyWidth,
fit: BoxFit.fitWidth,
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
// 右侧计数
Positioned(
right: 0,
top: trackHeight + 2.h,
child: Row(
children: [
Text(
'$finishLexiconCount',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF1BC26E),
),
),
Text(
'/$lexiconCount',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF999999),
),
),
],
),
),
],
),
);
},
),
);
}
}
/// 题目卡(单词 + 音标 + 翻转卡 + 选项)
class _QuestionCard extends StatelessWidget {
const _QuestionCard({
required this.question,
required this.selectAnswer,
required this.correctAnswer,
required this.hasResult,
required this.flipCtl,
required this.canFlip,
required this.isPad,
required this.onToggleFlip,
required this.onPlayVoice,
required this.onSelectAnswer,
});
final Question question;
final String selectAnswer;
final String correctAnswer;
final bool hasResult;
final AnimationController flipCtl;
final bool canFlip;
final bool isPad;
final VoidCallback onToggleFlip;
final void Function(bool isAmerican) onPlayVoice;
final ValueChanged<String> onSelectAnswer;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(vertical: 30.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
children: [
// 英/美 单词与音标
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.start,
spacing: 12.w,
runSpacing: 12.h,
children: [
_WordColumn(
word: question.english,
phonetic: question.phonetic,
voiceUrl: question.voiceUrl,
tag: '英',
onPlay: () => onPlayVoice(false),
),
if (question.americanPhonetic.isNotEmpty)
_WordColumn(
word: question.americanEnglish,
phonetic: question.americanPhonetic,
voiceUrl: question.americanVoiceUrl,
tag: '美',
onPlay: () => onPlayVoice(true),
),
],
),
),
SizedBox(height: 30.h),
// 翻转卡片(540x360rpx;pad 1080x720rpx)
_FlipCard(
question: question,
flipCtl: flipCtl,
canFlip: canFlip,
isPad: isPad,
onToggleFlip: onToggleFlip,
),
SizedBox(height: 20.h),
// 选项
Padding(
padding: EdgeInsets.symmetric(horizontal: 32.5.w),
child: Column(
children: [
for (var i = 0; i < question.options.length; i++) ...[
if (i > 0) SizedBox(height: 12.h),
_OptionItem(
text: question.options[i],
status: _optionStatus(question.options[i]),
onTap: () => onSelectAnswer(question.options[i]),
),
],
],
),
),
],
),
);
}
/// 0 默认 / 1 正确(绿)/ 2 错选(红)
int _optionStatus(String option) {
if (!hasResult) return 0;
if (option == correctAnswer) return 1;
if (option == selectAnswer) return 2;
return 0;
}
}
/// 单词 + 音标胶囊(带 4 帧发音按钮)
class _WordColumn extends StatelessWidget {
const _WordColumn({
required this.word,
required this.phonetic,
required this.voiceUrl,
required this.tag,
required this.onPlay,
});
final String word;
final String phonetic;
final String voiceUrl;
final String tag;
final VoidCallback onPlay;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
word,
style: TextStyle(
fontSize: 28.sp,
color: const Color(0xFF333333),
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 16.h),
Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.h),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(999.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (phonetic.isNotEmpty) ...[
Text(
tag,
style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF666666),
),
),
SizedBox(width: 4.w),
Text(
phonetic,
style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF999999),
),
),
SizedBox(width: 6.w),
],
VoiceAnimButton(url: voiceUrl, size: 27),
],
),
),
],
);
}
}
/// 翻转卡片(正面配图 / 背面语境搭配,0.6s Y 轴翻转)
class _FlipCard extends StatelessWidget {
const _FlipCard({
required this.question,
required this.flipCtl,
required this.canFlip,
required this.isPad,
required this.onToggleFlip,
});
final Question question;
final AnimationController flipCtl;
final bool canFlip;
final bool isPad;
final VoidCallback onToggleFlip;
@override
Widget build(BuildContext context) {
final width = isPad ? 540.w : 270.w;
final height = isPad ? 360.h : 180.h;
return AnimatedBuilder(
animation: flipCtl,
builder: (context, _) {
final angle = flipCtl.value * 3.1415926;
final showBack = angle > 3.1415926 / 2;
return SizedBox(
width: width,
height: height,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(angle),
child: showBack
? Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..rotateY(3.1415926),
child: _backSide(width, height),
)
: _frontSide(width, height),
),
);
},
);
}
/// 「翻转」小胶囊(正反两面右上角都有)
Widget _flipChip() {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onToggleFlip,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 2.h),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(999.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'翻转',
style: TextStyle(fontSize: 11.sp, color: const Color(0xFF999999)),
),
SizedBox(width: 4.w),
CachedNetworkImage(
imageUrl: ImgUrl.build('/StaticResource/2026-04-02/flip.png'),
width: 12.w,
height: 12.w,
errorWidget: (_, _, _) =>
Icon(Icons.flip, size: 12.w, color: const Color(0xFF999999)),
),
],
),
),
);
}
Widget _frontSide(double width, double height) {
return ClipRRect(
borderRadius: BorderRadius.circular(10.r),
child: Stack(
children: [
Positioned.fill(
child: CachedNetworkImage(
imageUrl: ImgUrl.build(question.imageDesc),
fit: BoxFit.cover,
placeholder: (_, _) => Container(color: const Color(0xFFF8F8F8)),
errorWidget: (_, _, _) =>
Container(color: const Color(0xFFF8F8F8)),
),
),
// 仅 contextMatch 非空正面才显示翻转入口
if (canFlip) Positioned(top: 14.h, right: 18.w, child: _flipChip()),
],
),
);
}
Widget _backSide(double width, double height) {
return Container(
padding: EdgeInsets.fromLTRB(12.w, 16.h, 12.w, 12.h),
decoration: BoxDecoration(
color: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(10.r),
),
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Text(
'语境搭配',
style: TextStyle(
fontSize: 16.sp,
color: const Color(0xFF333333),
),
),
],
),
SizedBox(height: 6.h),
Container(
width: 62.w,
height: 4.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2.r),
gradient: const LinearGradient(
colors: [Color(0x661BC26E), Color(0x667ED8AA)],
),
),
),
SizedBox(height: 14.h),
Expanded(
child: SingleChildScrollView(
child: _HighlightText(
source: question.contextMatch,
keyword: question.english,
),
),
),
],
),
Positioned(top: 0, right: 0, child: _flipChip()),
],
),
);
}
}
/// 语境搭配高亮(对标 r-highlight:单词本身绿色)
class _HighlightText extends StatelessWidget {
const _HighlightText({required this.source, required this.keyword});
final String source;
final String keyword;
@override
Widget build(BuildContext context) {
if (keyword.isEmpty) {
return Text(source, style: _baseStyle);
}
final spans = <TextSpan>[];
final pattern = RegExp(RegExp.escape(keyword), caseSensitive: false);
var lastEnd = 0;
for (final m in pattern.allMatches(source)) {
if (m.start > lastEnd) {
spans.add(TextSpan(text: source.substring(lastEnd, m.start)));
}
spans.add(
TextSpan(
text: source.substring(m.start, m.end),
style: const TextStyle(
color: Color(0xFF1BC26E),
fontWeight: FontWeight.w600,
),
),
);
lastEnd = m.end;
}
if (lastEnd < source.length) {
spans.add(TextSpan(text: source.substring(lastEnd)));
}
return Text.rich(
TextSpan(children: spans, style: _baseStyle),
style: const TextStyle(height: 1.5),
);
}
TextStyle get _baseStyle =>
TextStyle(fontSize: 14.sp, color: const Color(0xFF333333), height: 1.5);
}
/// 选项胶囊(默认灰 / 正确绿 / 错选红 + 角标)
class _OptionItem extends StatelessWidget {
const _OptionItem({
required this.text,
required this.status,
required this.onTap,
});
final String text;
/// 0 默认 / 1 正确 / 2 错选
final int status;
final VoidCallback onTap;
static const Color _green = Color(0xFF1BC26E);
static const Color _red = Color(0xFFF56245);
@override
Widget build(BuildContext context) {
final bg = switch (status) {
1 => _green,
2 => _red,
_ => const Color(0xFFF5F5F5),
};
final fg = status == 0 ? const Color(0xFF333333) : Colors.white;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 30.w, vertical: 14.h),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(999.r),
),
child: Row(
children: [
Expanded(
child: Text(
text,
style: TextStyle(fontSize: 15.sp, color: fg, height: 1.3),
),
),
if (status != 0)
CachedNetworkImage(
imageUrl: ImgUrl.build(
status == 1
? '/StaticResource/2026-04-02/checkSmall.png'
: '/StaticResource/2026-04-02/closeSmall.png',
),
width: 16.w,
height: 16.w,
errorWidget: (_, _, _) => Icon(
status == 1 ? Icons.check_circle : Icons.cancel,
size: 16.w,
color: Colors.white,
),
),
],
),
),
);
}
}
+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),
),
),
);
}
}
+26
View File
@@ -0,0 +1,26 @@
/// ============================================================
/// 背诵模块路由参数(GoRouter extra 传参用)
///
/// 用对象参数替代 Vue 的两套机制:
/// 1. eventChannel.emit('getChapter', chapter) → Chapter 直接 extra
/// 2. uni.setStorageSync('curChapterInfo', ...) → LevelStartArgs 显式传参
/// ============================================================
library;
import 'package:new_learn/core/api/lexicon/model.dart';
/// 答题页入参:chapterId(取章节标题/下一章)+ levelId(开局取题)
class LevelStartArgs {
const LevelStartArgs({required this.chapterId, required this.levelId});
final int chapterId;
final int levelId;
}
/// 学习小结入参:小结数据 + 当前章节 id(取章节标题 / 下一关跳转)
class LevelOverArgs {
const LevelOverArgs({required this.info, required this.chapterId});
final LevelOverInfo info;
final int chapterId;
}
+391 -35
View File
@@ -1,61 +1,417 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/lexicon_book/api.dart';
import 'package:new_learn/core/api/lexicon_book/model.dart';
import 'package:new_learn/core/components/center_toast.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/recite/widgets/recite_progress.dart';
/// ============================================================ /// ============================================================
/// 【RecitePage】「背诵」tab 页 —— 空页面占位 /// 【RecitePage】「背诵」tab 首页 —— 对标 Vue pages/tabbar/recite/recite.vue
/// ///
/// 后续开发计划:这里放背单词的卡片流(正面/背面翻转、记忆曲线等)。 /// 一、数据(Vue onShow,Flutter 在 initState + push 返回后刷新):
/// 现在先占位,保证 4 个 tab 都能正常切换。 /// 1. getCurrentLexiconBooks → 当前词书(可能为 null,卡片整体隐藏)
/// 2. getLearnDayCount → 坚持学习天数
/// 两个接口均静默失败(Vue hideErrorToast),catch 后保留旧值/默认值。
///
/// 二、跳转:
/// [切换教材] → /recite/textbooks,返回后重新拉数据(书可能已切换)
/// [开始学习] → /recite/chapters/:bookId;无当前书时拦截(Vue 端此处
/// 会读 null.id 崩溃,Flutter 迁移时补判空容错)
///
/// 三、与 Vue 差异说明:
/// Vue 自定义 Tabbar 需手动预留 paddingBottom;Flutter 端底部导航由
/// StatefulShellRoute 壳提供,body 天然在 tabbar 上方,无需该逻辑。
/// ============================================================ /// ============================================================
class RecitePage extends StatelessWidget { class RecitePage extends StatefulWidget {
const RecitePage({super.key}); const RecitePage({super.key});
@override @override
Widget build(BuildContext context) { State<RecitePage> createState() => _RecitePageState();
return const _Placeholder(
icon: Icons.auto_stories,
title: '背诵',
subtitle: '功能开发中,敬请期待',
);
}
} }
/// 通用占位组件:一个居中图标 + 标题 + 副标题 class _RecitePageState extends State<RecitePage> {
class _Placeholder extends StatelessWidget { BookInfo? _currBook;
const _Placeholder({ int _learnDayCount = 0;
required this.icon,
required this.title, @override
required this.subtitle, void initState() {
super.initState();
_load();
}
Future<void> _load() async {
// 两个接口互不依赖,并发拉取;均静默失败
final results = await Future.wait<dynamic>([
LexiconBookApi.instance.getCurrentBook().catchError((_) => null),
LexiconBookApi.instance.getLearnDayCount().catchError((_) => 0),
]);
if (!mounted) return;
setState(() {
_currBook = results[0] as BookInfo?;
_learnDayCount = results[1] as int? ?? 0;
}); });
}
final IconData icon; /// 切换教材:返回后刷新(对标 Vue onShow)
final String title; Future<void> _changeBook() async {
final String subtitle; await context.push('/recite/textbooks');
if (mounted) _load();
}
/// 开始学习:进入章节列表
void _startLearn() {
final book = _currBook;
if (book == null) {
// 容错:Vue 端 currBook 为 null 时点按钮会 null.id 崩溃
CenterToast.show(context, '请先选择教材');
return;
}
context.push('/recite/chapters/${book.id}');
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: Center( backgroundColor: const Color(0xFFF0F8FF),
child: Column( body: Stack(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, size: 64.w, color: const Color(0xFFC9CFD8)), // HeaderBac:顶部主题背景图(fit-width 顶部对齐)
SizedBox(height: 12.h), Positioned(
Text( top: 0,
title, left: 0,
style: TextStyle( right: 0,
fontSize: 18.sp, child: CachedNetworkImage(
fontWeight: FontWeight.bold, imageUrl: ImgUrl.build(
color: const Color(0xFF666666), '/StaticResource/2026-05-26/my-header-bg-default1779775984375.png',
),
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
// 右上角装饰图(mp: top 164rpx / right 24rpx / width 341rpx)
Positioned(
top: 82.h,
right: 12.w,
child: SafeArea(
bottom: false,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-09/recite-top-img.png',
),
width: 170.w,
fit: BoxFit.fitWidth,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
),
// 主体内容
SafeArea(
bottom: false,
child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(25.w, 88.h, 25.w, 24.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLearnDay(),
if (_currBook != null) ...[
SizedBox(height: 23.h),
_CurrBookCard(book: _currBook!, onChangeBook: _changeBook),
],
SizedBox(height: 24.h),
_StartButton(onTap: _startLearn),
],
), ),
), ),
SizedBox(height: 6.h),
Text(
subtitle,
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)),
), ),
], ],
), ),
);
}
/// 坚持学习天数
Widget _buildLearnDay() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$_learnDayCount',
style: TextStyle(
fontSize: 50.sp,
height: 1,
color: const Color(0xFF7DAAFF),
),
),
Padding(
padding: EdgeInsets.only(left: 10.w, bottom: 4.h),
child: Text(
'天',
style: TextStyle(
fontSize: 20.sp,
color: const Color(0xFF66A6FF),
),
),
),
],
),
SizedBox(height: 4.h),
Padding(
padding: EdgeInsets.only(left: 2.w),
child: Text(
'坚持学习天数',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF495A7A)),
),
),
],
);
}
}
/// 当前词书卡片(海报 + slogan + 切换按钮 + 封面 + 进度)
class _CurrBookCard extends StatelessWidget {
const _CurrBookCard({required this.book, required this.onChangeBook});
final BookInfo book;
final VoidCallback onChangeBook;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.r),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
// 海报 + slogan 遮罩 + 切换按钮
Stack(
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(book.poster),
width: double.infinity,
fit: BoxFit.fitWidth,
placeholder: (_, _) => Container(height: 120.h),
errorWidget: (_, _, _) => Container(height: 120.h),
),
Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.4),
),
),
Padding(
padding: EdgeInsets.only(
top: 127.h,
left: 22.w,
right: 6.w,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'“',
style: TextStyle(
fontSize: 32.sp,
color: Colors.white,
),
),
SizedBox(width: 4.w),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: 15.h),
child: Text(
book.slogan,
softWrap: true,
style: TextStyle(
fontSize: 15.sp,
color: Colors.white,
height: 1.4,
),
),
),
),
],
),
),
// 切换教材角标按钮
Positioned(
right: 0,
bottom: 0,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChangeBook,
child: Container(
width: 95.w,
height: 30.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(15.r),
),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF8AD1FD),
Color(0xFF3C84FF),
Color(0xFF3D7DF5),
],
stops: [0, 0.64, 1],
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'切换教材',
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
),
),
Icon(
Icons.chevron_right,
size: 14.w,
color: Colors.white,
),
],
),
),
),
),
],
),
// 书籍信息(左侧留出封面位置:277rpx)
Padding(
padding: EdgeInsets.fromLTRB(138.5.w, 20.h, 13.5.w, 37.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.sp,
color: const Color(0xFF333333),
),
),
SizedBox(height: 16.5.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
'进度',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
SizedBox(width: 5.w),
Text(
'${book.progressPercent}%',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF3D7DF5),
),
),
],
),
Row(
children: [
Text(
'${book.lexiconCount}词',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF3D7DF5),
),
),
Text(
' / ${book.totalCount}词',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
],
),
],
),
SizedBox(height: 8.5.h),
GradientProgressBar(
percent: book.progressPercent.toDouble(),
),
],
),
),
],
),
// 左下封面(mp: width 230rpx / bottom 50rpx / left 24rpx)
Positioned(
left: 12.w,
bottom: 25.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(book.cover),
width: 115.w,
fit: BoxFit.fitWidth,
placeholder: (_, _) => const SizedBox.shrink(),
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
],
),
);
}
}
/// 「开始学习」大按钮(mp: height 96rpx / 圆角 999)
class _StartButton extends StatelessWidget {
const _StartButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: double.infinity,
height: 48.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.r),
gradient: const LinearGradient(
colors: [Color(0xFF5A9EFF), Color(0xFF3C7CF6)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF3D7DF5).withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
alignment: Alignment.center,
child: Text(
'开始学习',
style: TextStyle(
fontSize: 17.sp,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
), ),
); );
} }
+510
View File
@@ -0,0 +1,510 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/lexicon_book/api.dart';
import 'package:new_learn/core/api/lexicon_book/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/widgets/recite_loading.dart';
import 'package:new_learn/features/recite/widgets/recite_progress.dart';
/// ============================================================
/// 【TextbookListPage】教材列表 —— 对标 Vue pages/recitePackage/textbookList
///
/// 一、数据:
/// 1. getCurrentLexiconBooks 顶部「当前学习」卡(静默失败)
/// 2. getAllLexiconBooks 我的书库列表(失败 toast「页面加载失败,请重试」)
///
/// 二、切换教材 handleStudy(容错逻辑保留):
/// showLoading「切换中...」(mask) → POST startLearn → 成功刷新两列表
/// → finally hideLoading。
///
/// 三、NavBar:随滚动 0→25px 从透明渐变到纯白(对标 updateNavBarBgColor)。
/// ============================================================
class TextbookListPage extends StatefulWidget {
const TextbookListPage({super.key});
@override
State<TextbookListPage> createState() => _TextbookListPageState();
}
class _TextbookListPageState extends State<TextbookListPage> {
BookInfo? _currBook;
List<BookInfo> _bookList = const [];
/// AppBar 白色不透明度 0~1
double _navBarOpacity = 0;
bool get _isPad =>
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
@override
void initState() {
super.initState();
_getCurrBook();
_getList();
}
Future<void> _getCurrBook() async {
try {
final res = await LexiconBookApi.instance.getCurrentBook();
if (mounted) setState(() => _currBook = res);
} catch (_) {
// Vue:静默 catch
}
}
Future<void> _getList() async {
try {
final res = await LexiconBookApi.instance.getAllBooks();
if (mounted) setState(() => _bookList = res);
} catch (_) {
if (mounted) {
CenterToast.show(
context,
'页面加载失败,请重试',
duration: const Duration(milliseconds: 2000),
);
}
}
}
/// 切换教材(对标 handleStudy)
Future<void> _handleStudy(BookInfo item) async {
ReciteLoading.show(context, '切换中...');
try {
final ok = await LexiconBookApi.instance.startLearn(item.id);
if (ok) {
await Future.wait([_getList(), _getCurrBook()]);
}
} catch (_) {
// DioClient 已统一记录错误;此处保持 loading 必定关闭即可
} finally {
if (mounted) ReciteLoading.hide(context);
}
}
/// 滚动联动 NavBar 底色(0~25px 线性渐变)
void _onScroll(double offset) {
final opacity = offset <= 0
? 0.0
: offset >= 25
? 1.0
: (offset / 25).clamp(0, 1).toDouble();
if (opacity != _navBarOpacity && mounted) {
setState(() => _navBarOpacity = opacity);
}
}
@override
Widget build(BuildContext context) {
final hPadding = _isPad ? 40.w : 12.5.w;
return Scaffold(
backgroundColor: const Color(0xFFF5F8FA),
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(0xFF1D2B47),
fontWeight: FontWeight.w600,
),
),
iconTheme: const IconThemeData(color: Color(0xFF1D2B47)),
),
body: NotificationListener<ScrollNotification>(
onNotification: (n) {
_onScroll(n.metrics.pixels);
return false;
},
child: SingleChildScrollView(
child: SafeArea(
bottom: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部背景图卡片
// —— 与 Vue bg-img 对齐:背景图 width:100%, height:auto, position:top
// "当前学习" + "助力英语..." 同一行两端对齐,书卡叠在图上
Container(
width: double.infinity,
margin: EdgeInsets.fromLTRB(hPadding, 20.h, hPadding, 0),
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build(
_isPad
? '/StaticResource/2026-06-03/textbookList-top-bg-pad1780469931684.png'
: '/StaticResource/2026-04-09/textbookList-top-bg.png',
),
),
alignment: Alignment.topCenter,
fit: BoxFit.fitWidth,
),
borderRadius: BorderRadius.circular(16.r),
),
child: Padding(
padding: EdgeInsets.fromLTRB(16.w, 17.h, 16.w, 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部标题行:左"当前学习" + 右"助力英语..."
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_SectionTitle(title: '当前学习'),
const Spacer(),
Padding(
padding: EdgeInsets.only(bottom: 6.h),
child: Text(
'助力英语全方位提升!',
style: TextStyle(
fontSize: 13.sp,
color: const Color(0xFF687FAD),
),
),
),
],
),
if (_currBook != null) ...[
SizedBox(height: 20.h),
_CurrentBook(book: _currBook!),
],
],
),
),
),
// 我的书库
Padding(
padding: EdgeInsets.fromLTRB(hPadding, 12.h, hPadding, 24.h),
child: Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(16.w, 19.h, 16.w, 0.5.h),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(title: '我的书库'),
SizedBox(height: 19.h),
..._bookList.map(
(book) =>
_BookItem(book: book, onStudy: _handleStudy),
),
],
),
),
),
],
),
),
),
),
);
}
}
/// 标题 + 底部渐变短线(study-title / library-title)
///
/// 用 IntrinsicWidth 包裹:组件宽度 shrink-wrap 到标题文字宽度,
/// 渐变线 width:infinity 即等于文字宽度——1:1 对应 Vue 里
/// .text-line(absolute, width:100%)+ relative 父容器(shrink-wrap)。
/// 同时保证本组件放进 Row(无界水平约束)时不会因 infinity 宽度布局崩溃。
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title});
final String title;
@override
Widget build(BuildContext context) {
return IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
SizedBox(height: 3.h),
Container(
height: 4.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999.r),
gradient: const LinearGradient(
colors: [
Color(0x003D7DF5),
Color(0x331D9CFF),
Color(0x808AECFD),
],
),
),
),
],
),
);
}
}
/// 顶部当前学习书卡
class _CurrentBook extends StatelessWidget {
const _CurrentBook({required this.book});
final BookInfo book;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(book.cover),
width: 95.w,
fit: BoxFit.fitWidth,
placeholder: (_, _) => SizedBox(width: 95.w, height: 112.h),
errorWidget: (_, _, _) => SizedBox(width: 95.w, height: 112.h),
),
SizedBox(width: 17.5.w),
Expanded(
child: Padding(
padding: EdgeInsets.only(top: 2.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18.sp,
color: const Color(0xFF333333),
),
),
SizedBox(height: 29.h - 2.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
'进度',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
Text(
'${book.progressPercent}%',
style: TextStyle(
fontSize: 12.sp,
color: const Color(0xFF3D7DF5),
),
),
],
),
Row(
children: [
Text(
'${book.lexiconCount}词',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF3D7DF5),
),
),
Text(
' / ${book.totalCount}词',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
],
),
],
),
SizedBox(height: 8.5.h),
GradientProgressBar(percent: book.progressPercent.toDouble()),
],
),
),
),
],
);
}
}
/// 书库列表项(封面浮在左侧渐变内容卡上 + 状态角标 + 学习按钮)
class _BookItem extends StatelessWidget {
const _BookItem({required this.book, required this.onStudy});
final BookInfo book;
final ValueChanged<BookInfo> onStudy;
@override
Widget build(BuildContext context) {
final completed =
book.lexiconCount >= book.totalCount && book.totalCount > 0;
final notStarted = book.lexiconCount == 0;
final buttonText = notStarted
? '学习此书'
: completed
? '已学完'
: '继续学习';
return Container(
height: 100.h,
margin: EdgeInsets.only(bottom: 25.h),
child: Stack(
children: [
// 渐变内容底卡(左留 97 给封面)
Positioned.fill(
child: Container(
padding: EdgeInsets.fromLTRB(97.w, 13.h, 10.w, 10.h),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFFEDF7FF), Colors.white],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
SizedBox(height: 13.5.h),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'进度${book.lexiconCount}词/${book.totalCount}词',
style: TextStyle(
fontSize: 10.sp,
color: const Color(0xFF999999),
),
),
SizedBox(height: 8.h),
GradientProgressBar(
percent: book.progressPercent.toDouble(),
),
],
),
),
SizedBox(width: 30.w),
_StudyButton(
text: buttonText,
disabled: completed,
onTap: () => onStudy(book),
),
],
),
],
),
),
),
// 左侧浮起封面(170rpx x 200rpx)
Positioned(
left: 0,
top: 0,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(book.cover),
width: 85.w,
height: 100.h,
fit: BoxFit.fill,
placeholder: (_, _) => SizedBox(width: 85.w, height: 100.h),
errorWidget: (_, _, _) => SizedBox(width: 85.w, height: 100.h),
),
),
// 右上角状态标签
if (book.isStudying || completed)
Positioned(
top: 0,
right: 0,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 8.5.w, vertical: 2.h),
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topRight: Radius.circular(5.r),
bottomLeft: Radius.circular(5.r),
),
gradient: LinearGradient(
colors: book.isStudying
? const [Color(0xFF7ED8AA), Color(0xFF1BC26E)]
: const [Color(0xFFF5963D), Color(0xFFFFD769)],
),
),
child: Text(
book.isStudying ? '学习中' : '已学完',
style: TextStyle(fontSize: 10.sp, color: Colors.white),
),
),
),
],
),
);
}
}
/// 「学习此书 / 继续学习 / 已学完」胶囊按钮
class _StudyButton extends StatelessWidget {
const _StudyButton({
required this.text,
required this.disabled,
required this.onTap,
});
final String text;
final bool disabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final color = disabled ? const Color(0xFFCCCCCC) : const Color(0xFF3D7DF5);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: disabled ? null : onTap,
child: Container(
width: 75.w,
height: 30.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(999.r),
),
child: Text(
text,
style: TextStyle(
fontSize: 12.sp,
color: Colors.white,
decoration: TextDecoration.none,
),
),
),
);
}
}
@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:just_audio/just_audio.dart';
import 'package:new_learn/core/logger/app_talker.dart';
/// ============================================================
/// 【ReciteAudioManager】背诵模块单例音频管理
/// —— 对标 Vue utils/audioManager
///
/// 规则(与 Vue 完全一致):
/// 1. 全局同时只播放一个音频:新 play 会先 stop 旧的;
/// 2. hasActive 为 true 时页面层直接忽略点击;
/// 3. 播放结束/失败/取消都清空 playingToken(驱动 4 帧动画停在第 1 帧);
/// 4. 离开答题/小结页调 stopAll() 释放(对标 cleanupAllAudio)。
/// ============================================================
class ReciteAudioManager {
ReciteAudioManager._() {
_playerSub = _player.playerStateStream.listen((state) {
if (state.processingState == ProcessingState.completed) {
playingToken.value = null;
currentUrl = null;
}
});
}
static final ReciteAudioManager instance = ReciteAudioManager._();
final AudioPlayer _player = AudioPlayer();
StreamSubscription<PlayerState>? _playerSub;
/// 当前播放令牌(自增),null = 无播放;4 帧按钮据此判断是否该动
final ValueNotifier<int?> playingToken = ValueNotifier<int?>(null);
/// 当前播放的音频 URL,null = 无播放
/// —— VoiceAnimButton 用它判断"当前播的是不是我的音频",
/// 比 token 归属更可靠(自动播放 / 手动播放 / 跨组件触发都能联动)。
String? currentUrl;
int _seq = 0;
bool get hasActive => playingToken.value != null;
/// 播放网络音频,返回本次播放令牌(失败返回 null)
Future<int?> play(String url) async {
if (url.isEmpty) return null;
final token = ++_seq;
try {
await _player.stop();
currentUrl = url;
playingToken.value = token;
await _player.setUrl(url);
await _player.play();
return token;
} catch (e) {
talker.error('背诵音频播放失败: $e');
if (playingToken.value == token) {
playingToken.value = null;
currentUrl = null;
}
return null;
}
}
/// 停止并清空播放态(页面 dispose 时必须调)
Future<void> stopAll() async {
await _player.stop();
playingToken.value = null;
currentUrl = null;
}
void dispose() {
_playerSub?.cancel();
_player.dispose();
}
}
@@ -0,0 +1,108 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【ReciteCircleProgress】圆环进度 —— 对标 Vue l-circle(canvas)
///
/// 用在:
/// 1. 章节页右上角「总进度」(顺时针,绿色 #1BC26E,底 #EBEBEB)
/// 2. 学习小结正确率(Vue :clockwise="false",逆时针绘制,视觉对称)
///
/// percent:0~100;中间内容由 child 自定义。
/// ============================================================
class ReciteCircleProgress extends StatelessWidget {
const ReciteCircleProgress({
super.key,
required this.percent,
required this.size,
this.strokeWidth = 4,
this.color = const Color(0xFF1BC26E),
this.trailColor = const Color(0xFFEBEBEB),
this.clockwise = true,
this.child,
});
final double percent;
final double size; // 直径
final double strokeWidth;
final Color color;
final Color trailColor;
/// false = 逆时针起笔(对标 l-circle :clockwise="false")
final bool clockwise;
final Widget? child;
@override
Widget build(BuildContext context) {
return SizedBox(
width: size.w,
height: size.w,
child: CustomPaint(
painter: _CirclePainter(
percent: percent.clamp(0, 100).toDouble(),
strokeWidth: strokeWidth.w,
color: color,
trailColor: trailColor,
clockwise: clockwise,
),
child: Center(child: child),
),
);
}
}
class _CirclePainter extends CustomPainter {
_CirclePainter({
required this.percent,
required this.strokeWidth,
required this.color,
required this.trailColor,
required this.clockwise,
});
final double percent;
final double strokeWidth;
final Color color;
final Color trailColor;
final bool clockwise;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
final trackPaint =
Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..color = trailColor;
final progressPaint =
Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..color = color;
canvas.drawCircle(center, radius, trackPaint);
const start = -math.pi / 2; // 12 点方向
final sweep = 2 * math.pi * percent / 100;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
clockwise ? start : start - sweep,
sweep,
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant _CirclePainter old) =>
old.percent != percent ||
old.strokeWidth != strokeWidth ||
old.color != color ||
old.clockwise != clockwise;
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【ReciteLoading】模态 loading —— 对标 uni.showLoading({mask:true})
///
/// 用于「切换中...」「正在提交」「加载中...」:
/// ReciteLoading.show(context, '切换中...');
/// ...await...
/// if (mounted) ReciteLoading.hide(context);
///
/// 用根 Navigator 弹出,可盖在底部 tabbar 之上;barrier 拦截所有点击。
/// ============================================================
class ReciteLoading {
ReciteLoading._();
static void show(BuildContext context, String text) {
showDialog<void>(
context: context,
barrierDismissible: false,
useRootNavigator: true,
builder: (_) => _ReciteLoadingView(text: text),
);
}
static void hide(BuildContext context) {
final nav = Navigator.of(context, rootNavigator: true);
if (nav.canPop()) nav.pop();
}
}
class _ReciteLoadingView extends StatelessWidget {
const _ReciteLoadingView({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 20.h),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(12.r),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
SizedBox(height: 12.h),
Text(
text,
style: TextStyle(
fontSize: 14.sp,
color: Colors.white,
decoration: TextDecoration.none,
),
),
],
),
),
);
}
}
@@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 背诵模块进度条组件
///
/// 1. GradientProgressBar —— 对标 Vue r-progress(linear-gradient 填充)
/// 词书卡蓝色渐变 / 关卡绿色渐变 / 未解锁灰色,全靠 colors 切换。
///
/// 2. SegmentedProgressBar —— 对标 Vue SegmentedProgressBar.vue
/// 三段独立进度,每段各自按 33/33/34 比例填充,绿色渐变。
/// ============================================================
/// 渐变线性进度条(percent:0~100)
class GradientProgressBar extends StatelessWidget {
const GradientProgressBar({
super.key,
required this.percent,
this.height = 6,
this.colors = const [Color(0xFF84AFFF), Color(0xFF3C7CF6)],
this.trackColor = const Color(0xFFF5F5F5),
this.radius,
});
/// 0~100(自动 clamp)
final double percent;
final double height;
/// 填充渐变(单色时传同色两个即可)
final List<Color> colors;
final Color trackColor;
final double? radius;
@override
Widget build(BuildContext context) {
final safe = percent.clamp(0, 100).toDouble();
return ClipRRect(
borderRadius: BorderRadius.circular(radius ?? (height / 2).r),
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return Container(
height: height.w,
color: trackColor,
child: Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: width * safe / 100,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: colors,
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
),
),
),
),
);
},
),
);
}
}
/// 三段式分段进度条(对标 SegmentedProgressBar.vue;progress:0~100)
class SegmentedProgressBar extends StatelessWidget {
const SegmentedProgressBar({super.key, required this.progress});
final double progress;
static const Color _green1 = Color(0xFF1BC26E);
static const Color _green2 = Color(0xFF7ED8AA);
/// 与 Vue updateBarProgress 完全一致的分段算法
List<double> _segmentPercents(double p) {
double bar1;
if (p >= 33) {
bar1 = 100;
} else {
bar1 = (p / 33 * 100).clamp(0, 100).toDouble();
}
double bar2;
if (p >= 66) {
bar2 = 100;
} else if (p > 33) {
bar2 = ((p - 33) / 33 * 100).clamp(0, 100).toDouble();
} else {
bar2 = 0;
}
double bar3;
if (p >= 100) {
bar3 = 100;
} else if (p > 66) {
bar3 = ((p - 66) / 34 * 100).clamp(0, 100).toDouble();
} else {
bar3 = 0;
}
return [bar1, bar2, bar3];
}
@override
Widget build(BuildContext context) {
final segs = _segmentPercents(progress);
return Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0) SizedBox(width: 5.w),
Expanded(
child: GradientProgressBar(
percent: segs[i],
height: 6,
colors: const [_green1, _green2],
),
),
],
],
);
}
}
@@ -0,0 +1,135 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/recite/widgets/recite_audio.dart';
/// ============================================================
/// 【VoiceAnimButton】发音 4 帧动画按钮
/// —— 1:1 对标 Vue components/FrameAnimation(:fps="5" :loop="true")
///
/// 帧图内容(绿色从无到有递进):
/// _frames[0] = icon_voice_2.png 全灰(0% 绿)
/// _frames[1] = icon_voice_3.png 仅小圆点绿(~15%)
/// _frames[2] = icon_voice_4.png 圆点+内弧绿(~55%)
/// _frames[3] = icon_voice_1.png 全绿(100%)—— 静止帧
///
/// 行为:
/// - 未播放时显示全绿静止帧(icon_voice_1.png);
/// - 只要 ReciteAudioManager 正在播的是当前按钮的 url,就播动画
/// (自动播放 / 手动点击 / 跨组件触发都能联动,不依赖谁调用了 play);
/// - 动画从全灰帧起步,5fps(200ms/帧)循环:
/// 灰 → 少绿 → 中绿 → 全绿 → 灰 → ...
/// - 点击时若无播放,则触发播放;已有播放则忽略;
/// - 播放结束 / 失败 / 被顶掉 → 回到全绿静止帧。
/// ============================================================
class VoiceAnimButton extends StatefulWidget {
const VoiceAnimButton({super.key, required this.url, this.size = 27});
/// 网络音频地址(空串时点击无效)
final String url;
/// 按钮边长(答题页 27px、小结页 18px)
final double size;
@override
State<VoiceAnimButton> createState() => _VoiceAnimButtonState();
}
class _VoiceAnimButtonState extends State<VoiceAnimButton> {
/// 4 帧序列,按"灰→少绿→中绿→全绿"递增排列
static final List<String> _frames = [
ImgUrl.build('/StaticResource/2026-04-02/icon_voice_2.png'), // 全灰
ImgUrl.build('/StaticResource/2026-04-02/icon_voice_3.png'), // 仅圆点绿
ImgUrl.build('/StaticResource/2026-04-02/icon_voice_4.png'), // 圆点+内弧绿
ImgUrl.build('/StaticResource/2026-04-02/icon_voice_1.png'), // 全绿
];
/// 静止帧索引(全绿 = icon_voice_1.png)
static const int _restIndex = 3;
/// 5fps = 200ms 切一帧
static const Duration _frameInterval = Duration(milliseconds: 200);
Timer? _timer;
int _frameIndex = _restIndex;
@override
void initState() {
super.initState();
// 全局播放 token 变化 → 同步本按钮动画启停
// (用 token 作为"播放状态变化"的信号,用 currentUrl 判断是不是我的音频)
ReciteAudioManager.instance.playingToken.addListener(_onPlayingChanged);
// 初始化时如果正好在播我的 url,直接启动画
if (_isMinePlaying) _startAnim();
}
/// 当前播的是不是我的音频
bool get _isMinePlaying {
final mgr = ReciteAudioManager.instance;
return mgr.playingToken.value != null &&
mgr.currentUrl == widget.url &&
widget.url.isNotEmpty;
}
void _onPlayingChanged() {
if (_isMinePlaying) {
_startAnim();
} else {
_stopAnim();
}
}
void _startAnim() {
if (_timer?.isActive ?? false) return;
_frameIndex = 0; // 全灰起步
if (mounted) setState(() {});
_timer = Timer.periodic(_frameInterval, (_) {
if (!mounted) return;
setState(() => _frameIndex = (_frameIndex + 1) % 4);
});
}
void _stopAnim() {
if (_timer == null) return;
_timer?.cancel();
_timer = null;
_frameIndex = _restIndex;
if (mounted) setState(() {});
}
Future<void> _onTap() async {
// 对标 Vue:if (audioManager.hasActiveAudio()) return;
if (ReciteAudioManager.instance.hasActive) return;
if (widget.url.isEmpty) return;
await ReciteAudioManager.instance.play(widget.url);
// 动画启停由 _onPlayingChanged 统一处理
}
@override
void dispose() {
_timer?.cancel();
ReciteAudioManager.instance.playingToken.removeListener(_onPlayingChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _onTap,
child: CachedNetworkImage(
imageUrl: _frames[_frameIndex],
width: widget.size.w,
height: widget.size.w,
fit: BoxFit.contain,
placeholder: (_, _) =>
SizedBox(width: widget.size.w, height: widget.size.w),
errorWidget: (_, _, _) =>
SizedBox(width: widget.size.w, height: widget.size.w),
),
);
}
}
@@ -0,0 +1,286 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/auth/api.dart';
import 'package:new_learn/core/api/report/api.dart';
import 'package:new_learn/core/api/report/model.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/providers/user_provider.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/report/widgets/computation_finished_popup.dart';
import 'package:new_learn/features/report/widgets/computation_progress_ring.dart';
import 'package:new_learn/features/report/widgets/computation_subject_item.dart';
/// ============================================================
/// 【ReportComputationPage】AI 加载动画页 —— 对标 Vue reportComputation.vue
///
/// 入门测 H5 壳子看完所有科目报告 → navigateTo reportComputation →
/// 圆环进度 + 每个科目依次"AI计算中"动画 → 500ms 后 finishedPopup 弹窗 →
/// 调 ChangeShowReport → refresh userProvider → switchTab 首页
///
/// Vue 端用 tPageImg/tPageColor 从 themes/default.json 抽主题;
/// Flutter 端未做主题抽离,本页资源路径/颜色集中在 _ComputationTheme。
/// ============================================================
/// 本页主题资源(对标 default.json → pages/reportPackage/reportComputation)
class _ComputationTheme {
// ---- images ----
static const String bg =
'/StaticResource/2026-06-11/bg-report21781144905849.png';
static const String bgPad =
'/StaticResource/2026-06-23/bg-report_pad1782196580176.png';
static const String bubble =
'/StaticResource/2026-06-11/Group33(2)1781142719442.png';
// ---- colors ----
static const Color pageBg = Color(0xFF275FD9); // background
}
String _img(String path) => ImgUrl.build(path);
class ReportComputationPage extends ConsumerStatefulWidget {
const ReportComputationPage({super.key});
@override
ConsumerState<ReportComputationPage> createState() =>
_ReportComputationPageState();
}
class _ReportComputationPageState extends ConsumerState<ReportComputationPage> {
List<_SubjectData> _subjectList = [];
double _ringProgress = 0;
bool _showFinishedPopup = false;
Timer? _animTimer;
/// 每个科目卡片的 key,用于动画切换时自动滚动到可见区域
/// (对标 Vue scroll-into-view / scrollToItemIfNeeded)
final List<GlobalKey> _itemKeys = [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_precacheImages();
_loadAndStart();
});
}
@override
void dispose() {
_animTimer?.cancel();
super.dispose();
}
/// 预加载弹窗/背景图,避免首次显示闪烁(对标 Vue onMounted getImageInfo)
void _precacheImages() {
final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
for (final path in [
isPad ? _ComputationTheme.bgPad : _ComputationTheme.bg,
ComputationFinishedPopup.topLightPadImg,
ComputationFinishedPopup.contentBgImg,
]) {
precacheImage(CachedNetworkImageProvider(_img(path)), context);
}
}
Future<void> _loadAndStart() async {
try {
final results = await Future.wait([
ReportApi.instance.getCurrentReport(),
ReportApi.instance.getCurrentReportKnow(),
]);
final report = results[0] as UserReportType;
final knowList = results[1] as List<UserReportKnowType>;
if (report.subjectReports.isEmpty) return;
final itemTime = (15 / report.subjectReports.length).floor();
final list = report.subjectReports.map((item) {
final knowItem = knowList.isNotEmpty
? knowList.firstWhere(
(k) => k.subjectName == item.subjectName,
orElse: () => const UserReportKnowType(),
)
: const UserReportKnowType();
return _SubjectData(
subjectReport: item,
knowCount: knowItem.knowCount,
time: itemTime,
);
}).toList();
if (!mounted) return;
setState(() {
_subjectList = list;
_itemKeys
..clear()
..addAll(List.generate(list.length, (_) => GlobalKey()));
});
_startSubjects(0);
} catch (e) {
talker.error('reportComputation 加载失败', e);
}
}
void _startSubjects(int index) {
if (index >= _subjectList.length) {
setState(() => _ringProgress = 100);
// Vue:最后一个科目完成后延迟 500ms 弹弹窗
Timer(const Duration(milliseconds: 500), () {
if (mounted) setState(() => _showFinishedPopup = true);
});
return;
}
setState(() {
_subjectList[index].status = ComputationSubjectStatus.inProgress;
_subjectList[index].progress = 0;
});
_scrollToItem(index);
final totalTimeMs = _subjectList[index].time * 1000;
final startTime = DateTime.now().millisecondsSinceEpoch;
_animTimer = Timer.periodic(const Duration(milliseconds: 30), (timer) {
final elapsed = DateTime.now().millisecondsSinceEpoch - startTime;
final progress = (elapsed / totalTimeMs * 100).clamp(0, 100).toInt();
setState(() {
_subjectList[index].progress = progress;
_ringProgress = (index * 100 + progress) / _subjectList.length;
});
if (elapsed >= totalTimeMs) {
timer.cancel();
_animTimer = null;
setState(() {
_subjectList[index].progress = 100;
_subjectList[index].status = ComputationSubjectStatus.finished;
});
_startSubjects(index + 1);
}
});
}
/// 当前科目卡片不完全可见时,滚动到可见区域
void _scrollToItem(int index) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final ctx = _itemKeys[index].currentContext;
if (ctx != null) {
Scrollable.ensureVisible(
ctx,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
alignment: 0.2,
);
}
});
}
/// finishedPopup 的"立即开启学习蝶变"按钮
Future<void> _onFinish() async {
try {
await UserApi.changeShowReport();
talker.debug('changeShowReport 成功');
await ref.read(currentUserProvider.notifier).refresh();
} catch (e) {
talker.error('changeShowReport 失败', e);
}
if (!mounted) return;
context.go('/');
}
@override
Widget build(BuildContext context) {
final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
final bottomPadding = MediaQuery.paddingOf(context).bottom;
return Scaffold(
backgroundColor: _ComputationTheme.pageBg,
body: Stack(
children: [
// ===== 背景图:宽铺满、居顶、高度自适应(Vue mode=widthFix)=====
Positioned(
top: 0,
left: 0,
right: 0,
child: CachedNetworkImage(
imageUrl: _img(
isPad ? _ComputationTheme.bgPad : _ComputationTheme.bg,
),
fit: BoxFit.fitWidth,
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
SafeArea(
bottom: false,
child: Column(
children: [
SizedBox(height: 8.h),
// AI核心算力气泡(454x98rpx)
CachedNetworkImage(
imageUrl: _img(_ComputationTheme.bubble),
width: 227.w,
height: 49.h,
fit: BoxFit.fill,
errorWidget: (_, _, _) =>
SizedBox(width: 227.w, height: 49.h),
),
SizedBox(height: 7.5.h),
// 圆环进度
ComputationProgressRing(progress: _ringProgress),
// 科目列表
Expanded(
child: ListView.separated(
padding: EdgeInsets.fromLTRB(
14.w,
10.h,
14.w,
20.h + bottomPadding,
),
itemCount: _subjectList.length,
separatorBuilder: (_, _) => SizedBox(height: 7.5.h),
itemBuilder: (_, index) {
final data = _subjectList[index];
return ComputationSubjectItem(
key: _itemKeys[index],
subjectName: data.subjectReport.subjectName,
iconUrl: data.subjectReport.icon,
knowCount: data.knowCount,
progress: data.progress,
status: data.status,
);
},
),
),
],
),
),
// finishedPopup 弹窗
if (_showFinishedPopup) ComputationFinishedPopup(onStart: _onFinish),
],
),
);
}
}
// ==================== 内部数据类 ====================
class _SubjectData {
final SubjectReportType subjectReport;
int progress = 0;
ComputationSubjectStatus status = ComputationSubjectStatus.notStarted;
final int time;
final int knowCount;
_SubjectData({
required this.subjectReport,
required this.time,
required this.knowCount,
});
}
@@ -0,0 +1,300 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/config/app_config.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【ReportWebViewPage】报告 H5 壳子容器 —— 对标 Vue reportWebView.vue
///
/// 承载已上线的 H5 壳子(App.K12.Student.Report),它内部自己加载
/// 每个科目的报告 URL 并提供"跳过/下一步"按钮。
///
/// Flutter 端负责:
/// 1. 拼 H5 壳子的加载 URL(baseURL / token / type / safeBottom);
/// 2. 注入 window.uni JS Bridge(switchTab / navigateTo / reLaunch),
/// 让 H5 壳子里已有的 `window.uni.switchTab(...)` 等调用自然生效。
/// 【通信方式】:用 console.log('[FLUTTER_BRIDGE] xxx url') + onConsoleMessage 监听,
/// 而不是 InAppWebView.callHandler/addJavaScriptHandler。
/// 原因:callHandler 在真机上首次 onLoadStop 注入时偶尔能通,
/// 但 H5 后续真实点击触发时就失效了(之前加的 _isExiting 锁导致死循环,
/// 去掉锁也没收到调用);console.log 通道每次都能收到,更稳定。
/// 3. H5 壳子自己调 updateAfterReportStatus() 更新出门测状态,
/// Flutter Bridge 只负责路由跳转,职责单一不重复。
///
/// 路由参数(GoRouteState.extra):
/// context.push('/reportWebView', extra: {'type': 'placementTest'});
///
/// H5 路径 → Flutter 路由映射表:见 _h5PathMap
/// ============================================================
class ReportWebViewPage extends ConsumerStatefulWidget {
const ReportWebViewPage({super.key});
@override
ConsumerState<ReportWebViewPage> createState() => _ReportWebViewPageState();
}
class _ReportWebViewPageState extends ConsumerState<ReportWebViewPage> {
InAppWebViewController? _webController;
double _loadingProgress = 0;
String _reportType = 'placementTest';
@override
void initState() {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// initState 里不能调 GoRouterState.of(context)(会访问 InheritedWidget),
// didChangeDependencies 在 initState 之后、build 之前立刻调用,
// 赋值时机刚好赶得上 InAppWebView.initialUrlRequest 用 _reportType 拼 URL。
final extra =
GoRouterState.of(context).extra as Map<String, dynamic>? ?? {};
_reportType = (extra['type'] as String?) ?? 'placementTest';
}
/// 拼 H5 壳子的加载 URL(对齐 UniApp reportWebView.vue 的 encodeURIComponent 逻辑)
String _buildH5Url() {
final token = DioClient.instance.accessToken ?? '';
final baseUrl = AppConfig.baseUrl;
final safeBottom = 0; // TODO: 计算安全底部距离
// 用 Dart Uri 统一编码所有 query param,和 UniApp 的 encodeURIComponent 对齐
final h5Uri = Uri.parse(AppConfig.reportUrl);
return h5Uri
.replace(
queryParameters: {
'baseURL': baseUrl,
'token': token,
't': DateTime.now().millisecondsSinceEpoch.toString(),
'safeBottom': safeBottom.toString(),
'type': _reportType,
},
)
.toString();
}
/// 注入 window.uni JS Bridge 源码(使用 console 通道通信,比 callHandler 更稳)
String get _uniBridgeScript {
final script = '''
(function() {
window.uni = window.uni || {};
// 通过 console.log 发送指令给 Flutter 端 onConsoleMessage 监听
function callFlutter(action, url) {
console.log('[FLUTTER_BRIDGE] ' + action + ' ' + url);
}
window.uni.switchTab = function(options) {
var url = (options && options.url) || '';
console.log('[uniBridge] switchTab called, url=' + url);
callFlutter('switchTab', url);
if (options && options.success) options.success();
};
window.uni.navigateTo = function(options) {
var url = (options && options.url) || '';
console.log('[uniBridge] navigateTo called, url=' + url);
callFlutter('navigateTo', url);
if (options && options.success) options.success();
};
window.uni.reLaunch = function(options) {
var url = (options && options.url) || '';
console.log('[uniBridge] reLaunch called, url=' + url);
callFlutter('reLaunch', url);
if (options && options.success) options.success();
};
console.log('[uniBridge] window.uni injected via console channel');
})();
''';
return script;
}
@override
Widget build(BuildContext context) {
final h5Url = _buildH5Url();
talker.debug('ReportWebViewPage loading: $h5Url');
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: Text('学生报告'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1D2B47)),
onPressed: () => context.canPop() ? context.pop() : context.go('/'),
),
),
body: InAppWebView(
initialUrlRequest: URLRequest(url: WebUri(h5Url)),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
allowsInlineMediaPlayback: true,
useHybridComposition: true,
),
// 把 H5 壳子的 console 日志转发到 Flutter talker
onConsoleMessage: (controller, consoleMessage) {
final levelName = consoleMessage.messageLevel
.toString()
.split('.')
.last;
final rawMsg = consoleMessage.message;
final msg = '[H5] [$levelName] $rawMsg';
if (levelName == 'ERROR') {
talker.error(msg);
} else {
talker.debug(msg);
}
// 解析 H5 通过 console 发来的跳转指令:
// console.log('[FLUTTER_BRIDGE] switchTab', '/pages/tabbar/task/task')
// console.log('[FLUTTER_BRIDGE] navigateTo', '/pages/reportPackage/reportComputation/reportComputation')
// console.log('[FLUTTER_BRIDGE] reLaunch', '/pages/xxx/yyy')
if (rawMsg.startsWith('[FLUTTER_BRIDGE]')) {
final parts = rawMsg
.substring('[FLUTTER_BRIDGE]'.length)
.trim()
.split(' ');
if (parts.length >= 2) {
final action = parts[0];
final url = parts.sublist(1).join(' ');
final flutterPath = _mapH5Path(url);
talker.debug('[consoleBridge] $action $url → $flutterPath');
switch (action) {
case 'switchTab':
if (mounted) context.go(flutterPath);
break;
case 'navigateTo':
if (mounted) context.go(flutterPath);
break;
case 'reLaunch':
if (mounted) context.go(flutterPath);
break;
}
}
}
},
onWebViewCreated: (controller) {
_webController = controller;
_registerJsBridge(controller);
},
onLoadStop: (controller, url) async {
setState(() => _loadingProgress = 1);
// 注入 window.uni JS Bridge
await controller.evaluateJavascript(source: _uniBridgeScript);
},
onProgressChanged: (controller, progress) {
setState(() => _loadingProgress = progress / 100.0);
},
),
bottomNavigationBar: _loadingProgress < 1.0
? LinearProgressIndicator(
value: _loadingProgress,
backgroundColor: Colors.transparent,
valueColor: const AlwaysStoppedAnimation(Color(0xFF3D7DF5)),
)
: null,
);
}
/// H5 壳子传的 uni-app 路径 → Flutter 路由路径映射表
///
/// H5 壳子里所有 uni.switchTab/navigateTo/reLaunch 都用 uni-app 的路径格式,
/// Flutter Bridge 收到后先过这个表转成 Flutter 自己的路由再跳转。
static const Map<String, String> _h5PathMap = {
// switchTab
'/pages/tabbar/task/task': '/',
'/pages/tabbar/recite/recite': '/recite',
'/pages/tabbar/class/class': '/class',
'/pages/tabbar/my/my': '/my',
// navigateTo
'/pages/reportPackage/reportComputation/reportComputation':
'/reportComputation',
// reLaunch
'/pages/reportPackage/studentInfo/studentInfo': '/studentInfo',
'/pages/login/index': '/login',
};
/// 把 H5 uni-app 路径转 Flutter 路由,转不了就原样返回
String _mapH5Path(String h5Path) {
// 去掉 query 参数再匹配
final pathOnly = h5Path.split('?').first;
return _h5PathMap[pathOnly] ?? h5Path;
}
// ============================================================
// JS Bridge:接收 H5 壳子通过 window.uni.switchTab/navigateTo/reLaunch 发起的调用
// 统一用 uniBridge 一个 handler,参数里带 name 区分
// ============================================================
void _registerJsBridge(InAppWebViewController controller) {
// addJavaScriptHandler 在 Chrome Web 平台不支持,跳过即可
if (kIsWeb) {
talker.debug('[uniBridge] Web平台跳过 registerJsBridge');
return;
}
controller.addJavaScriptHandler(
handlerName: 'uniBridge',
callback: (args) async {
// 最顶端先打,任何过滤之前
talker.debug('[uniBridge] 收到 callHandler, args=$args');
if (args.isEmpty) {
talker.debug('[uniBridge] args 为空,return');
return;
}
final payload = args.first;
if (payload is! Map) {
talker.debug(
'[uniBridge] payload 不是 Map,return: ${payload.runtimeType}',
);
return;
}
final name = payload['name'] as String?;
final options = payload['options'] as Map<String, dynamic>? ?? {};
final url = options['url'] as String? ?? '';
final flutterPath = _mapH5Path(url);
talker.debug('[uniBridge] $name $url → $flutterPath');
switch (name) {
case 'switchTab':
await _handleSwitchTab(flutterPath);
break;
case 'navigateTo':
await _handleNavigateTo(flutterPath);
break;
case 'reLaunch':
await _handleReLaunch(flutterPath);
break;
}
},
);
}
/// H5 壳子调 switchTab:直接跳对应 tab
Future<void> _handleSwitchTab(String flutterPath) async {
if (!mounted) return;
context.go(flutterPath);
}
/// H5 壳子调 navigateTo:跳到对应 Flutter 页面
Future<void> _handleNavigateTo(String flutterPath) async {
if (!mounted) return;
context.go(flutterPath);
}
/// H5 壳子调 reLaunch:重建路由栈跳到对应页面
Future<void> _handleReLaunch(String flutterPath) async {
if (!mounted) return;
context.go(flutterPath);
}
@override
void dispose() {
_webController?.dispose();
super.dispose();
}
}
+654
View File
@@ -0,0 +1,654 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/report/api.dart';
import 'package:new_learn/core/api/report/model.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【StudentInfoPage】诊断报告欢迎介绍页 —— 对标 Vue studentInfo.vue
///
/// Vue 端用 tPageImg / tPageCss / tPageColor 从 themes/default.json 抽主题;
/// Flutter 端未做主题抽离,所以直接硬编码 default.json 里这一页的资源路径、
/// 颜色值、渐变配置。集中在本文件顶部 _StudentTheme 常量,便于后续抽离。
///
/// 路由参数(GoRouteState.extra):
/// context.go('/studentInfo', extra: {'type': 'placementTest'});
/// - type: placementTest(入门测)| exitTest(出门测)
///
/// 完整链路:登录成功/全局拦截 → 此页 → H5壳子 → reportComputation(入门测)
/// 或 switchTab 首页(出门测)
/// ============================================================
/// 本页用到的所有主题资源(对标 Vue themes/default.json → pages/reportPackage/studentInfo)
class _StudentTheme {
// ---- 图片(default.json images)----
// Flutter 端用 network 加载,需拼 CDN 域名;这里存相对路径,
// 使用时调 ImgUrl.build(path)
static const String bg =
'/StaticResource/2026-06-10/bg-report1781093056538.png';
static const String bubble =
'/StaticResource/2026-06-12/report_kuang1781230963682.png';
static const String welcome =
'/StaticResource/2026-06-10/Group3331781094167604.png';
static const String analytics =
'/StaticResource/2026-06-10/analytics1781081543912.png';
static const String logo = '/StaticResource/2026-06-10/logo1781083733383.png';
static const String aiAnalysisTitle =
'/StaticResource/2026-06-10/ai-analysis-title1781087439752.png';
static const String analysisTitle =
'/StaticResource/2026-06-22/analysis-title1782110217839.png';
static const String levelA =
'/StaticResource/2026-06-23/levelA1782178351618.png';
static const String levelB =
'/StaticResource/2026-06-23/levelB1782178351618.png';
static const String levelC =
'/StaticResource/2026-06-23/levelC1782178351618.png';
static const String verify =
'/StaticResource/2026-06-10/verify1781081993368.png';
static const String starIcon =
'/StaticResource/2026-06-10/star1781089096898.png';
// ---- 颜色(default.json colors)----
static const Color pageBg = Color(0xFFE5F6FE); // background
static const Color highlight = Color(0xFF3D7DF5);
// ---- 渐变(default.json css 里的 linear-gradient/rgba 转 Flutter)----
// item-bg: linear-gradient(180deg, #F5FBFF → #E5F4FF)
static const LinearGradient itemBg = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF5FBFF), Color(0xFFE5F4FF)],
);
// item-border-color: linear-gradient(180deg, #FFFFFF → #C9E9FF(50%) → #BAE2FF)
static const LinearGradient itemBorder = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFFFFF), Color(0xFFC9E9FF), Color(0xFFBAE2FF)],
);
// analysis-view-bg: linear-gradient(180deg, #DAF3FF → #D4ECFF(52%) → #E5FAFB)
static const LinearGradient analysisViewBg = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFDAF3FF), Color(0xFFD4ECFF), Color(0xFFE5FAFB)],
);
// text-line: 底部渐变条(大字号等级的下划线)
// linear-gradient(90deg, rgba(61,125,245,0) → rgba(29,156,255,0.2) → rgba(138,236,253,0.5))
static const LinearGradient textLine = LinearGradient(
colors: [Color(0x003D7DF5), Color(0x331D9CFF), Color(0x808AECFD)],
);
// button-text-bg: linear-gradient(0deg, #FFFFFF → #E0F2FF)
static const LinearGradient buttonTextBg = LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Color(0xFFFFFFFF), Color(0xFFE0F2FF)],
);
// start-button-bg 径向渐变 + #3d7df5(简化:Flutter 径向渐变不太好还原多层 radial,
// 这里用线性渐变近似,视觉上接近即可)
static const LinearGradient startButtonBg = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF5B9BFF), Color(0xFF3D7DF5)],
);
}
/// 图片 URL 拼接工具(对标 Vue $img('/StaticResource/xxx'),复用项目 ImgUrl.build)
String _img(String path) => ImgUrl.build(path);
class StudentInfoPage extends ConsumerStatefulWidget {
const StudentInfoPage({super.key});
@override
ConsumerState<StudentInfoPage> createState() => _StudentInfoPageState();
}
class _StudentInfoPageState extends ConsumerState<StudentInfoPage> {
UserReportType _report = const UserReportType();
bool _loading = true;
String _reportType = 'placementTest';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final extra =
GoRouterState.of(context).extra as Map<String, dynamic>? ?? {};
_reportType = (extra['type'] as String?) ?? 'placementTest';
_loadReport();
});
}
Future<void> _loadReport() async {
setState(() => _loading = true);
try {
final isExit = _reportType == 'exitTest';
final report = isExit
? await ReportApi.instance.getCurrentAfterReport()
: await ReportApi.instance.getCurrentReport();
if (mounted) {
setState(() {
_report = report;
_loading = false;
});
}
} catch (e) {
talker.error('加载报告数据失败', e);
if (mounted) setState(() => _loading = false);
}
}
Future<void> _onStart() async {
if (_report.subjectReports.isEmpty) {
if (_reportType == 'exitTest') {
await ref.read(currentUserProvider.notifier).markShowAfterReportFalse();
} else {
await ref.read(currentUserProvider.notifier).markShowReportFalse();
}
if (mounted) context.go('/');
return;
}
context.push('/reportWebView', extra: {'type': _reportType});
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider).value;
final userName = _report.userName.isNotEmpty
? _report.userName
: (user?.userName ?? '');
return Scaffold(
backgroundColor: _StudentTheme.pageBg,
body: _loading
? const Center(child: CircularProgressIndicator())
: SizedBox.expand(
child: Stack(
children: [
// ===== 背景层(Vue bg-wrapper)铺满 =====
Positioned.fill(
child: Image.network(
_img(_StudentTheme.bg),
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
// ===== 内容层(滚动,底部留出按钮高度)=====
SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 140),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(userName),
const SizedBox(height: 130),
// _buildAnalysisLevel(),
// const SizedBox(height: 15),
_buildSubjectCard(),
const SizedBox(height: 14),
_buildLogo(),
],
),
),
),
// ===== 底部按钮(fixed,无边距)=====
Positioned(
left: 0,
right: 0,
bottom: 0,
child: SafeArea(top: false, child: _buildBottomButton()),
),
],
),
),
);
}
// ===== 顶部问候区 + welcome 插画 =====
// Vue: bubble-wrap(背景图气泡 + userName)+ welcome-wrap(欢迎插画)
Widget _buildHeader(String userName) {
return Padding(
padding: const EdgeInsets.only(left: 14, right: 14, top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// bubble 气泡(用 DecoratedBox 做背景图)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
// borderRadius: BorderRadius.circular(12),
image: DecorationImage(
image: NetworkImage(_img(_StudentTheme.bubble)),
fit: BoxFit.fill,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Hi . ',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
Text(
userName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w900,
color: Colors.white,
),
),
],
),
),
const SizedBox(height: 10),
// welcome 插画(占位,没图时跳过)
Image.network(
_img(_StudentTheme.welcome),
width: 165,
fit: BoxFit.fitWidth,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
],
),
);
}
// ===== 当前档次等级卡片(Vue analysis-view,被注释掉的)=====
Widget _buildAnalysisLevel() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Container(
width: double.infinity,
// analysis-view-bg 渐变外层
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: _StudentTheme.analysisViewBg,
boxShadow: const [
BoxShadow(
color: Color(0x1A3D7DF5),
blurRadius: 15,
offset: Offset(0, 5),
),
],
),
padding: const EdgeInsets.fromLTRB(16, 18, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// analysis-title 图
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Image.network(
_img(_StudentTheme.analysisTitle),
height: 18,
fit: BoxFit.fitHeight,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
],
),
const SizedBox(height: 12),
// 内层白色卡片:大字号 level + 文字
Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 18),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 大字号等级 + text-line 下划线
Stack(
alignment: Alignment.bottomLeft,
children: [
Text(
_report.userLevel.isNotEmpty
? _report.userLevel
: '--',
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2B47),
height: 1.0,
),
),
// 底部渐变条
Positioned(
left: 0,
right: 0,
bottom: -2,
child: Container(
height: 4,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
gradient: _StudentTheme.textLine,
),
),
),
],
),
const SizedBox(height: 12),
// ai-analysis-title 图("AI 智能诊断评定")
Image.network(
_img(_StudentTheme.aiAnalysisTitle),
height: 14,
fit: BoxFit.fitHeight,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
],
),
// 右上角 level${level} 图标
Positioned(
right: -18,
top: -44,
child: _LevelIcon(level: _report.userLevel),
),
],
),
),
],
),
),
);
}
// ===== 诊断科目分析卡片 =====
Widget _buildSubjectCard() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// header: analytics 图标 + title + 数据已校准
Row(
children: [
Image.network(
_img(_StudentTheme.analytics),
width: 18,
height: 18,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
const SizedBox(width: 6),
const Text(
'诊断科目分析',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2B47),
),
),
const Spacer(),
Image.network(
_img(_StudentTheme.verify),
width: 16,
height: 16,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
const SizedBox(width: 3),
const Text(
'数据已校准',
style: TextStyle(fontSize: 11, color: Color(0xFF00CC96)),
),
],
),
const SizedBox(height: 16),
// subject list
_report.subjectReports.isEmpty
? const Center(
child: Text(
'暂无数据',
style: TextStyle(color: Color(0xFF687FAD)),
),
)
: Wrap(
spacing: 10,
runSpacing: 10,
children: _report.subjectReports
.map((s) => _SubjectCard(subject: s))
.toList(),
),
],
),
),
);
}
// ===== logo =====
Widget _buildLogo() {
return Center(
child: Image.network(
_img(_StudentTheme.logo),
width: 65, // 130rpx ≈ 65dp
fit: BoxFit.fitWidth,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
);
}
// ===== 底部按钮区(fixed 底部,无边距,圆角顶对齐屏幕)=====
Widget _buildBottomButton() {
return Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
text: TextSpan(
style: const TextStyle(
fontSize: 11,
height: 1.6,
color: Color(0xFF495A7A),
),
children: [
WidgetSpan(
alignment: PlaceholderAlignment.top,
child: Padding(
padding: const EdgeInsets.only(right: 6),
child: Image.network(
_img(_StudentTheme.aiAnalysisTitle),
width: 95,
fit: BoxFit.fitWidth,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
),
const TextSpan(text: '本报告基于'),
const TextSpan(
text: '15年高考真题精研',
style: TextStyle(color: Color(0xFF3D7DF5)),
),
const TextSpan(text: '、'),
const TextSpan(
text: '50万道名校模拟题库',
style: TextStyle(color: Color(0xFF3D7DF5)),
),
const TextSpan(text: '及'),
const TextSpan(
text: '3,682个',
style: TextStyle(color: Color(0xFF3D7DF5)),
),
const TextSpan(text: '底层知识点拓扑结构,通过爱学智算引擎进行深度解析。'),
],
),
),
const SizedBox(height: 10),
// start-button
SizedBox(
width: double.infinity,
height: 44,
child: ElevatedButton(
onPressed: _onStart,
style: ElevatedButton.styleFrom(
padding: EdgeInsets.zero,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
),
elevation: 0,
backgroundColor: Colors.transparent,
),
child: Ink(
decoration: BoxDecoration(
gradient: _StudentTheme.startButtonBg,
borderRadius: BorderRadius.circular(22),
border: Border.all(color: const Color(0x4DFFFFFF), width: 2),
),
child: Container(
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.network(
_img(_StudentTheme.starIcon),
width: 24,
height: 24,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
const SizedBox(width: 6),
const Text(
'开启智能诊断报告',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
),
],
),
);
}
}
/// 科目卡片 —— item-bg 渐变 + item-border-color 渐变边框
class _SubjectCard extends StatelessWidget {
const _SubjectCard({required this.subject});
final SubjectReportType subject;
@override
Widget build(BuildContext context) {
return Container(
width: 72, // 144rpx
height: 88, // 176rpx
// 渐变边框用双层 Container 实现
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: _StudentTheme.itemBorder,
),
padding: const EdgeInsets.all(1), // 边框厚度
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(11),
gradient: _StudentTheme.itemBg,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: subject.icon.isNotEmpty
? Image.network(
subject.icon,
width: 40,
height: 40,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(
Icons.menu_book,
size: 40,
color: Color(0xFF3D7DF5),
),
)
: const Icon(
Icons.menu_book,
size: 40,
color: Color(0xFF3D7DF5),
),
),
const SizedBox(height: 8),
Text(
subject.subjectName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1D2B47),
),
),
],
),
),
);
}
}
/// 右上角等级图标:levelA/B/C 图片,加载失败时用占位
class _LevelIcon extends StatelessWidget {
const _LevelIcon({required this.level});
final String level;
@override
Widget build(BuildContext context) {
// 大写化匹配 default.json 的 key
final key = level.toUpperCase();
final iconPath = switch (key) {
'A' => _StudentTheme.levelA,
'B' => _StudentTheme.levelB,
'C' => _StudentTheme.levelC,
_ => _StudentTheme.levelB, // 兜底
};
return Image.network(
_img(iconPath),
width: 110,
height: 110,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Container(
width: 110,
height: 110,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
gradient: _StudentTheme.startButtonBg,
),
child: Text(
key,
style: const TextStyle(
fontSize: 48,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
);
}
}
@@ -0,0 +1,171 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/core/utils/screen_adapt_template.dart';
/// ============================================================
/// 【ComputationFinishedPopup】报告生成完成弹窗
/// —— 对标 Vue finishedPopup.vue
///
/// 黑色 60% 遮罩 + 顶部 62% 屏高的 top-light 光效图 +
/// 640x570rpx content-bg 内容图,底部「立即开启学习蝶变」渐变按钮,
/// 入场 0.5s 淡入上移(popup-fade-up)。
/// ============================================================
class ComputationFinishedPopup extends StatefulWidget {
const ComputationFinishedPopup({super.key, required this.onStart});
final VoidCallback onStart;
static const String topLightImg =
'/StaticResource/2026-06-11/top-light1781173654372.png';
static const String topLightPadImg =
'/StaticResource/2026-06-24/top-light_pad1782270110001.png';
static const String contentBgImg =
'/StaticResource/2026-06-11/content-bg1781173794633.png';
static const String starImg =
'/StaticResource/2026-06-10/star1781089096898.png';
@override
State<ComputationFinishedPopup> createState() =>
_ComputationFinishedPopupState();
}
class _ComputationFinishedPopupState extends State<ComputationFinishedPopup>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
)..forward();
late final Animation<double> _opacity = CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
);
late final Animation<Offset> _offset =
Tween(begin: const Offset(0, 0.08), end: Offset.zero).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.sizeOf(context).height;
final isPad =
MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
return Stack(
children: [
// popup-mask
const Positioned.fill(child: ColoredBox(color: Color(0x99000000))),
// top-light:宽铺满,高 62%,居顶
Positioned(
top: 0,
left: 0,
right: 0,
height: screenHeight * 0.62,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
isPad
? ComputationFinishedPopup.topLightPadImg
: ComputationFinishedPopup.topLightImg,
),
fit: BoxFit.fill,
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
),
// popup-content:640x570rpx
Center(
child: FadeTransition(
opacity: _opacity,
child: SlideTransition(
position: _offset,
child: SizedBox(
width: 320.w,
height: 310.h,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: CachedNetworkImage(
imageUrl:
ImgUrl.build(ComputationFinishedPopup.contentBgImg),
fit: BoxFit.fill,
errorWidget: (_, _, _) => DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.r),
),
),
),
),
// gogo-button:90% 宽,距底 50rpx
Positioned(
left: 16.w,
right: 16.w,
bottom: 25.h,
child: _buildStartButton(),
),
],
),
),
),
),
),
],
);
}
Widget _buildStartButton() {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onStart,
child: Container(
height: 50.h, // 100rpx
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
// start-button-bg 多层 radial-gradient 用线性渐变近似
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF6AA2FF), Color(0xFF3D7DF5)],
),
border: Border.all(color: const Color(0x4DFFFFFF), width: 1.w),
boxShadow: const [
BoxShadow(
color: Color(0x3D3D7DF5),
blurRadius: 20,
offset: Offset(0, 10),
),
],
),
alignment: Alignment.center,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CachedNetworkImage(
imageUrl: ImgUrl.build(ComputationFinishedPopup.starImg),
width: 24.w,
height: 24.w,
errorWidget: (_, _, _) => SizedBox(width: 24.w, height: 24.w),
),
SizedBox(width: 6.w),
Text(
'立即开启学习蝶变',
style: TextStyle(
fontSize: 16.spMin,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
);
}
}
@@ -0,0 +1,235 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【ComputationProgressRing】AI 计算圆环 —— 对标 Vue ProgressRing.vue
///
/// 由外到内:
/// 1. 虚线刻度图(700rpx,6s 顺时针慢转)
/// 2. 外圈光环球图(524rpx,1.5s 反向快转)
/// 3. 外层渐变光圈 ellipse-wai(452rpx)
/// 4. 渐变进度弧(370rpx,线宽 20rpx,#8BFFF5 → #006CFF 纵向渐变)
/// 5. 白色内圆 ellipse-nei(300rpx)+ 渐变百分比文字 + 副标题
///
/// Vue 用 canvas 画弧;Flutter 用 CustomPaint,渐变方向与 Vue
/// createLinearGradient(center,0 → center,size) 保持一致(纵向)。
/// ============================================================
class ComputationProgressRing extends StatefulWidget {
const ComputationProgressRing({super.key, required this.progress});
/// 进度值 0-100
final double progress;
/// 外圈光环球图(Vue props.outerImg 默认值)
static const String outerImg =
'/StaticResource/2026-06-11/outer1781143064766.png';
/// 虚线刻度图(Vue props.dashedLineImg 默认值)
static const String dashedImg =
'/StaticResource/2026-06-11/dashedLine1781143064766.png';
@override
State<ComputationProgressRing> createState() =>
_ComputationProgressRingState();
}
class _ComputationProgressRingState extends State<ComputationProgressRing>
with TickerProviderStateMixin {
/// 外圈:Vue rotate-cw 0 → -360deg / 1.5s
late final AnimationController _outerCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
/// 虚线:Vue rotate-ccw 0 → 360deg / 6s
late final AnimationController _dashedCtrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 6),
)..repeat();
@override
void dispose() {
_outerCtrl.dispose();
_dashedCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final size = 262.w; // 524rpx
return SizedBox(
width: size,
height: size,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
// 虚线刻度(700rpx,超出 524rpx 容器,允许溢出绘制)
RepaintBoundary(
child: RotationTransition(
turns: _dashedCtrl,
child: _ringImage(ComputationProgressRing.dashedImg, 350.w),
),
),
// 外圈光球
RepaintBoundary(
child: RotationTransition(
turns: ReverseAnimation(_outerCtrl),
child: _ringImage(ComputationProgressRing.outerImg, 262.w),
),
),
// ellipse-wai:87.42deg 紫色渐变 + 内发光(内阴影用外发光近似)
Container(
width: 226.w,
height: 226.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
stops: [0.1133, 1.0],
colors: [Color(0x6B5B5DFF), Color(0x0000FFF2)],
),
border: Border.all(color: const Color(0x33FFFFFF), width: 1.w),
boxShadow: const [
BoxShadow(color: Color(0x33FFFFFF), blurRadius: 25),
],
),
),
// 进度弧(canvas)
SizedBox(
width: 185.w,
height: 185.w,
child: CustomPaint(painter: _RingArcPainter(widget.progress)),
),
// ellipse-nei:内圆
Container(
width: 150.w,
height: 150.w,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment(-0.6, -0.8),
end: Alignment(0.87, 0.49),
stops: [0.42, 0.82],
colors: [Color(0xFFB1DFFF), Color(0xFFFFFFFF)],
),
border: Border.all(color: const Color(0x80FFFFFF)),
boxShadow: const [
BoxShadow(
color: Color(0x0D1D2B47),
blurRadius: 28,
offset: Offset(0, 14),
),
BoxShadow(color: Color(0x80FFFFFF), blurRadius: 26),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_gradientText(
widget.progress.floor().toString(),
40.spMin,
height: 1.0,
),
Padding(
padding: EdgeInsets.only(left: 2.w, bottom: 6.h),
child: _gradientText('%', 14.5.spMin),
),
],
),
SizedBox(height: 2.h),
Text(
'AI加载中',
style: TextStyle(
fontSize: 14.5.spMin,
fontWeight: FontWeight.bold,
color: const Color(0xFF0F9AFF),
),
),
],
),
),
],
),
);
}
Widget _ringImage(String path, double size) {
return CachedNetworkImage(
imageUrl: ImgUrl.build(path),
width: size,
height: size,
fit: BoxFit.contain,
errorWidget: (_, _, _) => SizedBox(width: size, height: size),
);
}
/// 渐变文字:linear-gradient(180deg, #006cff → #529bff)
Widget _gradientText(String text, double fontSize, {double? height}) {
return ShaderMask(
shaderCallback: (rect) => ui.Gradient.linear(
rect.topCenter,
rect.bottomCenter,
const [Color(0xFF006CFF), Color(0xFF529BFF)],
),
child: Text(
text,
style: TextStyle(
fontSize: fontSize,
height: height,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
);
}
}
/// 进度弧画笔(对标 Vue canvas arc + 纵向线性渐变 + round 线帽)
class _RingArcPainter extends CustomPainter {
_RingArcPainter(this.progress);
final double progress;
@override
void paint(Canvas canvas, Size size) {
final p = progress.clamp(0.0, 100.0);
if (p <= 0) return;
final stroke = 10.w; // 20rpx
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - stroke) / 2;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round
..shader = ui.Gradient.linear(
Offset(center.dx, center.dy - radius),
Offset(center.dx, center.dy + radius),
const [Color(0xFF8BFFF5), Color(0xFF006CFF)],
);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2, // 从 12 点方向开始
2 * math.pi * p / 100,
false,
paint,
);
}
@override
bool shouldRepaint(covariant _RingArcPainter oldDelegate) =>
oldDelegate.progress != progress;
}
@@ -0,0 +1,267 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// 科目计算状态(对标 Vue SubjectStatus)
enum ComputationSubjectStatus { notStarted, inProgress, finished }
/// ============================================================
/// 【ComputationSubjectItem】AI 计算科目卡片
/// —— 对标 reportComputation.vue 的 .subject-item
///
/// 右上角标(subscript):未开始/进行中蓝色闪电底,已完成绿色对勾底;
/// 标题行:科目名 + 科目图标 + ai-plan「AI学习计划+」图;
/// 知识点行 + 底部渐变进度条(进行中蓝 / 已完成绿)。
/// ============================================================
class ComputationSubjectItem extends StatelessWidget {
const ComputationSubjectItem({
super.key,
required this.subjectName,
required this.iconUrl,
required this.knowCount,
required this.progress,
required this.status,
});
final String subjectName;
final String iconUrl;
final int knowCount;
/// 0-100
final int progress;
final ComputationSubjectStatus status;
// ---- 图片资源(default.json ai-plan + Vue 内联 $img 角标图)----
static const String _aiPlanImg =
'/StaticResource/2026-06-11/aiPlan1781157211012.png';
static const String _subscriptBgBlue =
'/StaticResource/2026-06-11/subscript-bg11781162055363.png';
static const String _subscriptBgGreen =
'/StaticResource/2026-06-11/subscript-bg21781162055358.png';
static const String _iconFlash =
'/StaticResource/2026-06-11/subscript-cion1-1781162401643.png';
static const String _iconCheck =
'/StaticResource/2026-06-11/subscript-cion2-1781162401644.png';
bool get _isFinished => status == ComputationSubjectStatus.finished;
@override
Widget build(BuildContext context) {
return Opacity(
// Vue: 未开始 opacity 0.8
opacity: status == ComputationSubjectStatus.notStarted ? 0.8 : 1,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.r), // 16rpx
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0, 0.3, 1],
colors: _isFinished
? const [Color(0xFFE3FCF4), Color(0xFFF7FCFA), Colors.white]
: const [Color(0xFFE3F7FC), Color(0xFFF7FBFC), Colors.white],
),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: EdgeInsets.fromLTRB(14.w, 14.w, 14.w, 20.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(),
SizedBox(height: 16.h), // 知识点行 margin 32rpx 0
_buildKnowledge(),
SizedBox(height: 16.h),
_buildProgressBar(),
],
),
),
// 右上角标
Positioned(top: 0, right: 0, child: _buildSubscript()),
],
),
),
);
}
// ===== 标题行:科目名 + 科目图标 + AI学习计划图 =====
Widget _buildTitle() {
return Row(
children: [
Text(
subjectName,
style: TextStyle(
fontSize: 18.spMin, // 36rpx
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
SizedBox(width: 6.w),
if (iconUrl.isNotEmpty)
CachedNetworkImage(
imageUrl: ImgUrl.build(iconUrl),
width: 26.w, // 52rpx,widthFix
fit: BoxFit.fitWidth,
errorWidget: (_, _, _) => Icon(
Icons.menu_book,
size: 26.w,
color: const Color(0xFF3D7DF5),
),
)
else
Icon(Icons.menu_book, size: 26.w, color: const Color(0xFF3D7DF5)),
SizedBox(width: 6.w),
CachedNetworkImage(
imageUrl: ImgUrl.build(_aiPlanImg),
width: 77.w, // 154rpx,widthFix
fit: BoxFit.fitWidth,
errorWidget: (_, _, _) => const SizedBox.shrink(),
),
],
);
}
// ===== 知识点行(Vue 预计时间已注释,只保留知识点)=====
Widget _buildKnowledge() {
return Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 6.h),
decoration: BoxDecoration(
color: const Color(0xFFF3F8FE),
borderRadius: BorderRadius.circular(4.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'知识点',
style: TextStyle(
fontSize: 12.spMin,
color: const Color(0xFF495A7A),
),
),
SizedBox(width: 6.w),
Container(
padding: EdgeInsets.symmetric(horizontal: 3.5.w),
decoration: BoxDecoration(
color: const Color(0xFFE1EAFA),
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
'$knowCount',
style: TextStyle(
fontSize: 12.spMin,
color: const Color(0xFF495A7A),
),
),
),
],
),
);
}
// ===== 底部进度条:蓝渐变 / 绿渐变 =====
Widget _buildProgressBar() {
return SizedBox(
height: 6.h, // 12rpx
child: LayoutBuilder(
builder: (_, constraints) {
final fillWidth =
constraints.maxWidth * (progress.clamp(0, 100) / 100);
return Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFE9EDF5),
borderRadius: BorderRadius.circular(3.h),
),
),
),
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(
width: fillWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3.h),
gradient: LinearGradient(
colors: _isFinished
? const [Color(0xFFB4EADA), Color(0xFF19CE92)]
: const [
Color(0xFF8AD1FD),
Color(0xFF3C84FF),
Color(0xFF3D7DF5),
],
stops: _isFinished ? null : const [0, 0.64, 1],
),
),
),
),
],
);
},
),
);
}
// ===== 右上角标:162x66rpx 背景图 + 状态图标 + 百分比 =====
Widget _buildSubscript() {
final bgImg = _isFinished ? _subscriptBgGreen : _subscriptBgBlue;
return SizedBox(
width: 81.w,
height: 33.h,
child: Stack(
children: [
Positioned.fill(
child: CachedNetworkImage(
imageUrl: ImgUrl.build(bgImg),
fit: BoxFit.fill,
errorWidget: (_, _, _) => DecoratedBox(
decoration: BoxDecoration(
color: _isFinished
? const Color(0xFF19CE92)
: const Color(0xFF3D7DF5),
borderRadius: BorderRadius.only(
topRight: Radius.circular(8.r),
),
),
),
),
),
Padding(
padding: EdgeInsets.only(bottom: 5.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (status != ComputationSubjectStatus.notStarted)
Padding(
padding: EdgeInsets.only(right: 4.w),
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
_isFinished ? _iconCheck : _iconFlash,
),
width: 18.w,
height: 18.w,
errorWidget: (_, _, _) =>
SizedBox(width: 18.w, height: 18.w),
),
),
Text(
'$progress%',
style: TextStyle(
fontSize: 16.spMin, // 32rpx
color: Colors.white,
),
),
],
),
),
],
),
);
}
}
+29 -1
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/components/video_player_page.dart'; import 'package:new_learn/core/components/video_player_page.dart';
import 'package:new_learn/features/auth/login_page.dart'; import 'package:new_learn/features/auth/login_page.dart';
import 'package:new_learn/features/conquerPackage/captureList/index.dart';
import 'package:new_learn/pages/demo/align_demo.dart'; import 'package:new_learn/pages/demo/align_demo.dart';
import 'package:new_learn/pages/demo/animation_demo.dart'; import 'package:new_learn/pages/demo/animation_demo.dart';
import 'package:new_learn/pages/demo/form_demo.dart'; import 'package:new_learn/pages/demo/form_demo.dart';
@@ -228,6 +230,21 @@ class TaskPage extends StatelessWidget {
Icons.description_outlined, Icons.description_outlined,
_page(ExamDemoPage.new), _page(ExamDemoPage.new),
), ),
_DemoItem(
'攻克列表',
'课程攻克包列表 + 进度条 + 展开详情(go_router 跳转 + extra 传参)',
Icons.emoji_events_outlined,
_page(() => const Scaffold()),
onTap: (context) {
// 延时到当前帧结束后再导航,避免 build 过程中触发 Router rebuild
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.push('/conquerPackage/captureList',
extra: '841985856751366');
}
});
},
),
]; ];
@override @override
@@ -278,9 +295,13 @@ class TaskPage extends StatelessWidget {
), ),
// 点击 → 路由跳转到对应 Demo 页面 // 点击 → 路由跳转到对应 Demo 页面
onTap: () { onTap: () {
if (demo.onTap != null) {
demo.onTap!(context);
} else {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute(builder: demo.builder), MaterialPageRoute(builder: demo.builder),
); );
}
}, },
), ),
); );
@@ -308,10 +329,17 @@ WidgetBuilder _page(Widget Function() create) {
/// Demo 列表项数据模型 /// Demo 列表项数据模型
class _DemoItem { class _DemoItem {
const _DemoItem(this.title, this.subtitle, this.icon, this.builder); const _DemoItem(
this.title,
this.subtitle,
this.icon,
this.builder, {
this.onTap,
});
final String title; final String title;
final String subtitle; final String subtitle;
final IconData icon; final IconData icon;
final WidgetBuilder builder; final WidgetBuilder builder;
final void Function(BuildContext context)? onTap;
} }
+48
View File
@@ -1,11 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
// ignore: unused_import —— 调试结束切回 TabApp 时使用
import 'package:new_learn/core/app.dart'; import 'package:new_learn/core/app.dart';
import 'package:new_learn/core/network/dio_client.dart'; import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/providers/privacy_provider.dart'; import 'package:new_learn/core/providers/privacy_provider.dart';
import 'package:new_learn/core/router/app_router.dart';
import 'package:new_learn/core/storage/database/app_database.dart'; import 'package:new_learn/core/storage/database/app_database.dart';
import 'package:new_learn/core/storage/privacy_storage.dart'; import 'package:new_learn/core/storage/privacy_storage.dart';
import 'package:new_learn/core/utils/screen_adapt_template.dart';
/// App 启动入口 —— 真正的业务代码都在 app.dart / pages/ 里 /// App 启动入口 —— 真正的业务代码都在 app.dart / pages/ 里
/// ///
@@ -108,6 +113,9 @@ Future<void> main() async {
// 需要在启动 App 之前读取本地存储、调用原生插件,必须写在 runApp 之前! // 需要在启动 App 之前读取本地存储、调用原生插件,必须写在 runApp 之前!
// 作用: 初始化 Flutter 和原生的绑定桥梁 // 作用: 初始化 Flutter 和原生的绑定桥梁
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// flutter_inappwebview 平台实现注册(Chrome Web / Windows desktop 必须显式触发)
// 否则会报 InAppWebViewPlatform.instance != null 断言错误
InAppWebViewPlatform.instance;
// go_router 17.x 大坑:命令式导航(context.push/go)默认不写回浏览器 // go_router 17.x 大坑:命令式导航(context.push/go)默认不写回浏览器
// 地址栏(optionURLReflectsImperativeAPIs 默认 false)。必须在使用 // 地址栏(optionURLReflectsImperativeAPIs 默认 false)。必须在使用
// appRouter(runApp)之前全局开启,否则 web 调试 URL 不随页面变化。 // appRouter(runApp)之前全局开启,否则 web 调试 URL 不随页面变化。
@@ -123,4 +131,44 @@ Future<void> main() async {
await AppDatabase.instance.init(); await AppDatabase.instance.init();
// ProviderScope:Riverpod 所有 provider 的容器,必须包在根 // ProviderScope:Riverpod 所有 provider 的容器,必须包在根
runApp(const ProviderScope(child: TabApp())); runApp(const ProviderScope(child: TabApp()));
// 临时调试用,跑完改回。
// 注意两点:
// 1. 页面组件用了 .w/.h/.spMin,必须包 ScreenUtilInit(与 TabApp 一致),
// 否则 ScreenUtil 的 late 字段未初始化会抛 LateInitializationError;
// 2. 必须用真实 appRouter(MaterialApp.router),不能用裸 MaterialApp(home:),
// 否则页面里 context.go('/') 找不到 GoRouter,点"立即开启学习蝶变"不会跳首页。
// runApp(const ProviderScope(child: _DebugApp()));
}
/// 临时调试壳:ScreenUtilInit + 真实路由表,启动后直接进 /reportComputation
class _DebugApp extends StatefulWidget {
const _DebugApp();
@override
State<_DebugApp> createState() => _DebugAppState();
}
class _DebugAppState extends State<_DebugApp> {
@override
void initState() {
super.initState();
// 首帧后跳到调试页(默认初始位置是 '/',直接进会先闪一帧首页)
WidgetsBinding.instance.addPostFrameCallback((_) {
appRouter.go('/reportComputation');
});
}
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: AppAdapt.designSizeOf(context),
minTextAdapt: true,
splitScreenMode: true,
fontSizeResolver: AppAdapt.fontSizeResolver,
builder: (context, _) => MaterialApp.router(
debugShowCheckedModeBanner: false,
routerConfig: appRouter,
),
);
}
} }
@@ -0,0 +1,15 @@
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -41,14 +41,14 @@
buildTypes {
debug {
minifyEnabled false
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
release {
minifyEnabled true
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
+393 -417
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -166,6 +166,14 @@ dependencies:
# url_launcher:跳转应用商店 / 外部浏览器下载地址 # url_launcher:跳转应用商店 / 外部浏览器下载地址
url_launcher: ^6.3.1 url_launcher: ^6.3.1
# WebView(内嵌 H5 报告详情页,支持 JS ↔ Flutter 双向通信)
# 为什么用 flutter_inappwebview 而不是 webview_flutter:
# - 需要 H5 内部按钮回调到 Flutter(JS bridge:addJavaScriptHandler)
# - 比官方 webview_flutter 功能强:支持 callAsyncJavaScript、cookie 管理等
# - 6.1.5 stable 最低 Flutter 3.24 / Dart 3.5,项目 Flutter 3.44.8 完全兼容
# ⚠ Android 端要求 minSdkVersion >= 19(flutter_inappwebview 6.x 默认)
flutter_inappwebview: ^6.1.5
# 常驻底部面板(smooth_sheets:3 停靠点拖拽面板,支持内部 ListView 滚动联动) # 常驻底部面板(smooth_sheets:3 停靠点拖拽面板,支持内部 ListView 滚动联动)
# 用法:lib/core/components/persistent_snap_sheet.dart 封装组件 # 用法:lib/core/components/persistent_snap_sheet.dart 封装组件
# 版本:1.1.1(2026-07 发布,支持 persistent + modal 两种模式) # 版本:1.1.1(2026-07 发布,支持 persistent + modal 两种模式)
@@ -8,6 +8,7 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h> #include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_timezone/flutter_timezone_plugin_c_api.h> #include <flutter_timezone/flutter_timezone_plugin_c_api.h>
#include <gal/gal_plugin_c_api.h> #include <gal/gal_plugin_c_api.h>
@@ -21,6 +22,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterTimezonePluginCApiRegisterWithRegistrar( FlutterTimezonePluginCApiRegisterWithRegistrar(
+1
View File
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus connectivity_plus
file_selector_windows file_selector_windows
flutter_inappwebview_windows
flutter_secure_storage_windows flutter_secure_storage_windows
flutter_timezone flutter_timezone
gal gal