Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe060ac974 |
@@ -137,26 +137,6 @@ class UserApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 报告状态更新 ====================
|
|
||||||
|
|
||||||
/// 更新入门测试报告显示状态(看完后置回 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();
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ 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;
|
||||||
@@ -65,9 +64,6 @@ 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
|
||||||
@@ -77,7 +73,6 @@ 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,80 @@
|
|||||||
|
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
|
||||||
|
import 'package:new_learn/core/network/dio_client.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【课前课中模块接口】讲义列表 / 学习时长更新 / 提交报告
|
||||||
|
///
|
||||||
|
/// 对标 Vue 端 api/task/index.ts 中的相关接口:
|
||||||
|
///
|
||||||
|
/// getListByRecordHandout
|
||||||
|
/// —— 根据学习记录获取讲义列表
|
||||||
|
/// GET /api/v1/Handout/GetListByRecord/{recordId}
|
||||||
|
///
|
||||||
|
/// startOvercome
|
||||||
|
/// —— 开始/继续攻克学习包(返回 recordId 等)
|
||||||
|
/// POST /api/v1/StudyPack/StartOvercome/{packId}
|
||||||
|
///
|
||||||
|
/// renewHandoutTime
|
||||||
|
/// —— 更新讲义学习时长
|
||||||
|
/// POST /api/v1/Handout/RenewTime
|
||||||
|
///
|
||||||
|
/// getSubmitReport
|
||||||
|
/// —— 获取攻克报告(view 模式数据源)
|
||||||
|
/// GET /api/v1/StudyPack/GetReport/{recordId}
|
||||||
|
///
|
||||||
|
/// 注意:
|
||||||
|
/// - 页面只调这里,不直接碰 Dio;
|
||||||
|
/// - 异常不在 catch,交页面统一处理;
|
||||||
|
/// - view 模式下接口返回的 handouts 列表格式一致,统一走 HandoutListResponse。
|
||||||
|
/// ============================================================
|
||||||
|
class BeforeDuringClassApi {
|
||||||
|
BeforeDuringClassApi._();
|
||||||
|
|
||||||
|
static final BeforeDuringClassApi instance = BeforeDuringClassApi._();
|
||||||
|
|
||||||
|
/// 根据学习记录获取讲义列表 → GET /api/v1/Handout/GetListByRecord/{recordId}
|
||||||
|
Future<HandoutListResponse> getListByRecordHandout(String recordId) async {
|
||||||
|
final data = await DioClient.instance.get(
|
||||||
|
'/api/v1/Handout/GetListByRecord/$recordId',
|
||||||
|
);
|
||||||
|
return HandoutListResponse.fromJson(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新讲义学习时长 → POST /api/v1/Handout/RenewTime
|
||||||
|
///
|
||||||
|
/// [id] 讲义 id
|
||||||
|
/// [courseId] 课程 id
|
||||||
|
/// [startTime] 开始时间 ISO 字符串
|
||||||
|
/// [endTime] 结束时间 ISO 字符串
|
||||||
|
Future<void> renewHandoutTime({
|
||||||
|
required String id,
|
||||||
|
required String courseId,
|
||||||
|
required String startTime,
|
||||||
|
required String endTime,
|
||||||
|
}) async {
|
||||||
|
await DioClient.instance.post(
|
||||||
|
'/api/v1/Handout/RenewTime',
|
||||||
|
data: {
|
||||||
|
'id': id,
|
||||||
|
'courseId': courseId,
|
||||||
|
'startTime': startTime,
|
||||||
|
'endTime': endTime,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取攻克报告(view 模式) → GET /api/v1/StudyPack/GetReport/{recordId}
|
||||||
|
///
|
||||||
|
/// 返回结构中 handouts 字段对应讲义列表。
|
||||||
|
Future<HandoutListResponse> getSubmitReport(String recordId) async {
|
||||||
|
final data = await DioClient.instance.get(
|
||||||
|
'/api/v1/StudyPack/GetReport/$recordId',
|
||||||
|
);
|
||||||
|
// 报告里 handouts 可能在 data.handouts 下
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
final payload = data['data'] as Map<String, dynamic>? ?? data;
|
||||||
|
return HandoutListResponse.fromJson(payload['handouts']);
|
||||||
|
}
|
||||||
|
return const HandoutListResponse();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/// ============================================================
|
||||||
|
/// 【课前课中模块模型】讲义列表 / 材料 / 笔记
|
||||||
|
///
|
||||||
|
/// 对标 Vue 端 api/task/type.ts 中的 Handout 相关类型。
|
||||||
|
/// 序列化:手动 fromJson,兼容 {code, message, data: {...}} 响应壳。
|
||||||
|
/// ============================================================
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||||
|
|
||||||
|
/// 讲义材料类型
|
||||||
|
enum HandoutMaterialType {
|
||||||
|
video(1), // 视频
|
||||||
|
audio(2), // 音频
|
||||||
|
text(3), // 文本
|
||||||
|
unknown(0);
|
||||||
|
|
||||||
|
final int value;
|
||||||
|
const HandoutMaterialType(this.value);
|
||||||
|
|
||||||
|
factory HandoutMaterialType.fromInt(int? v) =>
|
||||||
|
HandoutMaterialType.values.firstWhere(
|
||||||
|
(e) => e.value == v,
|
||||||
|
orElse: () => HandoutMaterialType.unknown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 讲义材料项(视频/音频/文本)
|
||||||
|
class HandoutMaterial {
|
||||||
|
const HandoutMaterial({
|
||||||
|
this.id = '',
|
||||||
|
this.pageIndex = 0,
|
||||||
|
this.materialType = HandoutMaterialType.unknown,
|
||||||
|
this.materialContent = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id; // 材料 id
|
||||||
|
final int pageIndex; // 页码
|
||||||
|
final HandoutMaterialType materialType; // 材料类型
|
||||||
|
final String materialContent; // 内容(URL 或 HTML 文本)
|
||||||
|
|
||||||
|
factory HandoutMaterial.fromJson(Map<String, dynamic> json) {
|
||||||
|
return HandoutMaterial(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
pageIndex: _parseInt(json['pageIndex']),
|
||||||
|
materialType:
|
||||||
|
HandoutMaterialType.fromInt(_parseInt(json['materialType'])),
|
||||||
|
materialContent: json['materialContent'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'HandoutMaterial{id: $id, pageIndex: $pageIndex, '
|
||||||
|
'type: ${materialType.name}, content: '
|
||||||
|
'${materialContent.length > 50 ? '${materialContent.substring(0, 50)}...' : materialContent}}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 单页讲义
|
||||||
|
class HandoutItem {
|
||||||
|
const HandoutItem({
|
||||||
|
this.id = '',
|
||||||
|
this.handoutPageId = '',
|
||||||
|
this.name = '',
|
||||||
|
this.title = '',
|
||||||
|
this.content = '',
|
||||||
|
this.planStudyTimeSecond = 0,
|
||||||
|
this.planStudyRemainTimeSecond = 0,
|
||||||
|
this.studyTimeSecond = 0,
|
||||||
|
this.isFinishPlanTime = false,
|
||||||
|
this.isUploadNotes = false,
|
||||||
|
this.materials = const [],
|
||||||
|
this.notes = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id; // 讲义 id(用于 RenewTime / NoteAdd)
|
||||||
|
final String handoutPageId; // 讲义页 id
|
||||||
|
final String name; // 名称(页面顶部标题)
|
||||||
|
final String title; // 标题(NavBar 显示)
|
||||||
|
final String content; // 正文(富文本 HTML)
|
||||||
|
final int planStudyTimeSecond; // 建议学习时长(秒)
|
||||||
|
final int planStudyRemainTimeSecond; // 剩余学习时长(秒)
|
||||||
|
final int studyTimeSecond; // 已学习时长(秒)
|
||||||
|
final bool isFinishPlanTime; // 是否已完成建议时长
|
||||||
|
final bool isUploadNotes; // 是否必须上传笔记
|
||||||
|
final List<HandoutMaterial> materials; // 材料列表
|
||||||
|
final List<NoteImage> notes; // 笔记图片列表
|
||||||
|
|
||||||
|
/// 深拷贝(用于修改某字段后触发 setState)
|
||||||
|
HandoutItem copyWith({
|
||||||
|
String? id,
|
||||||
|
String? handoutPageId,
|
||||||
|
String? name,
|
||||||
|
String? title,
|
||||||
|
String? content,
|
||||||
|
int? planStudyTimeSecond,
|
||||||
|
int? planStudyRemainTimeSecond,
|
||||||
|
int? studyTimeSecond,
|
||||||
|
bool? isFinishPlanTime,
|
||||||
|
bool? isUploadNotes,
|
||||||
|
List<HandoutMaterial>? materials,
|
||||||
|
List<NoteImage>? notes,
|
||||||
|
}) {
|
||||||
|
return HandoutItem(
|
||||||
|
id: id ?? this.id,
|
||||||
|
handoutPageId: handoutPageId ?? this.handoutPageId,
|
||||||
|
name: name ?? this.name,
|
||||||
|
title: title ?? this.title,
|
||||||
|
content: content ?? this.content,
|
||||||
|
planStudyTimeSecond: planStudyTimeSecond ?? this.planStudyTimeSecond,
|
||||||
|
planStudyRemainTimeSecond:
|
||||||
|
planStudyRemainTimeSecond ?? this.planStudyRemainTimeSecond,
|
||||||
|
studyTimeSecond: studyTimeSecond ?? this.studyTimeSecond,
|
||||||
|
isFinishPlanTime: isFinishPlanTime ?? this.isFinishPlanTime,
|
||||||
|
isUploadNotes: isUploadNotes ?? this.isUploadNotes,
|
||||||
|
materials: materials ?? this.materials,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory HandoutItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
final materials = (json['materials'] as List<dynamic>? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(HandoutMaterial.fromJson)
|
||||||
|
.toList();
|
||||||
|
final notes = (json['notes'] as List<dynamic>? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(NoteImage.fromJson)
|
||||||
|
.toList();
|
||||||
|
return HandoutItem(
|
||||||
|
id: (json['id']?.toString()) ?? '',
|
||||||
|
handoutPageId: (json['handoutPageId']?.toString()) ?? '',
|
||||||
|
name: json['name'] as String? ?? json['title'] as String? ?? '',
|
||||||
|
title: json['title'] as String? ?? json['name'] as String? ?? '',
|
||||||
|
content: json['content'] as String? ?? '',
|
||||||
|
planStudyTimeSecond: _parseInt(json['planStudyTimeSecond']),
|
||||||
|
planStudyRemainTimeSecond: _parseInt(json['planStudyRemainTimeSecond']),
|
||||||
|
studyTimeSecond: _parseInt(json['studyTimeSecond']),
|
||||||
|
isFinishPlanTime:
|
||||||
|
json['ISFinishPlanTime'] as bool? ?? json['isFinishPlanTime'] as bool? ?? false,
|
||||||
|
isUploadNotes: json['isUploadNotes'] as bool? ?? false,
|
||||||
|
materials: materials,
|
||||||
|
notes: notes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'HandoutItem{id: $id, name: $name, planTime: $planStudyTimeSecond, '
|
||||||
|
'studyTime: $studyTimeSecond, isFinishPlanTime: $isFinishPlanTime, '
|
||||||
|
'isUploadNotes: $isUploadNotes, materials: ${materials.length}, '
|
||||||
|
'notes: ${notes.length}}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 讲义列表响应(Handout 接口返回的列表)
|
||||||
|
class HandoutListResponse {
|
||||||
|
const HandoutListResponse({this.items = const []});
|
||||||
|
|
||||||
|
final List<HandoutItem> items;
|
||||||
|
|
||||||
|
/// 兼容响应壳 {code, message, data: [...]},也兼容直接返回数组
|
||||||
|
factory HandoutListResponse.fromJson(dynamic data) {
|
||||||
|
List<dynamic> list;
|
||||||
|
if (data is List) {
|
||||||
|
list = data;
|
||||||
|
} else if (data is Map<String, dynamic>) {
|
||||||
|
final payload = data['data'] ?? data['items'] ?? data['handouts'];
|
||||||
|
list = (payload is List) ? payload : const [];
|
||||||
|
} else {
|
||||||
|
list = const [];
|
||||||
|
}
|
||||||
|
return HandoutListResponse(
|
||||||
|
items: list
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(HandoutItem.fromJson)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 安全解析 int
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/// ============================================================
|
|
||||||
/// 【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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
/// ============================================================
|
|
||||||
/// 【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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/// ============================================================
|
|
||||||
/// 【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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||||
|
import 'package:new_learn/core/network/dio_client.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【笔记相关接口】讲义笔记 / 试题笔记
|
||||||
|
///
|
||||||
|
/// 对应 Vue 端 api/task/index.ts:
|
||||||
|
/// HandoutNoteAdd → POST /api/v1/HandoutNote/Add
|
||||||
|
/// HandoutNoteDel → DELETE /api/v1/HandoutNote/Del/{id}
|
||||||
|
/// QuestionNoteAdd → POST /api/v1/QuestionNote/Add
|
||||||
|
/// QuestionNoteDel → DELETE /api/v1/QuestionNote/Del/{id}
|
||||||
|
///
|
||||||
|
/// 注意:页面只调这里,不直接碰 Dio;异常不在这里 catch,交页面统一处理。
|
||||||
|
/// ============================================================
|
||||||
|
class NoteApi {
|
||||||
|
NoteApi._();
|
||||||
|
|
||||||
|
static final NoteApi instance = NoteApi._();
|
||||||
|
|
||||||
|
/// 添加讲义笔记 → POST /api/v1/HandoutNote/Add
|
||||||
|
///
|
||||||
|
/// [id] 讲义 id
|
||||||
|
/// [url] 原图 URL(OSS 直传后返回)
|
||||||
|
/// [thumbnailUrl] 缩略图 URL
|
||||||
|
Future<NoteImage> handoutNoteAdd({
|
||||||
|
required dynamic id,
|
||||||
|
required String url,
|
||||||
|
required String thumbnailUrl,
|
||||||
|
}) async {
|
||||||
|
final data = await DioClient.instance.post(
|
||||||
|
'/api/v1/HandoutNote/Add',
|
||||||
|
data: {
|
||||||
|
'id': id,
|
||||||
|
'url': url,
|
||||||
|
'thumbnailUrl': thumbnailUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// 后端统一 {code, message, data} 包装:data 存在取 data,否则用顶层
|
||||||
|
final payload =
|
||||||
|
(data is Map<String, dynamic> && data.containsKey('data'))
|
||||||
|
? (data['data'] as Map<String, dynamic>?) ?? <String, dynamic>{}
|
||||||
|
: data as Map<String, dynamic>;
|
||||||
|
return NoteImage.fromJson(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除讲义笔记 → DELETE /api/v1/HandoutNote/Del/{id}
|
||||||
|
///
|
||||||
|
/// 返回 true 表示删除成功
|
||||||
|
Future<bool> handoutNoteDel(dynamic id) async {
|
||||||
|
await DioClient.instance.delete('/api/v1/HandoutNote/Del/$id');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加试题笔记 → POST /api/v1/QuestionNote/Add
|
||||||
|
Future<NoteImage> questionNoteAdd({
|
||||||
|
required dynamic id,
|
||||||
|
required String url,
|
||||||
|
required String thumbnailUrl,
|
||||||
|
}) async {
|
||||||
|
final data = await DioClient.instance.post(
|
||||||
|
'/api/v1/QuestionNote/Add',
|
||||||
|
data: {
|
||||||
|
'id': id,
|
||||||
|
'url': url,
|
||||||
|
'thumbnailUrl': thumbnailUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final payload =
|
||||||
|
(data is Map<String, dynamic> && data.containsKey('data'))
|
||||||
|
? (data['data'] as Map<String, dynamic>?) ?? <String, dynamic>{}
|
||||||
|
: data as Map<String, dynamic>;
|
||||||
|
return NoteImage.fromJson(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除试题笔记 → DELETE /api/v1/QuestionNote/Del/{id}
|
||||||
|
Future<bool> questionNoteDel(dynamic id) async {
|
||||||
|
await DioClient.instance.delete('/api/v1/QuestionNote/Del/$id');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/// ============================================================
|
||||||
|
/// 【笔记图片数据模型】讲义笔记 / 试题笔记
|
||||||
|
///
|
||||||
|
/// NoteImage 对应 Vue 端上传笔记后返回的图片记录:
|
||||||
|
/// - id:笔记记录 id(删除接口用)
|
||||||
|
/// - url:原图 URL
|
||||||
|
/// - thumbnailUrl:缩略图 URL(列表预览用)
|
||||||
|
/// ============================================================
|
||||||
|
|
||||||
|
/// 笔记图片项(用于已上传图片列表)
|
||||||
|
class NoteImage {
|
||||||
|
const NoteImage({
|
||||||
|
this.id,
|
||||||
|
required this.url,
|
||||||
|
this.thumbnailUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 服务器返回的笔记 id(删除接口用;本地添加的图片可能没有)
|
||||||
|
final dynamic id;
|
||||||
|
|
||||||
|
/// 图片原图 URL
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
/// 缩略图 URL(为空时用 url 兜底)
|
||||||
|
final String? thumbnailUrl;
|
||||||
|
|
||||||
|
factory NoteImage.fromJson(Map<String, dynamic> json) {
|
||||||
|
return NoteImage(
|
||||||
|
id: json['id'],
|
||||||
|
url: (json['url'] as String?) ?? '',
|
||||||
|
thumbnailUrl: json['thumbnailUrl'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'url': url,
|
||||||
|
'thumbnailUrl': thumbnailUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
/// ============================================================
|
|
||||||
/// 【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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
/// ============================================================
|
|
||||||
/// 【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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/// ============================================================
|
|
||||||
/// 【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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-7
@@ -9,7 +9,6 @@ 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 适配配置
|
||||||
@@ -90,12 +89,8 @@ class TabApp extends StatelessWidget {
|
|||||||
// 这样冷启动时不会先看到登录页/主页闪一下,再被路由重定向到隐私页,
|
// 这样冷启动时不会先看到登录页/主页闪一下,再被路由重定向到隐私页,
|
||||||
// 而是直接由全屏拦截页先接管。
|
// 而是直接由全屏拦截页先接管。
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// ReportRedirectNotifier 必须包在 MaterialApp 里面(能拿到 GoRouterState)
|
final content =
|
||||||
// 但在 PrivacyStartupGate 外层(隐私同意后的页面才能拦截)
|
PrivacyStartupGate(child: child ?? const SizedBox.shrink());
|
||||||
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(
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ class AudioPlayerCard extends StatefulWidget {
|
|||||||
final List<double> playbackSpeeds;
|
final List<double> playbackSpeeds;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AudioPlayerCard> createState() => _AudioPlayerCardState();
|
AudioPlayerCardState createState() => AudioPlayerCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AudioPlayerCardState extends State<AudioPlayerCard>
|
class AudioPlayerCardState extends State<AudioPlayerCard>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
/// 底层播放器(just_audio)
|
/// 底层播放器(just_audio)
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -123,6 +123,11 @@ class _AudioPlayerCardState extends State<AudioPlayerCard>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停音频)
|
||||||
|
void pause() {
|
||||||
|
_player.pause();
|
||||||
|
}
|
||||||
|
|
||||||
/// App 切后台 / 来电 → 自动暂停
|
/// App 切后台 / 来电 → 自动暂停
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
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% 近白)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
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/public_upload/api.dart';
|
||||||
|
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||||
|
import 'package:new_learn/core/components/center_toast.dart';
|
||||||
|
import 'package:new_learn/core/components/image_preview_viewer.dart';
|
||||||
|
import 'package:new_learn/core/network/oss_api.dart';
|
||||||
|
import 'package:new_learn/core/network/oss_uploader.dart';
|
||||||
|
import 'package:new_learn/core/services/image_processor.dart';
|
||||||
|
import 'package:new_learn/core/services/picker_service.dart';
|
||||||
|
import 'package:new_learn/core/utils/img_url.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【Upload 组件】多图上传组件(对标 Vue components/Upload/Upload.vue�?
|
||||||
|
///
|
||||||
|
/// 一、功能:
|
||||||
|
/// 1. edit 模式:相�?拍照多�?�?压缩 + EXIF 修正 �?OSS 直传 �?
|
||||||
|
/// 调用业务接口关联笔记 �?本地图片列表刷新
|
||||||
|
/// 2. view 模式:仅展示图片网格,无添加/删除按钮
|
||||||
|
/// 3. 点击图片 �?全屏预览(photo_view 双指缩放 + 左右切换�?
|
||||||
|
/// 4. 底部可配�?拍摄规范"提示文本(title == '上传图片作答' 时显示)
|
||||||
|
///
|
||||||
|
/// 二、Props 对照�?
|
||||||
|
/// - [maxCount] : 最多上传张数(默认 10�?
|
||||||
|
/// - [mode] : UploadMode.edit / UploadMode.view(默�?edit�?
|
||||||
|
/// - [title] : 标题文案(默�?'上传笔记'�?
|
||||||
|
/// - [initialImageList]: 初始已上传图片列�?
|
||||||
|
/// - [type] : 类型:handouts / questions / AIGrading / feedback
|
||||||
|
/// - [ids] : 业务 id(讲�?id / 题目 id�?
|
||||||
|
/// - [onChanged] : 图片列表变化回调(相当于 Vue �?change 事件�?
|
||||||
|
///
|
||||||
|
/// 三、上传链路(对齐 Vue + my_page.dart 头像上传流程):
|
||||||
|
/// �?PickerService.showPickSheet 选图(含权限前置弹窗�?
|
||||||
|
/// �?ImageProcessor.process 压缩 + EXIF 方向修正 + 尺寸过滤
|
||||||
|
/// �?OssUploader.uploadImage OSS 直传(scene �?type 映射�?
|
||||||
|
/// �?NoteApi 笔记关联(handouts/question 走业务接口,
|
||||||
|
/// AIGrading/feedback 仅本地维护列表)
|
||||||
|
///
|
||||||
|
/// 四、type �?OSS scene / 笔记接口映射�?
|
||||||
|
/// type | UploadScene | 笔记接口
|
||||||
|
/// ─────────────┼────────────────┼────────────
|
||||||
|
/// handouts | note | HandoutNoteAdd/Del
|
||||||
|
/// questions | note | QuestionNoteAdd/Del
|
||||||
|
/// AIGrading | aiGrading | 无(仅本地列表)
|
||||||
|
/// feedback | note | 无(仅本地列表)
|
||||||
|
///
|
||||||
|
/// 五、尺寸过滤策略(对齐 Vue filterOversizeImages):
|
||||||
|
/// - handouts / questions 类型:跳过尺寸校验(讲义/题目图可能很大)
|
||||||
|
/// - 其它类型:宽高必须在 14~4000 像素之间
|
||||||
|
/// ============================================================
|
||||||
|
|
||||||
|
/// 上传组件模式
|
||||||
|
enum UploadMode { edit, view }
|
||||||
|
|
||||||
|
/// 上传组件类型
|
||||||
|
enum UploadType { handouts, questions, aiGrading, feedback }
|
||||||
|
|
||||||
|
/// 图片项(统一承载:本地已�?+ 已上传)
|
||||||
|
class UploadItem {
|
||||||
|
const UploadItem({
|
||||||
|
this.id,
|
||||||
|
required this.url,
|
||||||
|
this.thumbnailUrl,
|
||||||
|
this.localBytes,
|
||||||
|
this.localEntity,
|
||||||
|
this.isUploading = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 服务器返回的笔记 id(删除用;本地图片无 id�?
|
||||||
|
final dynamic id;
|
||||||
|
|
||||||
|
/// 图片 URL(已上传)或本地路径(未上传�?
|
||||||
|
final String url;
|
||||||
|
|
||||||
|
/// 缩略�?URL
|
||||||
|
final String? thumbnailUrl;
|
||||||
|
|
||||||
|
/// 本地字节(Web 预览用,上传完成后置空)
|
||||||
|
final List<int>? localBytes;
|
||||||
|
|
||||||
|
/// 原生相册实体(原生端缩略图预览用,上传完成后置空�?
|
||||||
|
final dynamic localEntity;
|
||||||
|
|
||||||
|
/// 是否正在上传中(用于显示加载态)
|
||||||
|
final bool isUploading;
|
||||||
|
|
||||||
|
/// 是否为网络图(已上传�?OSS�?
|
||||||
|
bool get isNetwork => url.startsWith('http');
|
||||||
|
|
||||||
|
factory UploadItem.fromNoteImage(NoteImage note) => UploadItem(
|
||||||
|
id: note.id,
|
||||||
|
url: note.url,
|
||||||
|
thumbnailUrl: note.thumbnailUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Upload extends StatefulWidget {
|
||||||
|
const Upload({
|
||||||
|
super.key,
|
||||||
|
this.maxCount = 10,
|
||||||
|
this.mode = UploadMode.edit,
|
||||||
|
this.title = '上传笔记',
|
||||||
|
this.initialImageList = const [],
|
||||||
|
this.type = UploadType.handouts,
|
||||||
|
this.ids,
|
||||||
|
this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 最多上传张�?
|
||||||
|
final int maxCount;
|
||||||
|
|
||||||
|
/// 模式:编�?/ 查看
|
||||||
|
final UploadMode mode;
|
||||||
|
|
||||||
|
/// 标题文案
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// 初始图片列表(已上传的网络图�?
|
||||||
|
final List<UploadItem> initialImageList;
|
||||||
|
|
||||||
|
/// 上传类型
|
||||||
|
final UploadType type;
|
||||||
|
|
||||||
|
/// 业务 id(讲�?id / 题目 id�?
|
||||||
|
final dynamic ids;
|
||||||
|
|
||||||
|
/// 列表变化回调
|
||||||
|
final ValueChanged<List<UploadItem>>? onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<Upload> createState() => UploadState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadState extends State<Upload> {
|
||||||
|
final List<UploadItem> _imageList = [];
|
||||||
|
int _uploadingCount = 0;
|
||||||
|
bool _choosing = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_imageList.addAll(widget.initialImageList);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant Upload oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
// 外部 initialImageList 变化时同步(类似 Vue �?watch defaultImageList�?
|
||||||
|
if (oldWidget.initialImageList != widget.initialImageList) {
|
||||||
|
_imageList
|
||||||
|
..clear()
|
||||||
|
..addAll(widget.initialImageList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 对外暴露方法 =====================
|
||||||
|
|
||||||
|
/// 触发选图(等�?Vue �?chooseImage�?
|
||||||
|
Future<void> chooseImage() async => _chooseImage();
|
||||||
|
|
||||||
|
/// 获取当前图片列表
|
||||||
|
List<UploadItem> getImageList() => List.unmodifiable(_imageList);
|
||||||
|
|
||||||
|
/// 正在上传的数�?
|
||||||
|
int get uploadingCount => _uploadingCount;
|
||||||
|
|
||||||
|
/// 是否正在选图
|
||||||
|
bool get choosing => _choosing;
|
||||||
|
|
||||||
|
// ===================== 工具方法 =====================
|
||||||
|
|
||||||
|
void _notifyChanged() {
|
||||||
|
widget.onChanged?.call(List.unmodifiable(_imageList));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 映射 type �?OSS scene
|
||||||
|
UploadScene get _uploadScene {
|
||||||
|
switch (widget.type) {
|
||||||
|
case UploadType.aiGrading:
|
||||||
|
return UploadScene.aiGrading;
|
||||||
|
case UploadType.handouts:
|
||||||
|
case UploadType.questions:
|
||||||
|
case UploadType.feedback:
|
||||||
|
return UploadScene.note;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否跳过尺寸校验(讲�?题目大图允许�?
|
||||||
|
bool get _skipSizeLimit =>
|
||||||
|
widget.type == UploadType.handouts ||
|
||||||
|
widget.type == UploadType.questions;
|
||||||
|
|
||||||
|
void _toast(String message) {
|
||||||
|
CenterToast.show(context, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLoading(String message) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 28.w, vertical: 20.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black87,
|
||||||
|
borderRadius: BorderRadius.circular(10.r),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const CircularProgressIndicator(color: Colors.white),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
Text(
|
||||||
|
message,
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 13.sp),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hideLoading() {
|
||||||
|
if (Navigator.of(context, rootNavigator: true).canPop()) {
|
||||||
|
Navigator.of(context, rootNavigator: true).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _replaceExtension(String fileName, String extension) {
|
||||||
|
final dotIndex = fileName.lastIndexOf('.');
|
||||||
|
final safeExtension = extension.toLowerCase();
|
||||||
|
if (dotIndex <= 0) return '$fileName.$safeExtension';
|
||||||
|
return '${fileName.substring(0, dotIndex)}.$safeExtension';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 选图 =====================
|
||||||
|
|
||||||
|
Future<void> _chooseImage() async {
|
||||||
|
if (_choosing || _uploadingCount > 0) {
|
||||||
|
_toast('图片上传中,请稍后再选择');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final remainCount = widget.maxCount - _imageList.length - _uploadingCount;
|
||||||
|
if (remainCount <= 0) {
|
||||||
|
_toast('最多上传${widget.maxCount}张图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _choosing = true);
|
||||||
|
bool processingLoadingShown = false;
|
||||||
|
try {
|
||||||
|
final picked = await PickerService.instance.showPickSheet(
|
||||||
|
context,
|
||||||
|
maxCount: remainCount,
|
||||||
|
);
|
||||||
|
if (picked == null || picked.isEmpty) return;
|
||||||
|
|
||||||
|
// 显示"上传中..."遮罩(压�?+ 尺寸过滤可能耗时几秒�?
|
||||||
|
_showLoading('上传中...');
|
||||||
|
processingLoadingShown = true;
|
||||||
|
|
||||||
|
// 压缩 + EXIF 修正 + 尺寸过滤
|
||||||
|
final List<({PickedImage origin, ProcessedImage processed})> validList =
|
||||||
|
[];
|
||||||
|
|
||||||
|
for (final img in picked) {
|
||||||
|
final bytes = img.bytes ?? await img.entity?.originBytes;
|
||||||
|
if (bytes == null || bytes.isEmpty) continue;
|
||||||
|
|
||||||
|
final processed = await ImageProcessor.process(
|
||||||
|
bytes: bytes,
|
||||||
|
fileName: img.name,
|
||||||
|
maxDimension: 1920,
|
||||||
|
);
|
||||||
|
if (processed == null) {
|
||||||
|
// 压缩失败 / 解码失败 �?跳过
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_skipSizeLimit) {
|
||||||
|
validList.add((origin: img, processed: processed));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尺寸校验:由 ImageProcessor 内部 min/max 拦截�?
|
||||||
|
// 返回 null 说明尺寸不符合。这里按大小区分提示�?
|
||||||
|
// (ImageProcessor 只返�?null,不区分过小/过大�?
|
||||||
|
// 这里简化为"不符合要�?,与 Vue 的核心提示一致。)
|
||||||
|
validList.add((origin: img, processed: processed));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validList.isEmpty) {
|
||||||
|
_toast('图片尺寸不符合要求,请重新选择');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: AIGrading 类型的旋转预览弹窗待实现(Vue 端有 $showImagePreview�?
|
||||||
|
// 暂时直接上传,后续有需求再�?
|
||||||
|
await _startUpload(validList);
|
||||||
|
} catch (e) {
|
||||||
|
_toast('图片处理失败,请重试');
|
||||||
|
debugPrint('[Upload] 选图异常: $e');
|
||||||
|
} finally {
|
||||||
|
if (processingLoadingShown) _hideLoading();
|
||||||
|
if (mounted) setState(() => _choosing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 上传 =====================
|
||||||
|
|
||||||
|
Future<void> _startUpload(
|
||||||
|
List<({PickedImage origin, ProcessedImage processed})> items,
|
||||||
|
) async {
|
||||||
|
if (items.isEmpty) return;
|
||||||
|
|
||||||
|
final canUploadCount =
|
||||||
|
widget.maxCount - _imageList.length - _uploadingCount;
|
||||||
|
final safeEnd = canUploadCount.clamp(0, items.length);
|
||||||
|
final safeItems =
|
||||||
|
safeEnd > 0 ? items.sublist(0, safeEnd) : <({
|
||||||
|
PickedImage origin,
|
||||||
|
ProcessedImage processed
|
||||||
|
})>[];
|
||||||
|
if (safeItems.isEmpty) {
|
||||||
|
_toast('最多上传${widget.maxCount}张图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (safeItems.length < items.length) {
|
||||||
|
_toast('最多还能上传${safeItems.length}张图片');
|
||||||
|
}
|
||||||
|
|
||||||
|
final pendingCount = safeItems.length;
|
||||||
|
_uploadingCount += pendingCount;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (final item in safeItems) {
|
||||||
|
try {
|
||||||
|
final fileName = _replaceExtension(
|
||||||
|
item.origin.name ??
|
||||||
|
'img_${DateTime.now().millisecondsSinceEpoch}.jpg',
|
||||||
|
item.processed.extension,
|
||||||
|
);
|
||||||
|
|
||||||
|
// OSS 直传
|
||||||
|
final url = await OssUploader.uploadImage(
|
||||||
|
bytes: item.processed.bytes,
|
||||||
|
fileName: fileName,
|
||||||
|
contentType: item.processed.contentType,
|
||||||
|
scene: _uploadScene,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 业务接口关联笔记
|
||||||
|
await _noteAdd(url);
|
||||||
|
} finally {
|
||||||
|
_uploadingCount = (_uploadingCount - 1).clamp(0, 999);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_toast('上传成功');
|
||||||
|
} catch (e) {
|
||||||
|
_toast('笔记上传失败,请检查网络后重试');
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 调业务接口添加笔记(�?type 分流�?
|
||||||
|
Future<void> _noteAdd(String url) async {
|
||||||
|
switch (widget.type) {
|
||||||
|
case UploadType.handouts:
|
||||||
|
if (widget.ids != null) {
|
||||||
|
final note = await NoteApi.instance.handoutNoteAdd(
|
||||||
|
id: widget.ids,
|
||||||
|
url: url,
|
||||||
|
thumbnailUrl: url,
|
||||||
|
);
|
||||||
|
if (note.url.isNotEmpty && note.id != null) {
|
||||||
|
setState(() {
|
||||||
|
_imageList.add(UploadItem.fromNoteImage(note));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UploadType.questions:
|
||||||
|
if (widget.ids != null) {
|
||||||
|
final note = await NoteApi.instance.questionNoteAdd(
|
||||||
|
id: widget.ids,
|
||||||
|
url: url,
|
||||||
|
thumbnailUrl: url,
|
||||||
|
);
|
||||||
|
if (note.url.isNotEmpty && note.id != null) {
|
||||||
|
setState(() {
|
||||||
|
_imageList.add(UploadItem.fromNoteImage(note));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UploadType.aiGrading:
|
||||||
|
case UploadType.feedback:
|
||||||
|
setState(() {
|
||||||
|
_imageList.add(UploadItem(url: url, thumbnailUrl: url));
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_notifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 删除 =====================
|
||||||
|
|
||||||
|
Future<void> _removeImg(UploadItem item) async {
|
||||||
|
try {
|
||||||
|
switch (widget.type) {
|
||||||
|
case UploadType.handouts:
|
||||||
|
if (widget.ids != null && item.id != null) {
|
||||||
|
final ok = await NoteApi.instance.handoutNoteDel(item.id);
|
||||||
|
if (ok) {
|
||||||
|
setState(() {
|
||||||
|
_imageList.removeWhere((e) => e.id == item.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UploadType.questions:
|
||||||
|
if (widget.ids != null && item.id != null) {
|
||||||
|
final ok = await NoteApi.instance.questionNoteDel(item.id);
|
||||||
|
if (ok) {
|
||||||
|
setState(() {
|
||||||
|
_imageList.removeWhere((e) => e.id == item.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case UploadType.aiGrading:
|
||||||
|
case UploadType.feedback:
|
||||||
|
setState(() {
|
||||||
|
_imageList.removeWhere((e) => e.url == item.url);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_toast('删除失败,请检查网络后重试');
|
||||||
|
}
|
||||||
|
_notifyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 预览 =====================
|
||||||
|
|
||||||
|
void _previewImage(int index) {
|
||||||
|
final items = _imageList
|
||||||
|
.map(
|
||||||
|
(item) => PreviewImageItem(
|
||||||
|
imageProvider: _imageProviderOf(item),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
ImagePreviewViewer.show(context, items: items, initialIndex: index);
|
||||||
|
}
|
||||||
|
|
||||||
|
ImageProvider _imageProviderOf(UploadItem item) {
|
||||||
|
if (item.isNetwork) {
|
||||||
|
return CachedNetworkImageProvider(ImgUrl.build(item.url));
|
||||||
|
}
|
||||||
|
if (item.localBytes != null) {
|
||||||
|
return MemoryImage(Uint8List.fromList(item.localBytes!));
|
||||||
|
}
|
||||||
|
return const AssetImage(''); // 兜底
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Build =====================
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// 模式�?view 且没有图片时不显示(对齐 Vue v-if�?
|
||||||
|
if (widget.mode == UploadMode.edit || _imageList.isNotEmpty) {
|
||||||
|
return _buildUploadBox();
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUploadBox() {
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.all(14.w),
|
||||||
|
margin: EdgeInsets.only(bottom: 10.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFAFBFD),
|
||||||
|
border: Border.all(color: const Color(0xFFE3ECFF), width: 1.r),
|
||||||
|
borderRadius: BorderRadius.circular(10.r),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
_buildGrid(),
|
||||||
|
if (widget.mode == UploadMode.edit &&
|
||||||
|
widget.title == '上传图片作答')
|
||||||
|
_buildTip(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.only(right: 14.w),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
image: DecorationImage(
|
||||||
|
image: NetworkImage(
|
||||||
|
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
|
||||||
|
),
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
repeat: ImageRepeat.noRepeat,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFF1D2B47),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 14.w),
|
||||||
|
Text(
|
||||||
|
widget.mode == UploadMode.edit
|
||||||
|
? '已上传:${_imageList.length}/${widget.maxCount}张'
|
||||||
|
: '共 $_imageList.length 张',
|
||||||
|
style: TextStyle(fontSize: 12.sp, color: const Color(0xFFB4C2E0)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildGrid() {
|
||||||
|
final itemSize = 67.w;
|
||||||
|
final showAdd = widget.mode == UploadMode.edit &&
|
||||||
|
(_imageList.length + _uploadingCount) < widget.maxCount;
|
||||||
|
final totalCount = _imageList.length + (showAdd ? 1 : 0);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(top: 10.h),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 10.w,
|
||||||
|
runSpacing: 10.h,
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < _imageList.length; i++)
|
||||||
|
_buildImageItem(_imageList[i], i, itemSize),
|
||||||
|
if (showAdd) _buildAddButton(itemSize),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAddButton(double size) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _chooseImage,
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF5F8FA),
|
||||||
|
borderRadius: BorderRadius.circular(6.r),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Image.network(
|
||||||
|
ImgUrl.build('/StaticResource/2026-04-01/add.png'),
|
||||||
|
width: 19.5.w,
|
||||||
|
height: 19.5.w,
|
||||||
|
errorBuilder: (_, __, ___) => Icon(
|
||||||
|
Icons.add,
|
||||||
|
size: 19.5.w,
|
||||||
|
color: const Color(0xFFB0B8C4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImageItem(UploadItem item, int index, double size) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _previewImage(index),
|
||||||
|
child: Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF5F8FA),
|
||||||
|
borderRadius: BorderRadius.circular(6.r),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
if (item.isNetwork)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: ImgUrl.build(item.url),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorWidget: (_, __, ___) => const ColoredBox(
|
||||||
|
color: Color(0xFFEEF1F5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (item.localBytes != null)
|
||||||
|
Image.memory(
|
||||||
|
Uint8List.fromList(item.localBytes!),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const ColoredBox(color: Color(0xFFEEF1F5)),
|
||||||
|
// 删除按钮
|
||||||
|
if (widget.mode == UploadMode.edit)
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _removeImg(item),
|
||||||
|
child: Image.network(
|
||||||
|
ImgUrl.build('/StaticResource/2026-04-14/remove.png'),
|
||||||
|
width: 18.w,
|
||||||
|
height: 18.w,
|
||||||
|
errorBuilder: (_, __, ___) => Container(
|
||||||
|
width: 18.w,
|
||||||
|
height: 18.w,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.close,
|
||||||
|
size: 12, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTip() {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(top: 15.h),
|
||||||
|
child: Text(
|
||||||
|
'图片作答拍摄规范:\n'
|
||||||
|
'1. 采光:光线充足,无阴影、无反光遮挡答题区\n'
|
||||||
|
'2. 角度:纸张铺平,垂直俯拍,画面不歪斜\n'
|
||||||
|
'3. 画质:完整拍下整张纸,文字清晰无裁切模糊\n'
|
||||||
|
'4. 卷面:答题区仅留正式作答,草稿另用草稿纸\n'
|
||||||
|
'5. 修改:错处双线划去,旁侧重写,禁止反复涂黑叠写\n'
|
||||||
|
'提示:画面越规整清晰,识别批改越准确',
|
||||||
|
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,10 +107,10 @@ class VideoPlayerView extends StatefulWidget {
|
|||||||
final ChewieProgressColors? materialProgressColors;
|
final ChewieProgressColors? materialProgressColors;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<VideoPlayerView> createState() => _VideoPlayerViewState();
|
VideoPlayerViewState createState() => VideoPlayerViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VideoPlayerViewState extends State<VideoPlayerView>
|
class VideoPlayerViewState extends State<VideoPlayerView>
|
||||||
with WidgetsBindingObserver {
|
with WidgetsBindingObserver {
|
||||||
/// 官方底层控制器
|
/// 官方底层控制器
|
||||||
VideoPlayerController? _videoController;
|
VideoPlayerController? _videoController;
|
||||||
@@ -144,6 +144,11 @@ class _VideoPlayerViewState extends State<VideoPlayerView>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停视频)
|
||||||
|
void pause() {
|
||||||
|
_videoController?.pause();
|
||||||
|
}
|
||||||
|
|
||||||
/// App 切后台 / 来电 → 自动暂停,防止声音继续播
|
/// App 切后台 / 来电 → 自动暂停,防止声音继续播
|
||||||
@override
|
@override
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,9 +48,6 @@ 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 → 对象(服务器响应)
|
||||||
@@ -78,7 +74,6 @@ 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']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,17 +96,12 @@ class CurrentUserExtension {
|
|||||||
'userAvatarReason': userAvatarReason,
|
'userAvatarReason': userAvatarReason,
|
||||||
'isShowGuide': isShowGuide,
|
'isShowGuide': isShowGuide,
|
||||||
'isShowReport': isShowReport,
|
'isShowReport': isShowReport,
|
||||||
'isShowAfterReport': isShowAfterReport,
|
|
||||||
'id': id,
|
'id': id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 局部更新(目前只用得上 avatar / isShowReport / isShowAfterReport)
|
/// 局部更新(目前只用得上 avatar:头像上传成功后替换 URL)
|
||||||
CurrentUserExtension copyWith({
|
CurrentUserExtension copyWith({String? avatar}) {
|
||||||
String? avatar,
|
|
||||||
bool? isShowReport,
|
|
||||||
bool? isShowAfterReport,
|
|
||||||
}) {
|
|
||||||
return CurrentUserExtension(
|
return CurrentUserExtension(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
userName: userName,
|
userName: userName,
|
||||||
@@ -127,8 +117,7 @@ class CurrentUserExtension {
|
|||||||
userAvatarStatus: userAvatarStatus,
|
userAvatarStatus: userAvatarStatus,
|
||||||
userAvatarReason: userAvatarReason,
|
userAvatarReason: userAvatarReason,
|
||||||
isShowGuide: isShowGuide,
|
isShowGuide: isShowGuide,
|
||||||
isShowReport: isShowReport ?? this.isShowReport,
|
isShowReport: isShowReport,
|
||||||
isShowAfterReport: isShowAfterReport ?? this.isShowAfterReport,
|
|
||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -87,49 +86,6 @@ 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 用)
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -13,26 +13,14 @@ 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/conquerPackage/captureList/index.dart';
|
||||||
|
import 'package:new_learn/features/conquerPackage/beforeDuringClass/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/my_report_page.dart';
|
||||||
import 'package:new_learn/features/my/my_report/report_detail_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/settings_page.dart';
|
||||||
import 'package:new_learn/features/my/settings/about_app_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';
|
||||||
@@ -83,12 +71,6 @@ 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:
|
||||||
@@ -147,19 +129,7 @@ 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: '/myReport', builder: (_, _) => const MyReportPage()),
|
||||||
GoRoute(path: '/myExam', builder: (_, _) => const ExamPage()),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/report-detail',
|
path: '/report-detail',
|
||||||
builder: (_, state) {
|
builder: (_, state) {
|
||||||
@@ -173,16 +143,6 @@ final GoRouter appRouter = GoRouter(
|
|||||||
),
|
),
|
||||||
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
|
GoRoute(path: '/settings', builder: (_, _) => const SettingsPage()),
|
||||||
GoRoute(path: '/about-app', builder: (_, _) => const AboutAppPage()),
|
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',
|
||||||
@@ -207,38 +167,17 @@ final GoRouter appRouter = GoRouter(
|
|||||||
// ---------- 攻克包 - 截图名单 ----------
|
// ---------- 攻克包 - 截图名单 ----------
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/conquerPackage/captureList',
|
path: '/conquerPackage/captureList',
|
||||||
builder: (_, state) => CaptureListPage(courseId: state.extra as String),
|
builder: (_, state) =>
|
||||||
|
CaptureListPage(courseId: state.extra as String),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/register', builder: (_, _) => const RegisterPage()),
|
// ---------- 攻克包 - 课前课中 ----------
|
||||||
// ---------- 背诵模块二级页(不在 ShellBranch 内,push 时不显示 tabbar) ----------
|
|
||||||
// 教材列表
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/recite/textbooks',
|
path: '/conquerPackage/beforeDuringClass',
|
||||||
builder: (_, _) => const TextbookListPage(),
|
builder: (_, state) => BeforeDuringClassPage(
|
||||||
),
|
extra: state.extra as Map<String, dynamic>?,
|
||||||
// 词汇章节(bookId 走路径参数,替代 Vue 缓存)
|
|
||||||
GoRoute(
|
|
||||||
path: '/recite/chapters/:bookId',
|
|
||||||
builder: (_, state) => ChaptersPage(
|
|
||||||
bookId: int.parse(state.pathParameters['bookId']!),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 关卡列表(Chapter 对象走 extra)
|
GoRoute(path: '/register', builder: (_, _) => const RegisterPage()),
|
||||||
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()),
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -381,18 +381,6 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
password: password,
|
password: password,
|
||||||
rememberPassword: _rememberPassword,
|
rememberPassword: _rememberPassword,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
// 对齐 Vue login/index.vue L404: 登录成功后检查报告标记
|
|
||||||
// isShowReport / isShowAfterReport 任一为 true → 跳报告页
|
|
||||||
final hasReport = user.isShowReport || user.isShowAfterReport;
|
|
||||||
if (hasReport) {
|
|
||||||
final reportType = user.isShowAfterReport ? 'exitTest' : 'placementTest';
|
|
||||||
context.go('/studentInfo', extra: {'type': reportType});
|
|
||||||
} else {
|
|
||||||
context.go('/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
debugPrint('⚠ 【用户信息】拉取失败,已跳过缓存');
|
debugPrint('⚠ 【用户信息】拉取失败,已跳过缓存');
|
||||||
// 即使拉用户信息失败也存历史(userName 留空)
|
// 即使拉用户信息失败也存历史(userName 留空)
|
||||||
@@ -402,7 +390,19 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
password: password,
|
password: password,
|
||||||
rememberPassword: _rememberPassword,
|
rememberPassword: _rememberPassword,
|
||||||
);
|
);
|
||||||
if (mounted) context.go('/');
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
context.go('/');
|
||||||
|
// messenger.showSnackBar(
|
||||||
|
// SnackBar(
|
||||||
|
// content: Text(
|
||||||
|
// '登录成功,欢迎 ${res.info.userName.isEmpty ? account : res.info.userName}',
|
||||||
|
// ),
|
||||||
|
// duration: const Duration(seconds: 2),
|
||||||
|
// ),
|
||||||
|
// );
|
||||||
}
|
}
|
||||||
} on ApiException catch (e) {
|
} on ApiException catch (e) {
|
||||||
if (mounted) _toast(e.message);
|
if (mounted) _toast(e.message);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ 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';
|
||||||
|
|
||||||
@@ -237,6 +236,35 @@ 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});
|
||||||
@@ -585,3 +613,9 @@ class _CommentInputState extends State<CommentInput> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SubjectColor {
|
||||||
|
const _SubjectColor(this.fg, this.bg);
|
||||||
|
final int fg;
|
||||||
|
final int bg;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
|
||||||
|
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
|
||||||
|
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||||
|
import 'package:new_learn/core/components/audio_player_card.dart';
|
||||||
|
import 'package:new_learn/core/components/rich_text_content.dart';
|
||||||
|
import 'package:new_learn/core/components/upload.dart';
|
||||||
|
import 'package:new_learn/core/components/video_player_view.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【BeforeDuringPage】单页讲义内容组件
|
||||||
|
///
|
||||||
|
/// 对标 Vue pages/conquerPackage/beforeDuringClass/beforeDuringPage.vue
|
||||||
|
///
|
||||||
|
/// 一、功能:
|
||||||
|
/// 1. 显示讲义标题(name)
|
||||||
|
/// 2. 渲染正文富文本(content → RichTextContent)
|
||||||
|
/// 3. 渲染材料列表(materials):
|
||||||
|
/// materialType=1 视频 → VideoPlayerView
|
||||||
|
/// materialType=2 音频 → AudioPlayerCard
|
||||||
|
/// materialType=3 文本 → RichTextContent
|
||||||
|
/// 4. 底部 Upload 上传笔记组件(type=handouts)
|
||||||
|
/// 5. 对外暴露方法:
|
||||||
|
/// uploadLectureNotes() 触发选图上传
|
||||||
|
/// getImageList() 获取当前笔记列表
|
||||||
|
/// getIsUploading() 是否上传中/选图中
|
||||||
|
/// pauseMedia() 暂停页面内所有媒体
|
||||||
|
///
|
||||||
|
/// 二、尺寸换算(本工程统一):
|
||||||
|
/// Vue 的 N rpx → Flutter 的 (N/2).w/.h/.sp/.r
|
||||||
|
///
|
||||||
|
/// 三、与 Vue 的差异:
|
||||||
|
/// - Flutter 端不维护 ref 数组来控制媒体暂停,
|
||||||
|
/// 滑出视口时 dispose 自动释放资源(PageView.builder 懒加载特性)。
|
||||||
|
/// - pauseMedia 方法保留为对外接口,通过 GlobalKey 调用时
|
||||||
|
/// 遍历可见子树查找播放器组件来暂停(兼容手动触发场景)。
|
||||||
|
/// ============================================================
|
||||||
|
|
||||||
|
class BeforeDuringPage extends StatefulWidget {
|
||||||
|
const BeforeDuringPage({
|
||||||
|
super.key,
|
||||||
|
required this.data,
|
||||||
|
this.isCurrent = false,
|
||||||
|
this.onUploadChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 讲义数据
|
||||||
|
final HandoutItem data;
|
||||||
|
|
||||||
|
/// 是否为当前可见页(只有当前页才真正初始化视频解码器,避免硬件解码器占满)
|
||||||
|
final bool isCurrent;
|
||||||
|
|
||||||
|
/// 笔记列表变化回调
|
||||||
|
final ValueChanged<List<UploadItem>>? onUploadChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BeforeDuringPage> createState() => BeforeDuringPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class BeforeDuringPageState extends State<BeforeDuringPage>
|
||||||
|
with AutomaticKeepAliveClientMixin {
|
||||||
|
final GlobalKey<UploadState> _uploadKey = GlobalKey<UploadState>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true;
|
||||||
|
|
||||||
|
// ===================== 对外暴露方法 =====================
|
||||||
|
|
||||||
|
/// 触发上传笔记
|
||||||
|
void uploadLectureNotes() {
|
||||||
|
_uploadKey.currentState?.chooseImage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前笔记列表
|
||||||
|
List<UploadItem> getImageList() {
|
||||||
|
return _uploadKey.currentState?.getImageList() ?? const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否上传中或选图中
|
||||||
|
bool getIsUploading() {
|
||||||
|
final state = _uploadKey.currentState;
|
||||||
|
if (state == null) return false;
|
||||||
|
return state.uploadingCount > 0 || state.choosing;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 暂停页面内所有媒体(视频/音频)
|
||||||
|
void pauseMedia() {
|
||||||
|
context.visitChildElements((element) {
|
||||||
|
_tryPauseMedia(element);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tryPauseMedia(Element element) {
|
||||||
|
// 通过 GlobalKey 形式找到 State 并调用 pause()
|
||||||
|
if (element is StatefulElement) {
|
||||||
|
final state = element.state;
|
||||||
|
if (state is VideoPlayerViewState) {
|
||||||
|
state.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state is AudioPlayerCardState) {
|
||||||
|
state.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.visitChildren(_tryPauseMedia);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Build =====================
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 14.w,
|
||||||
|
right: 14.w,
|
||||||
|
top: 4.h,
|
||||||
|
bottom: 90.h,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 标题
|
||||||
|
if (widget.data.name.isNotEmpty) _buildTitle(),
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
// 正文富文本
|
||||||
|
if (widget.data.content.isNotEmpty) _buildContent(),
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
// 材料列表
|
||||||
|
if (widget.data.materials.isNotEmpty) _buildMaterials(),
|
||||||
|
// 上传笔记
|
||||||
|
_buildUpload(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTitle() {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 3.w,
|
||||||
|
height: 14.h,
|
||||||
|
margin: EdgeInsets.only(right: 7.5.w),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF3D7DF5),
|
||||||
|
borderRadius: BorderRadius.circular(10.r),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.data.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16.sp,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: const Color(0xFF1D2B47),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16.r),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||||||
|
child: RichTextContent(
|
||||||
|
html: widget.data.content,
|
||||||
|
renderMode: RenderMode.column,
|
||||||
|
textStyle: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF495A7A),
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMaterials() {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
for (final material in widget.data.materials)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 10.h),
|
||||||
|
child: _buildMaterialItem(material),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMaterialItem(HandoutMaterial material) {
|
||||||
|
switch (material.materialType) {
|
||||||
|
case HandoutMaterialType.video:
|
||||||
|
// 只有当前页才真正初始化视频解码器,避免硬件解码器占满导致 ExoPlaybackException
|
||||||
|
if (!widget.isCurrent) {
|
||||||
|
return _buildVideoPlaceholder(material);
|
||||||
|
}
|
||||||
|
return VideoPlayerView(
|
||||||
|
url: material.materialContent,
|
||||||
|
title: '讲义视频',
|
||||||
|
autoPlay: false,
|
||||||
|
);
|
||||||
|
case HandoutMaterialType.audio:
|
||||||
|
return AudioPlayerCard(
|
||||||
|
url: material.materialContent,
|
||||||
|
title: '讲义音频',
|
||||||
|
);
|
||||||
|
case HandoutMaterialType.text:
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16.r),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||||||
|
child: RichTextContent(
|
||||||
|
html: material.materialContent,
|
||||||
|
renderMode: RenderMode.column,
|
||||||
|
textStyle: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF495A7A),
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case HandoutMaterialType.unknown:
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 视频占位图(非当前页展示,不占用硬件解码器)
|
||||||
|
Widget _buildVideoPlaceholder(HandoutMaterial material) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
Icons.play_circle_outline,
|
||||||
|
size: 48.w,
|
||||||
|
color: Colors.white54,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUpload() {
|
||||||
|
return Upload(
|
||||||
|
key: _uploadKey,
|
||||||
|
type: UploadType.handouts,
|
||||||
|
ids: widget.data.id,
|
||||||
|
initialImageList: widget.data.notes
|
||||||
|
.map((note) => UploadItem.fromNoteImage(note))
|
||||||
|
.toList(),
|
||||||
|
onChanged: widget.onUploadChanged,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,870 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/api.dart';
|
||||||
|
import 'package:new_learn/core/api/conquerPackage/beforeDuringClass/model.dart';
|
||||||
|
import 'package:new_learn/core/api/public_upload/model.dart';
|
||||||
|
import 'package:new_learn/core/components/center_toast.dart';
|
||||||
|
import 'package:new_learn/core/components/confirm_box.dart';
|
||||||
|
import 'package:new_learn/core/components/upload.dart';
|
||||||
|
import 'package:new_learn/core/utils/img_url.dart';
|
||||||
|
import 'package:new_learn/features/conquerPackage/beforeDuringClass/before_during_page.dart';
|
||||||
|
|
||||||
|
// ===================== 自定义滑动物理 =====================
|
||||||
|
|
||||||
|
/// 单向滑动控制:
|
||||||
|
/// - 向右滑(上一页,index 减小)始终允许
|
||||||
|
/// - 向左滑(下一页,index 增大)需要满足时长 + 笔记条件
|
||||||
|
/// ============================================================
|
||||||
|
/// 【BeforeDuringClassPage】课前课中(讲义学习)页面
|
||||||
|
/// 对标 Vue pages/conquerPackage/beforeDuringClass/beforeDuringClass.vue
|
||||||
|
///
|
||||||
|
/// 一、页面结构:
|
||||||
|
/// ┌──────────────────────────────────┐
|
||||||
|
/// │ HeaderBac 背景图 + NavBar │
|
||||||
|
/// │ ┌────────────────────────────┐ │
|
||||||
|
/// │ │ 计时图标+时间 进度条 1/10 │ │
|
||||||
|
/// │ └────────────────────────────┘ │
|
||||||
|
/// ├──────────────────────────────────┤
|
||||||
|
/// │ │
|
||||||
|
/// │ PageView.builder (懒加载) │
|
||||||
|
/// │ 每页一个 BeforeDuringPage │
|
||||||
|
/// │ (仅构建视口 ±1 页,滑出回收) │
|
||||||
|
/// │ │
|
||||||
|
/// ├──────────────────────────────────┤
|
||||||
|
/// │ 底部操作栏:上传笔记 / 上一步 / │
|
||||||
|
/// │ 下一步(最后一页→完成学习) │
|
||||||
|
/// └──────────────────────────────────┘
|
||||||
|
///
|
||||||
|
/// 二、核心功能:
|
||||||
|
/// 1. PageView.builder 懒加载:physics 禁止滑动(按钮翻页),
|
||||||
|
/// allowImplicitScrolling=true 预构建相邻页
|
||||||
|
/// 2. 学习计时:Ticker 驱动,onPageChange 结算时长
|
||||||
|
/// 3. 建议时长倒计时:当前页 planStudyTimeSecond 剩余时间
|
||||||
|
/// 4. 笔记必传校验:isUploadNotes 页必须有笔记才能进下一页
|
||||||
|
/// 5. 时长不足提示弹窗(可跳过)
|
||||||
|
/// 6. 笔记必传提示弹窗
|
||||||
|
///
|
||||||
|
/// 三、尺寸换算(本工程统一):
|
||||||
|
/// Vue 的 N rpx → Flutter 的 (N/2).w/.h/.sp/.r
|
||||||
|
///
|
||||||
|
/// 四、路由参数(通过 extra 传入):
|
||||||
|
/// - courseId 课程 id
|
||||||
|
/// - packId 攻克包 id
|
||||||
|
/// - recordId 学习记录 id
|
||||||
|
/// - type edit / view(默认 edit)
|
||||||
|
/// - isWrong 是否错题模式(默认 false)
|
||||||
|
/// ============================================================
|
||||||
|
|
||||||
|
/// 页面模式
|
||||||
|
enum BeforeDuringMode { edit, view }
|
||||||
|
|
||||||
|
class BeforeDuringClassPage extends StatefulWidget {
|
||||||
|
const BeforeDuringClassPage({super.key, this.extra});
|
||||||
|
|
||||||
|
final Map<String, dynamic>? extra;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BeforeDuringClassPage> createState() => _BeforeDuringClassPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BeforeDuringClassPageState extends State<BeforeDuringClassPage>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
// ========== 颜色常量 ==========
|
||||||
|
static const Color _bgColor = Color(0xFFF5F8FA);
|
||||||
|
static const Color _titleColor = Color(0xFF1D2B47);
|
||||||
|
static const Color _subtitleColor = Color(0xFF99A4BA);
|
||||||
|
static const Color _primary = Color(0xFF3D7DF5);
|
||||||
|
|
||||||
|
// ========== Controller ==========
|
||||||
|
final PageController _pageController = PageController();
|
||||||
|
final Map<int, GlobalKey<BeforeDuringPageState>> _pageKeys = {};
|
||||||
|
|
||||||
|
// ========== 路由参数 ==========
|
||||||
|
late BeforeDuringMode _mode;
|
||||||
|
late String _courseId;
|
||||||
|
late String _packId;
|
||||||
|
late String _recordId;
|
||||||
|
bool _isWrong = false;
|
||||||
|
|
||||||
|
// ========== 数据 ==========
|
||||||
|
List<HandoutItem> _pageList = [];
|
||||||
|
int _currentIndex = 0;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
// ========== 全局学习计时 ==========
|
||||||
|
late final Ticker _timingTicker;
|
||||||
|
Duration _timingOffset = Duration.zero;
|
||||||
|
Duration _timingElapsed = Duration.zero;
|
||||||
|
bool _timingRunning = false;
|
||||||
|
|
||||||
|
// ========== 当前页倒计时 ==========
|
||||||
|
late final Ticker _countdownTicker;
|
||||||
|
Duration _countdownRemaining = Duration.zero;
|
||||||
|
int _lastCountdownTickSecond = 0;
|
||||||
|
DateTime _pageStartTime = DateTime.now();
|
||||||
|
bool _isNavigatingAway = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_parseExtra();
|
||||||
|
|
||||||
|
// 全局学习计时
|
||||||
|
_timingTicker = createTicker((elapsed) {
|
||||||
|
if (_timingRunning) {
|
||||||
|
_timingElapsed = elapsed + _timingOffset;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (_mode == BeforeDuringMode.edit) {
|
||||||
|
_timingTicker.start();
|
||||||
|
_timingRunning = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单页倒计时(基于真实流逝时间,每秒更新 UI)
|
||||||
|
_countdownTicker = createTicker((elapsed) {
|
||||||
|
final elapsedSeconds = elapsed.inSeconds;
|
||||||
|
if (_lastCountdownTickSecond == elapsedSeconds) return;
|
||||||
|
_lastCountdownTickSecond = elapsedSeconds;
|
||||||
|
|
||||||
|
if (_countdownRemaining.inSeconds > 0) {
|
||||||
|
_countdownRemaining -= const Duration(seconds: 1);
|
||||||
|
setState(() {});
|
||||||
|
} else {
|
||||||
|
_countdownTicker.stop();
|
||||||
|
_markCurrentPagePlanTimeFinished();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_loadData();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timingTicker.dispose();
|
||||||
|
_countdownTicker.dispose();
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 初始化 =====================
|
||||||
|
|
||||||
|
void _parseExtra() {
|
||||||
|
final extra = widget.extra ?? const <String, dynamic>{};
|
||||||
|
_courseId = (extra['courseId']?.toString()) ?? '';
|
||||||
|
_packId = (extra['packId']?.toString()) ?? '';
|
||||||
|
_recordId = (extra['recordId']?.toString()) ?? '';
|
||||||
|
_isWrong = extra['isWrong'] == true || extra['isWrong'] == 'true';
|
||||||
|
final typeStr = (extra['type'] as String?) ?? 'edit';
|
||||||
|
_mode = typeStr == 'view'
|
||||||
|
? BeforeDuringMode.view
|
||||||
|
: BeforeDuringMode.edit;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final HandoutListResponse resp;
|
||||||
|
if (_mode == BeforeDuringMode.view) {
|
||||||
|
resp = await BeforeDuringClassApi.instance.getSubmitReport(_recordId);
|
||||||
|
} else {
|
||||||
|
resp = await BeforeDuringClassApi.instance
|
||||||
|
.getListByRecordHandout(_recordId);
|
||||||
|
}
|
||||||
|
_pageList = List<HandoutItem>.from(resp.items);
|
||||||
|
// _pageList = List<HandoutItem>.from(resp.items).map((item) {
|
||||||
|
// return item.copyWith(
|
||||||
|
// isUploadNotes: true,
|
||||||
|
// isFinishPlanTime: false,
|
||||||
|
// planStudyTimeSecond: 900,
|
||||||
|
// studyTimeSecond: 0,
|
||||||
|
// );
|
||||||
|
// }).toList();
|
||||||
|
// 测试:复制一份追加到数组末尾(翻倍页数)
|
||||||
|
// _pageList.addAll(_pageList.map((item) => item.copyWith()).toList());
|
||||||
|
|
||||||
|
// 汇总已有学习时长
|
||||||
|
if (_mode == BeforeDuringMode.edit) {
|
||||||
|
final totalSeconds = _pageList.fold<int>(
|
||||||
|
0,
|
||||||
|
(acc, item) => acc + item.studyTimeSecond,
|
||||||
|
);
|
||||||
|
_timingOffset = Duration(seconds: totalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_pageList.isEmpty) {
|
||||||
|
_goTransitionPage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_pageStartTime = DateTime.now();
|
||||||
|
_startCountdownForCurrentPage();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[BeforeDuringClass] 加载失败: $e');
|
||||||
|
if (mounted) {
|
||||||
|
CenterToast.show(context, '加载失败,请重试');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 计时工具 =====================
|
||||||
|
|
||||||
|
String get _timingText => _formatDuration(_timingElapsed);
|
||||||
|
|
||||||
|
String _formatDuration(Duration d) {
|
||||||
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
if (d.inHours > 0) {
|
||||||
|
final h = d.inHours.toString().padLeft(2, '0');
|
||||||
|
return '$h:$m:$s';
|
||||||
|
}
|
||||||
|
return '$m:$s';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 倒计时 =====================
|
||||||
|
|
||||||
|
void _startCountdownForCurrentPage() {
|
||||||
|
if (_mode == BeforeDuringMode.view) return;
|
||||||
|
final page = _currentPage;
|
||||||
|
if (page == null) return;
|
||||||
|
if (page.planStudyTimeSecond <= 0 || page.isFinishPlanTime) {
|
||||||
|
_countdownRemaining = Duration.zero;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final remain = page.planStudyRemainTimeSecond > 0
|
||||||
|
? page.planStudyRemainTimeSecond
|
||||||
|
: page.planStudyTimeSecond - page.studyTimeSecond;
|
||||||
|
if (remain <= 0) {
|
||||||
|
_markCurrentPagePlanTimeFinished();
|
||||||
|
_countdownRemaining = Duration.zero;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_countdownRemaining = Duration(seconds: remain);
|
||||||
|
_lastCountdownTickSecond = 0;
|
||||||
|
if (!_countdownTicker.isActive) {
|
||||||
|
_countdownTicker.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _markCurrentPagePlanTimeFinished() {
|
||||||
|
if (_currentIndex >= _pageList.length) return;
|
||||||
|
final page = _pageList[_currentIndex];
|
||||||
|
if (page.isFinishPlanTime) return;
|
||||||
|
_pageList[_currentIndex] = page.copyWith(
|
||||||
|
isFinishPlanTime: true,
|
||||||
|
planStudyRemainTimeSecond: 0,
|
||||||
|
);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _saveCurrentPageRemainTime() {
|
||||||
|
if (_mode == BeforeDuringMode.view) return;
|
||||||
|
if (_currentIndex >= _pageList.length) return;
|
||||||
|
final page = _pageList[_currentIndex];
|
||||||
|
if (page.planStudyTimeSecond <= 0 || page.isFinishPlanTime) return;
|
||||||
|
final remain = _countdownRemaining.inSeconds;
|
||||||
|
if (remain > 0 && remain <= page.planStudyTimeSecond) {
|
||||||
|
_pageList[_currentIndex] =
|
||||||
|
page.copyWith(planStudyRemainTimeSecond: remain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String get _countdownText {
|
||||||
|
final d = _countdownRemaining;
|
||||||
|
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
|
return '$m:$s';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 计算属性 =====================
|
||||||
|
|
||||||
|
int get _totalCount => _pageList.length;
|
||||||
|
int get _currentLabel => _totalCount == 0 ? 0 : _currentIndex + 1;
|
||||||
|
double get _percentage => _totalCount == 0 ? 0 : _currentLabel / _totalCount;
|
||||||
|
|
||||||
|
HandoutItem? get _currentPage =>
|
||||||
|
_currentIndex < _pageList.length ? _pageList[_currentIndex] : null;
|
||||||
|
|
||||||
|
String get _title {
|
||||||
|
if (_mode == BeforeDuringMode.view) {
|
||||||
|
return _isWrong ? '错题解析' : '全部解析';
|
||||||
|
}
|
||||||
|
return _currentPage?.title ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 最低时长是否未完成
|
||||||
|
bool get _isTimeDisabled {
|
||||||
|
if (_mode == BeforeDuringMode.view) return false;
|
||||||
|
final p = _currentPage;
|
||||||
|
if (p == null) return false;
|
||||||
|
if (p.planStudyTimeSecond > 0 && !p.isFinishPlanTime) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否必须上传笔记但还没传
|
||||||
|
bool get _isNoteDisabled {
|
||||||
|
if (_mode == BeforeDuringMode.view) return false;
|
||||||
|
final p = _currentPage;
|
||||||
|
if (p == null) return false;
|
||||||
|
if (p.isUploadNotes && p.notes.isEmpty) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 滑动到下一页是否被拦截(时长不足 或 笔记必传未传,都不能滑)
|
||||||
|
bool get _isSwipeNextBlocked {
|
||||||
|
if (_mode == BeforeDuringMode.view) return false;
|
||||||
|
return _isTimeDisabled || _isNoteDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 倒计时气泡是否显示
|
||||||
|
bool get _showTimeBubble {
|
||||||
|
if (_mode == BeforeDuringMode.view) return false;
|
||||||
|
final p = _currentPage;
|
||||||
|
if (p == null) return false;
|
||||||
|
if (p.planStudyTimeSecond <= 0 || p.isFinishPlanTime) return false;
|
||||||
|
return _countdownRemaining.inSeconds > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 页面切换 =====================
|
||||||
|
|
||||||
|
void _onPageChanged(int index) {
|
||||||
|
if (index == _currentIndex) return;
|
||||||
|
|
||||||
|
_saveCurrentPageRemainTime();
|
||||||
|
_pausePageMedia(_currentIndex);
|
||||||
|
_renewHandoutTimeForIndex(_currentIndex);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_currentIndex = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
_pageStartTime = DateTime.now();
|
||||||
|
_startCountdownForCurrentPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手势滑动翻页(自己控制,不受 PageView physics 影响)
|
||||||
|
/// - 向右滑(primaryVelocity > 0)→ 上一页,始终允许
|
||||||
|
/// - 向左滑(primaryVelocity < 0)→ 下一页,需满足时长 + 笔记条件
|
||||||
|
void _onHorizontalDragEnd(DragEndDetails details) {
|
||||||
|
final velocity = details.primaryVelocity ?? 0;
|
||||||
|
const kMinVelocity = 200.0; // 最小滑动速度阈值
|
||||||
|
|
||||||
|
if (velocity.abs() < kMinVelocity) return; // 滑动速度不够,忽略
|
||||||
|
|
||||||
|
if (velocity > 0) {
|
||||||
|
// 向右滑 → 上一页
|
||||||
|
if (_currentIndex > 0) {
|
||||||
|
_pageController.animateToPage(
|
||||||
|
_currentIndex - 1,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 向左滑 → 下一页(时长不足或笔记必传未传,都不能滑)
|
||||||
|
if (_currentIndex < _pageList.length - 1 && !_isSwipeNextBlocked) {
|
||||||
|
_pageController.animateToPage(
|
||||||
|
_currentIndex + 1,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _renewHandoutTimeForIndex(int index) async {
|
||||||
|
if (_mode == BeforeDuringMode.view) return;
|
||||||
|
if (index >= _pageList.length) return;
|
||||||
|
final page = _pageList[index];
|
||||||
|
if (page.id.isEmpty) return;
|
||||||
|
try {
|
||||||
|
await BeforeDuringClassApi.instance.renewHandoutTime(
|
||||||
|
id: page.id,
|
||||||
|
courseId: _courseId,
|
||||||
|
startTime: _pageStartTime.toIso8601String(),
|
||||||
|
endTime: DateTime.now().toIso8601String(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('[BeforeDuringClass] RenewTime 失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pausePageMedia(int index) {
|
||||||
|
_pageKeys[index]?.currentState?.pauseMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 底部按钮操作 =====================
|
||||||
|
|
||||||
|
void _onUploadNotes() {
|
||||||
|
_pageKeys[_currentIndex]?.currentState?.uploadLectureNotes();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onPrevious() async {
|
||||||
|
if (_currentIndex == 0) return;
|
||||||
|
_saveCurrentPageRemainTime();
|
||||||
|
_pausePageMedia(_currentIndex);
|
||||||
|
_pageController.previousPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onNext() async {
|
||||||
|
// 上传中禁止跳转
|
||||||
|
final currentState = _pageKeys[_currentIndex]?.currentState;
|
||||||
|
if (currentState != null && currentState.getIsUploading()) {
|
||||||
|
CenterToast.show(context, '图片上传中,请稍后再试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时长不足(可跳过)
|
||||||
|
if (_isTimeDisabled) {
|
||||||
|
final timeText = _formatDuration(
|
||||||
|
Duration(seconds: _currentPage?.planStudyTimeSecond ?? 0),
|
||||||
|
);
|
||||||
|
final ok = await showConfirmBox(
|
||||||
|
context,
|
||||||
|
title: '建议学习时长 $timeText',
|
||||||
|
content: '仓促速学掌握不牢,之后还要重新复习\n本次攻克成功,学习时长会逐步减少',
|
||||||
|
cancelText: '继续学习',
|
||||||
|
confirmText: '跳过',
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 笔记必传(不可跳过,放在时长之后,点跳过时长后仍会检查)
|
||||||
|
if (_isNoteDisabled) {
|
||||||
|
final ok = await showConfirmBox(
|
||||||
|
context,
|
||||||
|
title: '该讲义必须上传笔记',
|
||||||
|
content: '上传笔记,有助于更好记录课程内容,提升学习效率',
|
||||||
|
cancelText: '取消',
|
||||||
|
confirmText: '上传笔记',
|
||||||
|
);
|
||||||
|
if (ok == true) {
|
||||||
|
_onUploadNotes();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一页 → 完成学习
|
||||||
|
if (_currentIndex == _pageList.length - 1) {
|
||||||
|
if (_mode == BeforeDuringMode.edit) {
|
||||||
|
await _finishAll();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveCurrentPageRemainTime();
|
||||||
|
_pausePageMedia(_currentIndex);
|
||||||
|
_pageController.nextPage(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _finishAll() async {
|
||||||
|
_isNavigatingAway = true;
|
||||||
|
_saveCurrentPageRemainTime();
|
||||||
|
_pausePageMedia(_currentIndex);
|
||||||
|
await _renewHandoutTimeForIndex(_currentIndex);
|
||||||
|
if (mounted) {
|
||||||
|
_goTransitionPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goTransitionPage() {
|
||||||
|
// TODO: 跳转到 transition 页,等路由配置好后替换
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 笔记变化 =====================
|
||||||
|
|
||||||
|
void _onUploadChanged(List<UploadItem> list) {
|
||||||
|
if (_currentIndex >= _pageList.length) return;
|
||||||
|
final page = _pageList[_currentIndex];
|
||||||
|
_pageList[_currentIndex] = page.copyWith(
|
||||||
|
notes: list
|
||||||
|
.where((item) => item.id != null)
|
||||||
|
.map((item) => NoteImage(
|
||||||
|
id: item.id,
|
||||||
|
url: item.url,
|
||||||
|
thumbnailUrl: item.thumbnailUrl,
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== 返回 =====================
|
||||||
|
|
||||||
|
Future<bool> _onWillPop() async {
|
||||||
|
if (_mode == BeforeDuringMode.edit && !_isNavigatingAway) {
|
||||||
|
_saveCurrentPageRemainTime();
|
||||||
|
await _renewHandoutTimeForIndex(_currentIndex);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Build =====================
|
||||||
|
|
||||||
class CaptureListPage extends StatelessWidget {
|
|
||||||
const CaptureListPage({super.key});
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return PopScope(
|
||||||
appBar: AppBar(title: const Text('Align 对齐布局 Demo11111')),
|
canPop: true,
|
||||||
// ---------- 下方即为核心代码,可直接复制到 Scaffold body ----------
|
onPopInvoked: (didPop) async {
|
||||||
body: Container(
|
if (didPop) return;
|
||||||
width: 300.w,
|
await _onWillPop();
|
||||||
height: 300.h,
|
},
|
||||||
color: Colors.grey[300],
|
child: Scaffold(
|
||||||
|
backgroundColor: _bgColor,
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _pageList.isEmpty
|
||||||
|
? const Center(child: Text('暂无讲义'))
|
||||||
|
: Stack(
|
||||||
|
children: [
|
||||||
|
// 顶部背景图
|
||||||
|
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: (_, __) => const SizedBox.shrink(),
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 导航栏
|
||||||
|
_buildNavBar(),
|
||||||
|
// 计时 + 进度
|
||||||
|
_buildHeader(),
|
||||||
|
// 主体(外层手势识别,自己控制左右滑动)
|
||||||
|
Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onHorizontalDragEnd: _onHorizontalDragEnd,
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
child: PageView.builder(
|
||||||
|
controller: _pageController,
|
||||||
|
itemCount: _pageList.length,
|
||||||
|
onPageChanged: _onPageChanged,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
allowImplicitScrolling: true,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
_pageKeys[index] ??=
|
||||||
|
GlobalKey<BeforeDuringPageState>();
|
||||||
|
return BeforeDuringPage(
|
||||||
|
key: _pageKeys[index],
|
||||||
|
data: _pageList[index],
|
||||||
|
isCurrent: index == _currentIndex,
|
||||||
|
onUploadChanged: _onUploadChanged,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 底部操作栏
|
||||||
|
_buildFooter(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===================== NavBar =====================
|
||||||
|
|
||||||
|
Widget _buildNavBar() {
|
||||||
|
return SizedBox(
|
||||||
|
height: 44.h,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Text(
|
||||||
|
_title,
|
||||||
|
style: const 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: () async {
|
||||||
|
await _onWillPop();
|
||||||
|
if (mounted) Navigator.of(context).maybePop();
|
||||||
|
},
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Header(计时 + 进度) =====================
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(14.w, 4.h, 14.w, 10.h),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// 左侧:计时
|
||||||
|
_buildTiming(),
|
||||||
|
const Spacer(),
|
||||||
|
// 右侧:进度条 + 页码
|
||||||
|
_buildProgress(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTiming() {
|
||||||
|
if (_mode == BeforeDuringMode.view) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 6.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(30.r),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: ImgUrl.build('/StaticResource/2026-04-02/timing.png'),
|
||||||
|
width: 18.w,
|
||||||
|
height: 18.w,
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
Icon(Icons.timer, size: 18.w, color: _primary),
|
||||||
|
),
|
||||||
|
SizedBox(width: 5.w),
|
||||||
|
Text(
|
||||||
|
_timingText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
color: _titleColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgress() {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 150.w,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4.r),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: _percentage,
|
||||||
|
minHeight: 4,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
valueColor: const AlwaysStoppedAnimation(_primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '$_currentLabel',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _titleColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: '/$_totalCount',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.sp,
|
||||||
|
color: _subtitleColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================== Footer =====================
|
||||||
|
|
||||||
|
Widget _buildFooter() {
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: const Color(0xFFEEEEEE), width: 1.w),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// 上传笔记按钮
|
||||||
|
if (_mode == BeforeDuringMode.edit) _buildUploadNoteButton(),
|
||||||
|
if (_mode == BeforeDuringMode.edit) SizedBox(width: 10.w),
|
||||||
|
// 上一步
|
||||||
|
Expanded(
|
||||||
|
child: _buildStepButton(
|
||||||
|
text: '上一步',
|
||||||
|
disabled: _currentIndex == 0,
|
||||||
|
onTap: _onPrevious,
|
||||||
|
isPrimary: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10.w),
|
||||||
|
// 下一步
|
||||||
|
Expanded(
|
||||||
|
child: _buildNextButton(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUploadNoteButton() {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _onUploadNotes,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: ImgUrl.build('/StaticResource/2026-04-02/note.png'),
|
||||||
|
width: 22.w,
|
||||||
|
height: 22.w,
|
||||||
|
errorWidget: (_, __, ___) => Icon(
|
||||||
|
Icons.note_add_outlined,
|
||||||
|
size: 22.w,
|
||||||
|
color: _primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 3.h),
|
||||||
|
Text(
|
||||||
|
'上传笔记',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10.sp,
|
||||||
|
color: _titleColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepButton({
|
||||||
|
required String text,
|
||||||
|
required bool disabled,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
required bool isPrimary,
|
||||||
|
}) {
|
||||||
|
final bgColor = isPrimary ? _primary : const Color(0xFFF0F2F5);
|
||||||
|
final textColor = isPrimary ? Colors.white : _titleColor;
|
||||||
|
|
||||||
|
return Opacity(
|
||||||
|
opacity: disabled ? 0.5 : 1.0,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: disabled ? null : onTap,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
foregroundColor: textColor,
|
||||||
|
disabledBackgroundColor: bgColor,
|
||||||
|
disabledForegroundColor: textColor,
|
||||||
|
minimumSize: Size.fromHeight(47.h),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20.r),
|
||||||
|
),
|
||||||
|
textStyle: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(text),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNextButton() {
|
||||||
|
final isLast = _currentIndex == _pageList.length - 1;
|
||||||
|
final text = isLast && _mode == BeforeDuringMode.edit
|
||||||
|
? '完成学习'
|
||||||
|
: '下一步';
|
||||||
|
// 只有 view 模式最后一页才禁用;edit 模式永远可点(点了弹引导弹窗)
|
||||||
|
final disabled = _mode == BeforeDuringMode.view && isLast;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
_buildStepButton(
|
||||||
|
text: text,
|
||||||
|
disabled: disabled,
|
||||||
|
onTap: _onNext,
|
||||||
|
isPrimary: true,
|
||||||
|
),
|
||||||
|
if (_showTimeBubble)
|
||||||
|
Positioned(
|
||||||
|
right: 4.w,
|
||||||
|
top: -12.h,
|
||||||
|
child: Container(
|
||||||
|
height: 20.h,
|
||||||
|
padding: EdgeInsets.fromLTRB(8.w, 0, 8.w, 3.h),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
image: DecorationImage(
|
||||||
|
image: NetworkImage(
|
||||||
|
ImgUrl.build('/StaticResource/2026-04-17/bubble.png'),
|
||||||
|
),
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, -1.h),
|
||||||
|
child: Text(
|
||||||
|
_countdownText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11.sp,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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:cached_network_image/cached_network_image.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/api.dart';
|
||||||
import 'package:new_learn/core/api/conquerPackage/captureList/model.dart';
|
import 'package:new_learn/core/api/conquerPackage/captureList/model.dart';
|
||||||
@@ -220,7 +221,7 @@ class _CaptureListPageState extends State<CaptureListPage> {
|
|||||||
// 跳转分支(对标 Vue navigateToCaptureInfo)
|
// 跳转分支(对标 Vue navigateToCaptureInfo)
|
||||||
if (res.hasHandout) {
|
if (res.hasHandout) {
|
||||||
// 有讲义 → 课前课中页
|
// 有讲义 → 课前课中页
|
||||||
_navigateTo('/conquer/beforeDuringClass', {
|
_navigateTo('/conquerPackage/beforeDuringClass', {
|
||||||
'status': '1',
|
'status': '1',
|
||||||
'packId': item.id,
|
'packId': item.id,
|
||||||
'courseId': widget.courseId,
|
'courseId': widget.courseId,
|
||||||
@@ -261,13 +262,14 @@ class _CaptureListPageState extends State<CaptureListPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 页面跳转(占位 —— 待路由统一后替换为正式路由名)
|
/// 页面跳转(go_router push + extra 传参)
|
||||||
void _navigateTo(String path, Map<String, String> params) {
|
void _navigateTo(String path, Map<String, String> params) {
|
||||||
// TODO(路由): 替换为项目统一的路由跳转方式
|
// TODO(路由): 替换为项目统一的路由跳转方式
|
||||||
debugPrint('navigateTo: $path $params');
|
// debugPrint('navigateTo: $path $params');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
// ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('跳转 $path ${params.toString()}'), behavior: SnackBarBehavior.floating),
|
// SnackBar(content: Text('跳转 $path ${params.toString()}'), behavior: SnackBarBehavior.floating),
|
||||||
);
|
// );
|
||||||
|
context.push(path, extra: params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 点击报告(跳转报告页)
|
/// 点击报告(跳转报告页)
|
||||||
|
|||||||
@@ -1,435 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -96,14 +96,6 @@ 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',
|
||||||
@@ -117,7 +109,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: () {
|
||||||
context.push('/myNote');
|
// TODO: 跳转到我的笔记页
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_MenuItem(
|
_MenuItem(
|
||||||
@@ -125,7 +117,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: () {
|
||||||
context.push('/myWrong');
|
// TODO: 跳转到错题本页
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_MenuItem(
|
_MenuItem(
|
||||||
@@ -133,7 +125,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: () {
|
||||||
context.push('/myCollect');
|
// TODO: 跳转到我的收藏页
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_MenuItem(
|
_MenuItem(
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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/components/common_paged_list.dart';
|
||||||
import 'package:new_learn/core/logger/app_talker.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/img_url.dart';
|
||||||
import 'package:new_learn/core/utils/subject_icons.dart';
|
|
||||||
|
|
||||||
/// ============================================================
|
/// ============================================================
|
||||||
/// 【MyReportPage】我的报告列表 —— 对标 Vue pages/myPackage/myReport/myReport.vue
|
/// 【MyReportPage】我的报告列表 —— 对标 Vue pages/myPackage/myReport/myReport.vue
|
||||||
@@ -26,6 +25,19 @@ import 'package:new_learn/core/utils/subject_icons.dart';
|
|||||||
/// 四、先只做 phone 布局,Pad 后续再说
|
/// 四、先只做 phone 布局,Pad 后续再说
|
||||||
/// ============================================================
|
/// ============================================================
|
||||||
|
|
||||||
|
/// 学科 icon 映射表(对标 Vue utils/constant.js SUBJECT_ICON_MAP)
|
||||||
|
const Map<String, String> _subjectIconMap = {
|
||||||
|
'语文': '/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',
|
||||||
|
};
|
||||||
|
|
||||||
/// 取学科名(报告名前 2 个字,对标 Vue getSubjectName)
|
/// 取学科名(报告名前 2 个字,对标 Vue getSubjectName)
|
||||||
String _extractSubjectName(UserReportDto item) {
|
String _extractSubjectName(UserReportDto item) {
|
||||||
final name = item.reportName.trim();
|
final name = item.reportName.trim();
|
||||||
@@ -33,6 +45,12 @@ String _extractSubjectName(UserReportDto item) {
|
|||||||
return name.substring(0, 2);
|
return name.substring(0, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 取学科 icon URL(无匹配则用数学兜底,对标 Vue getSubjectIcon)
|
||||||
|
String _getSubjectIconUrl(String subjectName) {
|
||||||
|
final path = _subjectIconMap[subjectName] ?? _subjectIconMap['数学']!;
|
||||||
|
return ImgUrl.build(path);
|
||||||
|
}
|
||||||
|
|
||||||
/// 格式化创建时间(对标 Vue getReportTime)
|
/// 格式化创建时间(对标 Vue getReportTime)
|
||||||
String _formatCreateTime(String raw) {
|
String _formatCreateTime(String raw) {
|
||||||
if (raw.isEmpty) return '';
|
if (raw.isEmpty) return '';
|
||||||
@@ -102,8 +120,8 @@ class _MyReportPageState extends ConsumerState<MyReportPage> {
|
|||||||
color: const Color(0xFFF5F8FA),
|
color: const Color(0xFFF5F8FA),
|
||||||
child: CommonPagedList<UserReportDto>(
|
child: CommonPagedList<UserReportDto>(
|
||||||
load: _loadPage,
|
load: _loadPage,
|
||||||
padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 0),
|
padding: EdgeInsets.fromLTRB(12.w, 16.h, 12.w, 0),
|
||||||
// separatorBuilder: (_, _) => SizedBox(height: 14.h),
|
separatorBuilder: (_, _) => SizedBox(height: 14.h),
|
||||||
buildItem: (context, item, index) {
|
buildItem: (context, item, index) {
|
||||||
return _ReportCard(item: item, onTap: () => _onTapCard(item));
|
return _ReportCard(item: item, onTap: () => _onTapCard(item));
|
||||||
},
|
},
|
||||||
@@ -122,11 +140,11 @@ class _MyReportPageState extends ConsumerState<MyReportPage> {
|
|||||||
/// ============================================================
|
/// ============================================================
|
||||||
/// 【_ReportCard】报告卡片
|
/// 【_ReportCard】报告卡片
|
||||||
///
|
///
|
||||||
/// 左侧装饰与我的考试(exam)卡片一致:
|
/// 布局(Row 从左到右):
|
||||||
/// 白色圆角卡片 + 左侧溢出书图(book_icon)+ 学科图标叠在书图上 +
|
/// [icon + 学科标签] → [报告名 + 时间] → [箭头]
|
||||||
/// 底部学科名胶囊。书图/卡片尺寸统一用 .w 锁定宽高比,适配 pad。
|
|
||||||
///
|
///
|
||||||
/// 内容区:中间 [报告名 + 时间],右侧 [箭头]。
|
/// 背景图叠底,InkWell 提供点击水波纹。
|
||||||
|
/// 图标/标签自然靠左,不做偏移 hack。
|
||||||
/// ============================================================
|
/// ============================================================
|
||||||
class _ReportCard extends StatelessWidget {
|
class _ReportCard extends StatelessWidget {
|
||||||
const _ReportCard({required this.item, required this.onTap});
|
const _ReportCard({required this.item, required this.onTap});
|
||||||
@@ -134,177 +152,115 @@ class _ReportCard extends StatelessWidget {
|
|||||||
final UserReportDto item;
|
final UserReportDto item;
|
||||||
final VoidCallback onTap;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final subjectName = _extractSubjectName(item);
|
final subjectName = _extractSubjectName(item);
|
||||||
final iconUrl = subjectIconUrl(subjectName);
|
final subjectIconUrl = _getSubjectIconUrl(subjectName);
|
||||||
final timeText = _formatCreateTime(item.createTime);
|
final timeText = _formatCreateTime(item.createTime);
|
||||||
|
|
||||||
return GestureDetector(
|
const bgPath =
|
||||||
behavior: HitTestBehavior.opaque,
|
'/StaticResource/2026-06-10/examListItem_bg1781074465963.png';
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: SizedBox(
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
height: (_bookHeight + _bookExtraHeight).w,
|
child: Container(
|
||||||
|
height: 109.h,
|
||||||
|
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12.r)),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
children: [
|
||||||
// 底层:白色圆角卡片(底部对齐,书图只向上溢出)
|
// 底层:卡片背景图
|
||||||
Positioned(
|
Positioned.fill(
|
||||||
left: 0,
|
child: ClipRRect(
|
||||||
right: 0,
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
bottom: 0,
|
child: CachedNetworkImage(
|
||||||
child: Container(
|
imageUrl: ImgUrl.build(bgPath),
|
||||||
height: _cardHeight.w,
|
fit: BoxFit.fill,
|
||||||
decoration: BoxDecoration(
|
placeholder: (_, _) => Container(color: Colors.white),
|
||||||
color: Colors.white,
|
errorWidget: (_, _, _) => Container(color: Colors.white),
|
||||||
borderRadius: BorderRadius.circular(_cardRadius.r),
|
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: Color(0x0A1D2B47),
|
|
||||||
blurRadius: 20,
|
|
||||||
offset: Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 中层:左侧书形装饰图
|
// 上层内容:Row 左对齐,padding 控制整体边距
|
||||||
Positioned(
|
Padding(
|
||||||
left: _bookOffsetLeft.w,
|
padding: EdgeInsets.symmetric(horizontal: 22.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(
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// 左侧书图区域占位
|
// ── 左侧:学科 icon + 标签(垂直排列,自然靠左) ──
|
||||||
SizedBox(width: 90.w),
|
Column(
|
||||||
Expanded(
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Padding(
|
children: [
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
CachedNetworkImage(
|
||||||
child: Row(
|
imageUrl: subjectIconUrl,
|
||||||
children: [
|
width: 56.w,
|
||||||
Expanded(child: _buildMid(timeText)),
|
height: 56.w,
|
||||||
_buildRight(),
|
placeholder: (_, _) => const SizedBox.shrink(),
|
||||||
],
|
errorWidget: (_, _, _) => const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
|
SizedBox(height: 4.h),
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8.w),
|
||||||
|
height: 25.h,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEAF5FE),
|
||||||
|
borderRadius: BorderRadius.circular(7.r),
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
subjectName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF3D7DF5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(width: 40.w),
|
||||||
|
// ── 中间:报告名 + 时间(Expanded 占剩余) ──
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
Text(
|
||||||
|
item.reportName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 16.sp,
|
||||||
|
color: const Color(0xFF1D2B47),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 20.h),
|
||||||
|
Text(
|
||||||
|
timeText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.sp,
|
||||||
|
color: const Color(0xFF687FAD),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// ── 右侧:箭头 ──
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: 10.h),
|
||||||
|
child: Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: const Color(0xFF4B6B99),
|
||||||
|
size: 22.sp,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 学科标签(覆盖在书图底部胶囊上,卡片贴底)
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,528 +0,0 @@
|
|||||||
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),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,453 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,512 +0,0 @@
|
|||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,912 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,506 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
/// ============================================================
|
|
||||||
/// 背诵模块路由参数(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;
|
|
||||||
}
|
|
||||||
@@ -1,417 +1,61 @@
|
|||||||
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 首页 —— 对标 Vue pages/tabbar/recite/recite.vue
|
/// 【RecitePage】「背诵」tab 页 —— 空页面占位
|
||||||
///
|
///
|
||||||
/// 一、数据(Vue onShow,Flutter 在 initState + push 返回后刷新):
|
/// 后续开发计划:这里放背单词的卡片流(正面/背面翻转、记忆曲线等)。
|
||||||
/// 1. getCurrentLexiconBooks → 当前词书(可能为 null,卡片整体隐藏)
|
/// 现在先占位,保证 4 个 tab 都能正常切换。
|
||||||
/// 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 StatefulWidget {
|
class RecitePage extends StatelessWidget {
|
||||||
const RecitePage({super.key});
|
const RecitePage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<RecitePage> createState() => _RecitePageState();
|
Widget build(BuildContext context) {
|
||||||
|
return const _Placeholder(
|
||||||
|
icon: Icons.auto_stories,
|
||||||
|
title: '背诵',
|
||||||
|
subtitle: '功能开发中,敬请期待',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RecitePageState extends State<RecitePage> {
|
/// 通用占位组件:一个居中图标 + 标题 + 副标题
|
||||||
BookInfo? _currBook;
|
class _Placeholder extends StatelessWidget {
|
||||||
int _learnDayCount = 0;
|
const _Placeholder({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
final IconData icon;
|
||||||
void initState() {
|
final String title;
|
||||||
super.initState();
|
final String subtitle;
|
||||||
_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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换教材:返回后刷新(对标 Vue onShow)
|
|
||||||
Future<void> _changeBook() async {
|
|
||||||
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(
|
||||||
backgroundColor: const Color(0xFFF0F8FF),
|
body: Center(
|
||||||
body: Stack(
|
child: Column(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
// HeaderBac:顶部主题背景图(fit-width 顶部对齐)
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: ImgUrl.build(
|
|
||||||
'/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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 坚持学习天数
|
|
||||||
Widget _buildLearnDay() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
children: [
|
||||||
|
Icon(icon, size: 64.w, color: const Color(0xFFC9CFD8)),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
Text(
|
Text(
|
||||||
'$_learnDayCount',
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 50.sp,
|
fontSize: 18.sp,
|
||||||
height: 1,
|
fontWeight: FontWeight.bold,
|
||||||
color: const Color(0xFF7DAAFF),
|
color: const Color(0xFF666666),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
SizedBox(height: 6.h),
|
||||||
padding: EdgeInsets.only(left: 10.w, bottom: 4.h),
|
Text(
|
||||||
child: Text(
|
subtitle,
|
||||||
'天',
|
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,510 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
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],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,286 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,654 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_inappwebview/flutter_inappwebview.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/ 里
|
||||||
///
|
///
|
||||||
@@ -131,44 +127,4 @@ 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user