flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:new_learn/core/api/auth/model.dart';
|
||||
import 'package:new_learn/core/models/user_model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
import 'package:new_learn/core/storage/user_storage.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【UserApi】用户相关接口(对齐 Vue 端 api/user/index.ts)
|
||||
///
|
||||
/// 分层职责(像 Vue 项目把接口收敛到 api 目录一样):
|
||||
/// Page(UI) → UserApi(业务方法) → DioClient(Dio 封装) → 服务器
|
||||
///
|
||||
/// 一、已实现:
|
||||
/// login → POST /api/v1/User/Login
|
||||
/// getCurrentUserExtension → GET /api/v1/User/GetCurrentUserExtension
|
||||
/// (登录后拿完整用户信息:昵称/头像/班级/等级等)
|
||||
/// getCurrentUser → 统一入口(本地缓存优先)
|
||||
/// updateUserAvatar → 更改用户头像
|
||||
///
|
||||
/// 二、后续继续写(对齐 Vue,用到再加):
|
||||
/// checkStudyPackStatus / setChangeUserOnline
|
||||
/// getUserPointLevel / UpdateGuideStatus / UpdateReportStatus
|
||||
///
|
||||
/// 三、高频坑:
|
||||
/// 1. 登录密码 MD5 加密(对齐 Vue stringToMd5),提交前在 Api 层完成;
|
||||
/// 2. getCurrentUserExtension 必须带 Token 才有效 ——
|
||||
/// 登录成功已把 Token 写进 DioClient.accessToken,自动带上;
|
||||
/// 3. 不要在这里 catch ApiException —— 交给页面统一处理展示。
|
||||
/// ============================================================
|
||||
class UserApi {
|
||||
// ==================== 登录 ====================
|
||||
|
||||
/// 登录 → POST /api/v1/User/Login
|
||||
/// 密码规则对齐 Vue 端 stringToMd5:MD5 加密后再提交
|
||||
static Future<LoginResponse> login({
|
||||
required String account,
|
||||
required String password,
|
||||
}) async {
|
||||
final request = LoginRequest(
|
||||
account: account.trim(),
|
||||
password: _md5(password.trim()),
|
||||
);
|
||||
final data = await DioClient.instance.post(
|
||||
'/api/v1/User/Login',
|
||||
data: request.toJson(),
|
||||
);
|
||||
return LoginResponse.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 登录后拉取完整用户信息 → GET /api/v1/User/GetCurrentUserExtension
|
||||
static Future<CurrentUserExtension> getCurrentUserExtension() async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/User/GetCurrentUserExtension',
|
||||
);
|
||||
return CurrentUserExtension.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 获取当前用户(统一入口,缓存优先):
|
||||
/// 1. 有本地缓存 → 直接返回(不费流量不转圈);
|
||||
/// 2. 无缓存或 forceRefresh → 调 GetCurrentUserExtension 拉最新
|
||||
/// (Token 由 Dio 拦截器自动带进 header)并写回缓存;
|
||||
/// 3. 接口失败 → 回退返回本地缓存,缓存也没有才抛异常。
|
||||
/// 页面展示用户信息一律走这里,别直接调 getCurrentUserExtension。
|
||||
static Future<CurrentUserExtension> getCurrentUser({
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final cached = await UserStorage.instance.readUserInfo();
|
||||
if (!forceRefresh && cached != null) return cached;
|
||||
|
||||
try {
|
||||
final ext = await getCurrentUserExtension();
|
||||
await UserStorage.instance.saveUserInfo(ext);
|
||||
return ext;
|
||||
} catch (_) {
|
||||
if (cached != null) return cached; // 网络失败回退本地缓存
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 更改用户头像 → POST /api/v1/User/UpdateUserAvatar
|
||||
///
|
||||
/// 对齐 UniApp:头像先传 OSS,拿到 url 后再把 {avatar: url} 提交给业务接口。
|
||||
static Future<void> updateUserAvatar(String avatar) async {
|
||||
await DioClient.instance.post(
|
||||
'/api/v1/User/UpdateUserAvatar',
|
||||
data: {'avatar': avatar},
|
||||
);
|
||||
}
|
||||
|
||||
/// MD5 加密(UniApp stringToMd5 的等价实现)
|
||||
static String _md5(String text) {
|
||||
return md5.convert(utf8.encode(text)).toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/// ============================================================
|
||||
/// 【auth 模块模型】登录相关实体 —— 对照 Vue 项目 src/api/login.ts
|
||||
///
|
||||
/// LoginRequest → 登录请求体(toJson 提交给服务器)
|
||||
/// LoginResponse → 登录响应(token + 用户信息)
|
||||
/// UserInfo → 用户信息子模型(info 字段)
|
||||
///
|
||||
/// 高频坑:
|
||||
/// 1. fromJson 全部用 ?? 兜底,字段缺失/类型漂移不会闪崩;
|
||||
/// 2. 数字用 (json['x'] as num?)?.toInt() 防 int/double 不一致;
|
||||
/// 3. 兼容业务响应壳 {code, msg, data: {token, info}}。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
class LoginRequest {
|
||||
const LoginRequest({required this.account, required this.password});
|
||||
|
||||
final String account;
|
||||
final String password;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'account': account,
|
||||
'password': password,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class LoginResponse {
|
||||
const LoginResponse({required this.token, required this.info});
|
||||
|
||||
final String token;
|
||||
final UserInfo info;
|
||||
|
||||
factory LoginResponse.fromJson(Map<String, dynamic> json) {
|
||||
// 兼容常见包装结构:后端可能直接返回 {token, info},
|
||||
// 也可能包一层 {code, msg, data: {token, info}}(业务响应通用壳)。
|
||||
// data 存在就取 data,否则直接用 json,两种都能解析到。
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return LoginResponse(
|
||||
token: payload['token'] as String? ?? '',
|
||||
info: UserInfo.fromJson(
|
||||
(payload['info'] as Map<String, dynamic>?) ?? const {},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户信息(对齐 Vue 端 getUserInfo 的字段,未知的用 ?? 兜底)
|
||||
class UserInfo {
|
||||
const UserInfo({
|
||||
this.userId = 0,
|
||||
this.userName = '',
|
||||
this.account = '',
|
||||
this.avatar = '',
|
||||
this.phone = '',
|
||||
this.isShowReport = false,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String userName;
|
||||
final String account;
|
||||
final String avatar;
|
||||
final String phone;
|
||||
final bool isShowReport;
|
||||
|
||||
factory UserInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UserInfo(
|
||||
// userId 可能是字符串(后端统一风格),不能直接 as num
|
||||
userId: _toInt(json['userId']),
|
||||
userName: json['userName'] as String? ?? '',
|
||||
account: json['account'] as String? ?? '',
|
||||
avatar: json['avatar'] as String? ?? '',
|
||||
phone: json['phone'] as String? ?? '',
|
||||
isShowReport: json['isShowReport'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容"字符串数字"(后端 userId 返回 "820466383020136")
|
||||
int _toInt(dynamic v) {
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:new_learn/core/api/class/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【class 模块接口】班级模块请求 —— 对标 Vue3 端 api/class/index.ts + api/userPoint/index.ts
|
||||
///
|
||||
/// ClassApi:班级动态/成员/点赞/评论
|
||||
/// getUserClassPost → 班级动态分页(游标 pageIndex + prevPageId)
|
||||
/// getClassUsers → 班级成员在线状态
|
||||
/// addUserLike → 点赞 / 取消点赞
|
||||
/// addUserComment → 评论
|
||||
///
|
||||
/// UserRankApi:积分排行榜
|
||||
/// getUserCount → 排行榜总人数(本班级 / 同年级 / 同云校)
|
||||
/// getRankPage → 排行榜分页(游标分页:pageIndex + prevPageId)
|
||||
///
|
||||
/// 注意:页面只调这里,不直接碰 Dio;异常不在这里 catch,交页面统一处理。
|
||||
/// ============================================================
|
||||
class ClassApi {
|
||||
ClassApi._();
|
||||
|
||||
static final ClassApi instance = ClassApi._();
|
||||
|
||||
/// 班级动态列表(queryType: 0 我的动态 / 1 班级动态)
|
||||
Future<UserPostResponse> getUserClassPost({
|
||||
int queryType = 1,
|
||||
int pageIndex = 1,
|
||||
int pageSize = 10,
|
||||
int prevPageId = 0,
|
||||
}) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserPost/GetUserClassPost',
|
||||
query: {
|
||||
'queryType': queryType,
|
||||
'pageIndex': pageIndex,
|
||||
'pageSize': pageSize,
|
||||
'prevPageId': prevPageId,
|
||||
},
|
||||
);
|
||||
return UserPostResponse.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 班级成员(在线状态)
|
||||
Future<List<ClassUser>> getClassUsers() async {
|
||||
final data = await DioClient.instance.get('/api/v1/User/GetClassUsers');
|
||||
final list = data is Map<String, dynamic> ? data['data'] : data;
|
||||
if (list is List) {
|
||||
return list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ClassUser.fromJson)
|
||||
.toList();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// 点赞 / 取消点赞(likeStatus: true 点赞 / false 取消)
|
||||
Future<void> addUserLike({
|
||||
required int userPostId,
|
||||
required bool likeStatus,
|
||||
}) async {
|
||||
await DioClient.instance.post('/api/v1/UserLike/AddUserLike', data: {
|
||||
'userPostId': userPostId,
|
||||
'likeStatus': likeStatus,
|
||||
});
|
||||
}
|
||||
|
||||
/// 评论动态
|
||||
Future<void> addUserComment({
|
||||
required int userPostId,
|
||||
required String content,
|
||||
}) async {
|
||||
await DioClient.instance.post('/api/v1/UserPost/AddUserCommon', data: {
|
||||
'userPostId': userPostId,
|
||||
'content': content,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 积分排行榜接口
|
||||
class UserRankApi {
|
||||
UserRankApi._();
|
||||
|
||||
static final UserRankApi instance = UserRankApi._();
|
||||
|
||||
/// 排行榜总人数(本班级 / 同年级 / 同云校)
|
||||
Future<UserCountResult> getUserCount() async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserPointsRecord/GetUserCount',
|
||||
);
|
||||
return UserCountResult.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 排行榜分页(游标分页:pageIndex + prevPageId)
|
||||
/// [userRankType] 0 总榜 / 1 本周 / 2 上周 / 3 今天 / 4 昨天
|
||||
/// [userClass] 0 本班级 / 1 同年级 / 2 同云校
|
||||
Future<UserRankPagedList> getRankPage({
|
||||
required int pageIndex,
|
||||
required int pageSize,
|
||||
required int prevPageId,
|
||||
required int userRankType,
|
||||
required int userClass,
|
||||
required bool desc,
|
||||
}) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserPointsRecord/GetUserRank',
|
||||
query: {
|
||||
'pageIndex': pageIndex,
|
||||
'pageSize': pageSize,
|
||||
'prevPageId': prevPageId,
|
||||
'UserRankType': userRankType,
|
||||
'UserClass': userClass,
|
||||
'desc': desc,
|
||||
},
|
||||
);
|
||||
return UserRankPagedList.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/// ============================================================
|
||||
/// 【class 模块模型】班级模块实体 —— 对标 Vue3 api/class/type.ts + api/userPoint/type.ts
|
||||
///
|
||||
/// 内容(合并自原 features/class/models/):
|
||||
/// 班级动态:UserPostCommonDto / UserPostDto / UserPostResponse / ClassUser
|
||||
/// 积分排行:UserRank / UserRankPagedList / UserCountResult
|
||||
///
|
||||
/// 序列化:手动 fromJson(响应壳 {code,message,data:{...}} 先解包 data,再逐字段取),
|
||||
/// 不依赖 build_runner。数字用 _toInt 兜底字符串数字。
|
||||
///
|
||||
/// 说明:UserPostDto 提供 copyWith(点赞/评论乐观更新需要在列表内替换单条)。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
// ============ 班级动态 ============
|
||||
|
||||
class UserPostCommonDto {
|
||||
const UserPostCommonDto({
|
||||
this.userId = 0,
|
||||
this.userName = '',
|
||||
this.content = '',
|
||||
this.createTime = '',
|
||||
this.id = 0,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String userName;
|
||||
final String content;
|
||||
final String createTime;
|
||||
final int id;
|
||||
|
||||
factory UserPostCommonDto.fromJson(Map<String, dynamic> json) =>
|
||||
UserPostCommonDto(
|
||||
userId: _toInt(json['userId']),
|
||||
userName: json['userName'] as String? ?? '',
|
||||
content: json['content'] as String? ?? '',
|
||||
createTime: json['createTime'] as String? ?? '',
|
||||
id: _toInt(json['id']),
|
||||
);
|
||||
}
|
||||
|
||||
class UserPostDto {
|
||||
const UserPostDto({
|
||||
this.userId = 0,
|
||||
this.userName = '',
|
||||
this.avatar = '',
|
||||
this.classId = 0,
|
||||
this.content = '',
|
||||
this.likeCount = 0,
|
||||
this.replyCount = 0,
|
||||
this.subjectName = '',
|
||||
this.createTime = '',
|
||||
this.userLikeStatus = false,
|
||||
this.commons = const [],
|
||||
this.id = 0,
|
||||
this.urls = const [],
|
||||
this.type = 0, // 1 攻克任务 2 试题笔记 3 讲义笔记
|
||||
this.userLikeAvatars = const [],
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String userName;
|
||||
final String avatar;
|
||||
final int classId;
|
||||
final String content;
|
||||
final int likeCount;
|
||||
final int replyCount;
|
||||
final String subjectName;
|
||||
final String createTime;
|
||||
final bool userLikeStatus;
|
||||
final List<UserPostCommonDto> commons;
|
||||
final int id;
|
||||
final List<String> urls;
|
||||
final int type;
|
||||
final List<String> userLikeAvatars;
|
||||
|
||||
factory UserPostDto.fromJson(Map<String, dynamic> json) => UserPostDto(
|
||||
userId: _toInt(json['userId']),
|
||||
userName: json['userName'] as String? ?? '',
|
||||
avatar: json['avatar'] as String? ?? '',
|
||||
classId: _toInt(json['classId']),
|
||||
content: json['content'] as String? ?? '',
|
||||
likeCount: _toInt(json['likeCount']),
|
||||
replyCount: _toInt(json['replyCount']),
|
||||
subjectName: json['subjectName'] as String? ?? '',
|
||||
createTime: json['createTime'] as String? ?? '',
|
||||
userLikeStatus: json['userLikeStatus'] as bool? ?? false,
|
||||
commons: (json['commons'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserPostCommonDto.fromJson)
|
||||
.toList(),
|
||||
id: _toInt(json['id']),
|
||||
urls: (json['urls'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>()
|
||||
.toList(),
|
||||
type: _toInt(json['type']),
|
||||
userLikeAvatars: (json['userLikeAvatars'] as List<dynamic>? ?? const [])
|
||||
.whereType<String>()
|
||||
.toList(),
|
||||
);
|
||||
|
||||
UserPostDto copyWith({
|
||||
int? userId,
|
||||
String? userName,
|
||||
String? avatar,
|
||||
int? classId,
|
||||
String? content,
|
||||
int? likeCount,
|
||||
int? replyCount,
|
||||
String? subjectName,
|
||||
String? createTime,
|
||||
bool? userLikeStatus,
|
||||
List<UserPostCommonDto>? commons,
|
||||
int? id,
|
||||
List<String>? urls,
|
||||
int? type,
|
||||
List<String>? userLikeAvatars,
|
||||
}) {
|
||||
return UserPostDto(
|
||||
userId: userId ?? this.userId,
|
||||
userName: userName ?? this.userName,
|
||||
avatar: avatar ?? this.avatar,
|
||||
classId: classId ?? this.classId,
|
||||
content: content ?? this.content,
|
||||
likeCount: likeCount ?? this.likeCount,
|
||||
replyCount: replyCount ?? this.replyCount,
|
||||
subjectName: subjectName ?? this.subjectName,
|
||||
createTime: createTime ?? this.createTime,
|
||||
userLikeStatus: userLikeStatus ?? this.userLikeStatus,
|
||||
commons: commons ?? this.commons,
|
||||
id: id ?? this.id,
|
||||
urls: urls ?? this.urls,
|
||||
type: type ?? this.type,
|
||||
userLikeAvatars: userLikeAvatars ?? this.userLikeAvatars,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 班级动态分页响应
|
||||
///
|
||||
/// 响应壳:{code, message, data: {data: UserPostDto[], total, ...}}
|
||||
/// 外层 data 是分页对象,内层 data 是动态数组。
|
||||
class UserPostResponse {
|
||||
const UserPostResponse({
|
||||
this.data = const [],
|
||||
this.total = 0,
|
||||
this.pageIndex = 1,
|
||||
this.pageSize = 10,
|
||||
this.id = 0,
|
||||
});
|
||||
|
||||
final List<UserPostDto> data;
|
||||
final int total;
|
||||
final int pageIndex;
|
||||
final int pageSize;
|
||||
final int id;
|
||||
|
||||
factory UserPostResponse.fromJson(Map<String, dynamic> json) {
|
||||
final payload = json['data'] is Map<String, dynamic>
|
||||
? json['data'] as Map<String, dynamic>
|
||||
: json;
|
||||
final list = payload['data'];
|
||||
return UserPostResponse(
|
||||
data: list is List
|
||||
? list
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserPostDto.fromJson)
|
||||
.toList()
|
||||
: const [],
|
||||
total: _toInt(payload['total']),
|
||||
pageIndex: _toInt(payload['pageIndex']),
|
||||
pageSize: _toInt(payload['pageSize']),
|
||||
id: _toInt(payload['id']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ClassUser {
|
||||
const ClassUser({
|
||||
this.userId = 0,
|
||||
this.userName = '',
|
||||
this.avatar = '',
|
||||
this.isOnline = false,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String userName;
|
||||
final String avatar;
|
||||
final bool isOnline;
|
||||
|
||||
factory ClassUser.fromJson(Map<String, dynamic> json) => ClassUser(
|
||||
userId: _toInt(json['userId']),
|
||||
userName: json['userName'] as String? ?? '',
|
||||
avatar: json['avatar'] as String? ?? '',
|
||||
isOnline: json['isOnline'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 积分排行 ============
|
||||
|
||||
class UserRank {
|
||||
const UserRank({
|
||||
this.userName = '',
|
||||
this.userId = 0,
|
||||
this.userAvatar = '',
|
||||
this.userClassName = '',
|
||||
this.rank = 0,
|
||||
this.points = 0,
|
||||
});
|
||||
|
||||
final String userName;
|
||||
final int userId;
|
||||
final String userAvatar;
|
||||
final String userClassName;
|
||||
final int rank;
|
||||
final double points;
|
||||
|
||||
factory UserRank.fromJson(Map<String, dynamic> json) => UserRank(
|
||||
userName: json['userName'] as String? ?? '',
|
||||
userId: _toInt(json['userId']),
|
||||
userAvatar: json['userAvatar'] as String? ?? '',
|
||||
userClassName: json['userClassName'] as String? ?? '',
|
||||
rank: _toInt(json['rank']),
|
||||
points: _toDouble(json['points']),
|
||||
);
|
||||
}
|
||||
|
||||
class UserRankPagedList {
|
||||
const UserRankPagedList({
|
||||
this.myRank,
|
||||
this.userRanks = const [],
|
||||
this.total = 0,
|
||||
this.pageIndex = 1,
|
||||
this.pageSize = 20,
|
||||
});
|
||||
|
||||
final UserRank? myRank;
|
||||
final List<UserRank> userRanks;
|
||||
final int total;
|
||||
final int pageIndex;
|
||||
final int pageSize;
|
||||
|
||||
factory UserRankPagedList.fromJson(Map<String, dynamic> json) {
|
||||
final payload = json['data'] is Map<String, dynamic>
|
||||
? json['data'] as Map<String, dynamic>
|
||||
: json;
|
||||
// 兼容 userRanks / data 两种返回字段
|
||||
final ranksList = payload['userRanks'] is List
|
||||
? payload['userRanks'] as List<dynamic>
|
||||
: (payload['data'] is List
|
||||
? payload['data'] as List<dynamic>
|
||||
: const []);
|
||||
return UserRankPagedList(
|
||||
myRank: payload['myRank'] is Map<String, dynamic>
|
||||
? UserRank.fromJson(payload['myRank'] as Map<String, dynamic>)
|
||||
: null,
|
||||
userRanks: ranksList.whereType<Map<String, dynamic>>().map(UserRank.fromJson).toList(),
|
||||
total: _toInt(payload['total']),
|
||||
pageIndex: _toInt(payload['pageIndex']),
|
||||
pageSize: _toInt(payload['pageSize']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserCountResult {
|
||||
const UserCountResult({
|
||||
this.classUserCount,
|
||||
this.gradeUserCount,
|
||||
this.cloudSchoolUserCount,
|
||||
});
|
||||
|
||||
final int? classUserCount;
|
||||
final int? gradeUserCount;
|
||||
final int? cloudSchoolUserCount;
|
||||
|
||||
factory UserCountResult.fromJson(Map<String, dynamic> json) {
|
||||
final payload = json['data'] is Map<String, dynamic>
|
||||
? json['data'] as Map<String, dynamic>
|
||||
: json;
|
||||
return UserCountResult(
|
||||
classUserCount: _toIntOrNull(payload['classUserCount']),
|
||||
gradeUserCount: _toIntOrNull(payload['gradeUserCount']),
|
||||
cloudSchoolUserCount: _toIntOrNull(payload['cloudSchoolUserCount']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 类型转换工具 ============
|
||||
|
||||
int _toInt(dynamic v) {
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
double _toDouble(dynamic v) {
|
||||
if (v is num) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int? _toIntOrNull(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) return int.tryParse(v);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:new_learn/core/api/my_point/model.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【my_point 模块接口】积分接口 —— 对标 Vue3 端 api/userPoint/index.ts
|
||||
///
|
||||
/// getRecordPage —— 积分记录分页(页码分页)
|
||||
/// GET /api/v1/UserPointsRecord/GetUserPointRecordPagedList
|
||||
/// 参数:pageIndex / pageSize
|
||||
///
|
||||
/// 说明:排行榜相关接口(getUserCount / getUserRankPagedList)归属班级模块,
|
||||
/// 统一收在 core/api/class/api.dart。
|
||||
/// ============================================================
|
||||
class UserPointApi {
|
||||
UserPointApi._();
|
||||
|
||||
static final UserPointApi instance = UserPointApi._();
|
||||
|
||||
/// 积分记录分页(页码分页:pageIndex / pageSize)
|
||||
Future<UserPointRecordPage> getRecordPage({
|
||||
int pageIndex = 1,
|
||||
int pageSize = 10,
|
||||
}) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/UserPointsRecord/GetUserPointRecordPagedList',
|
||||
query: {'pageIndex': pageIndex, 'pageSize': pageSize},
|
||||
);
|
||||
return UserPointRecordPage.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/// ============================================================
|
||||
/// 【my_point 模块模型】积分实体 —— 对标 Vue3 端 api/userPoint/type.ts
|
||||
///
|
||||
/// UserPointRecord(积分变动记录)/ UserPointRecordPage(分页响应)
|
||||
///
|
||||
/// 序列化:手动 fromJson(兼容 {code, message, data: {...}} 响应壳),
|
||||
/// 等价原 point_model_original_version.dart 的自包含实现,不依赖 build_runner。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 单条积分变动记录
|
||||
class UserPointRecord {
|
||||
const UserPointRecord({
|
||||
this.points = 0,
|
||||
this.pointMessage = '',
|
||||
this.type = 0,
|
||||
this.bizTime = '',
|
||||
});
|
||||
|
||||
final int points; // 积分变动值(正=加分,负=扣分)
|
||||
final String pointMessage; // 变动说明
|
||||
final int type; // 类型
|
||||
final String bizTime; // 变动时间字符串
|
||||
|
||||
factory UserPointRecord.fromJson(Map<String, dynamic> json) {
|
||||
return UserPointRecord(
|
||||
points: (json['points'] as num?)?.toInt() ?? 0,
|
||||
pointMessage: json['pointMessage'] as String? ?? '',
|
||||
type: (json['type'] as num?)?.toInt() ?? 0,
|
||||
bizTime: json['bizTime'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 积分记录分页响应
|
||||
class UserPointRecordPage {
|
||||
const UserPointRecordPage({
|
||||
this.data = const [],
|
||||
this.total = 0,
|
||||
this.pageIndex = 1,
|
||||
this.pageSize = 10,
|
||||
});
|
||||
|
||||
final List<UserPointRecord> data; // 当前页记录
|
||||
final int total; // 总记录数
|
||||
final int pageIndex;
|
||||
final int pageSize;
|
||||
|
||||
/// 兼容业务响应壳 {code, message, data: {...}}:data 存在就取 data
|
||||
factory UserPointRecordPage.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return UserPointRecordPage(
|
||||
data: (payload['data'] as List<dynamic>? ?? const [])
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(UserPointRecord.fromJson)
|
||||
.toList(),
|
||||
total: (payload['total'] as num?)?.toInt() ?? 0,
|
||||
pageIndex: (payload['pageIndex'] as num?)?.toInt() ?? 1,
|
||||
pageSize: (payload['pageSize'] as num?)?.toInt() ?? 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:new_learn/core/components/privacy_startup_gate.dart';
|
||||
import 'package:new_learn/core/config/app_config.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/privacy_provider.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/utils/screen_adapt_template.dart'; // 复用 AppAdapt 适配配置
|
||||
import 'package:talker_flutter/talker_flutter.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【TabApp】App 顶层入口(MaterialApp)
|
||||
///
|
||||
/// 职责分工(对照 Vue 项目):
|
||||
/// main.dart = 启动脚本(main 函数)
|
||||
/// app.dart = 根组件:ScreenUtilInit + MaterialApp(主题 + 首页)
|
||||
/// main_tab_page.dart = 底部 4 tab 主框架
|
||||
/// pages/ = 各业务页面
|
||||
///
|
||||
/// 日志体系(talker_flutter + talker_dio_logger):
|
||||
/// 1. 全局唯一实例:core/logger/app_talker.dart 的 talker(全项目 import 即用);
|
||||
/// 2. Dio 网络日志:core/network/dio_client.dart 已接入 TalkerDioLogger;
|
||||
/// 3. App 内查看:本文件在 MaterialApp.builder 外层注入 TalkerWrapper + DebugTalkerBubble,
|
||||
/// 非 Release 模式会出现右下角悬浮小圆球,点开进入 TalkerScreen 查看全部日志;
|
||||
/// 4. Release 自动关闭:kReleaseMode 时不注入 TalkerWrapper/悬浮球,线上不可见。
|
||||
///
|
||||
/// 业务页面直接使用(不需要 new Talker):
|
||||
/// import 'package:new_learn/core/logger/app_talker.dart';
|
||||
/// talker.debug('调试信息');
|
||||
/// talker.warning('警告');
|
||||
/// talker.error('错误');
|
||||
///
|
||||
/// 一、ScreenUtilInit 屏幕适配(flutter_screenutil 5.9.0):
|
||||
/// designSize = 设计稿尺寸(这里 375x812 = iPhone 标准逻辑尺寸,
|
||||
/// 如果你的设计稿是 750 宽(uni-app rpx),就写 Size(750, 1624))。
|
||||
/// 适配原理:按"实际屏幕 / 设计稿"的比例统一缩放,保证不同手机上
|
||||
/// UI 比例一致,不会大屏显小、小屏溢出。
|
||||
///
|
||||
/// 二、页面里怎么用(核心三件套):
|
||||
/// .w 宽度/横向间距 Container(width: 100.w)
|
||||
/// .h 高度/纵向间距 Container(height: 50.h)
|
||||
/// .sp 字号 TextStyle(fontSize: 16.sp)
|
||||
/// .r 圆角 BorderRadius.circular(12.r)
|
||||
///
|
||||
/// 三、高频坑:
|
||||
/// 1. 必须在 ScreenUtilInit 的 builder 内部才能用 .w/.h/.sp/.r,
|
||||
/// 所以初始化要包在最外层(本文件就是最外层);
|
||||
/// 2. 改 main.dart 或这里,必须 Shift+R 热重启,热重载不生效;
|
||||
/// 3. 不要对图片宽高做 .w 缩放后还叠加 transform,会双倍缩放。
|
||||
/// ============================================================
|
||||
class TabApp extends StatelessWidget {
|
||||
const TabApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScreenUtilInit(
|
||||
// 方案 B:平板(宽≥600dp)自动用大设计稿,缩放比例回到 ~1,
|
||||
// 否则 Pad 上 .sp 会随宽度放大 2 倍+,字体爆炸。
|
||||
designSize: AppAdapt.designSizeOf(context),
|
||||
minTextAdapt: true, // 字号最小自适应,防小屏文字过小
|
||||
splitScreenMode: true, // 支持分屏/平板
|
||||
// 兜底:字体放大封顶 1.2 倍(防横屏/极端宽屏)
|
||||
fontSizeResolver: AppAdapt.fontSizeResolver,
|
||||
builder: (context, child) {
|
||||
// 品牌 × 环境 聚合:App 名 + 主题色板随品牌走(见 core/config/app_config.dart)
|
||||
final brandTheme = AppConfig.theme;
|
||||
return MaterialApp.router(
|
||||
title: AppConfig.appName,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
// 品牌 seed 色生成整套 Material 色板(换品牌 = 全局换色)
|
||||
colorSchemeSeed: brandTheme.seedColor,
|
||||
scaffoldBackgroundColor: const Color(0xFFF5F7FA),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Color(0xFF333333),
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
),
|
||||
),
|
||||
// 方案 A:启动层隐私 Gate。
|
||||
// 这样冷启动时不会先看到登录页/主页闪一下,再被路由重定向到隐私页,
|
||||
// 而是直接由全屏拦截页先接管。
|
||||
builder: (context, child) {
|
||||
final content =
|
||||
PrivacyStartupGate(child: child ?? const SizedBox.shrink());
|
||||
final bootstrapped = _LocalNotificationBootstrapper(child: content);
|
||||
if (kReleaseMode) return bootstrapped;
|
||||
return TalkerWrapper(
|
||||
talker: talker,
|
||||
child: DebugTalkerBubble(
|
||||
child: _NetworkStatusGlobalLogger(child: bootstrapped),
|
||||
),
|
||||
);
|
||||
},
|
||||
// 全局路由表:登录守卫 + 4 tab ShellRoute(见 app_router.dart)
|
||||
routerConfig: appRouter,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LocalNotificationBootstrapper extends StatefulWidget {
|
||||
const _LocalNotificationBootstrapper({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<_LocalNotificationBootstrapper> createState() =>
|
||||
_LocalNotificationBootstrapperState();
|
||||
}
|
||||
|
||||
class _LocalNotificationBootstrapperState
|
||||
extends State<_LocalNotificationBootstrapper> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
privacyAcceptedNotifier.addListener(_onPrivacyChanged);
|
||||
_onPrivacyChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
privacyAcceptedNotifier.removeListener(_onPrivacyChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onPrivacyChanged() {
|
||||
if (!privacyAcceptedNotifier.value) return;
|
||||
unawaited(LocalNotificationService.instance.ensureInitialized());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
|
||||
class _NetworkStatusGlobalLogger extends ConsumerStatefulWidget {
|
||||
const _NetworkStatusGlobalLogger({required this.child});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
ConsumerState<_NetworkStatusGlobalLogger> createState() =>
|
||||
_NetworkStatusGlobalLoggerState();
|
||||
}
|
||||
|
||||
class _NetworkStatusGlobalLoggerState
|
||||
extends ConsumerState<_NetworkStatusGlobalLogger> {
|
||||
late final ProviderSubscription<NetworkStatus> _sub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final current = ref.read(networkStatusProvider);
|
||||
talker.debug(
|
||||
'网络状态(初始):'
|
||||
'types=[${current.networkTypes.map((e) => e.name).join(',')}] '
|
||||
'hasInternet=${current.hasInternet} '
|
||||
'hasBackend=${current.hasBackend}',
|
||||
);
|
||||
|
||||
_sub = ref.listenManual(networkStatusProvider, (prev, next) {
|
||||
talker.debug(
|
||||
'网络状态变化:'
|
||||
'types=[${next.networkTypes.map((e) => e.name).join(',')}] '
|
||||
'hasInternet=${next.hasInternet} '
|
||||
'hasBackend=${next.hasBackend}',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_sub.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:new_learn/core/version/remote_version_entity.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AppUpdateDialog】版本更新弹窗 —— 区分【强制更新】【可选更新】
|
||||
///
|
||||
/// 一、强制更新行为:
|
||||
/// barrierDismissible: false(点外部不关闭)
|
||||
/// PopScope 拦截返回按键(弹窗不可关闭)
|
||||
/// 只有【立即更新】按钮
|
||||
///
|
||||
/// 二、可选更新行为:
|
||||
/// 提供【稍后再说】【立即更新】两个按钮
|
||||
/// 点"稍后再说"关闭弹窗,本次生命周期不再重复弹窗
|
||||
///
|
||||
/// 三、点击"立即更新":
|
||||
/// 安卓 → 打开外部浏览器访问下载地址(androidUrl)
|
||||
/// iOS → 跳转 App Store 页面(iosUrl)
|
||||
/// ============================================================
|
||||
class AppUpdateDialog extends StatelessWidget {
|
||||
const AppUpdateDialog({
|
||||
super.key,
|
||||
required this.remoteInfo,
|
||||
required this.isForce,
|
||||
});
|
||||
|
||||
final RemoteVersionEntity remoteInfo;
|
||||
final bool isForce;
|
||||
|
||||
/// 显示更新弹窗(统一入口)
|
||||
static Future<void> show(
|
||||
BuildContext context,
|
||||
RemoteVersionEntity remoteInfo, {
|
||||
required bool isForce,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: !isForce, // 强制更新时点外部不关闭
|
||||
builder: (ctx) => PopScope(
|
||||
canPop: !isForce, // 强制更新时拦截返回键
|
||||
child: AppUpdateDialog(remoteInfo: remoteInfo, isForce: isForce),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 点击"立即更新":区分平台跳转
|
||||
Future<void> _launchUpdate() async {
|
||||
final urlStr = Platform.isIOS
|
||||
? (remoteInfo.iosUrl ?? '')
|
||||
: (remoteInfo.androidUrl ?? '');
|
||||
if (urlStr.isEmpty) return;
|
||||
final uri = Uri.tryParse(urlStr);
|
||||
if (uri == null) return;
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final updateDesc = remoteInfo.updateDesc ?? [];
|
||||
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isForce ? Icons.warning_amber_rounded : Icons.system_update,
|
||||
color: isForce ? Colors.orange : theme.colorScheme.primary,
|
||||
size: 24.w,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(isForce ? '重要更新' : '发现新版本'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 版本号
|
||||
Text(
|
||||
'v${remoteInfo.latestVersion ?? ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
// 更新日志标题
|
||||
if (updateDesc.isNotEmpty) ...[
|
||||
Text(
|
||||
'更新内容:',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
// 更新日志列表
|
||||
...updateDesc.map(
|
||||
(desc) => Padding(
|
||||
padding: EdgeInsets.only(bottom: 4.h),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'• ',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: const Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
desc,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: const Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// 强制更新提示
|
||||
if (isForce) ...[
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
'此版本为必要更新,请立即下载以继续使用',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (!isForce)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('稍后再说'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _launchUpdate,
|
||||
child: Text(isForce ? '立即更新' : '立即更新'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AudioPlayerCard】通用音频播放控制条(在线音频)
|
||||
///
|
||||
/// 一、为什么用 just_audio:
|
||||
/// 学习类 App 的课程音频(在线/列表/倍速/后台)需求全在它强项上:
|
||||
/// - 底层 ExoPlayer / AVPlayer / HTML5,原生解码稳定;
|
||||
/// - 支持网络流、播放列表 gapless 连播、倍速/变速、循环、音频会话;
|
||||
/// - 不带 UI,控制条由本组件自绘(对标 <audio> 标签的 controls)。
|
||||
///
|
||||
/// 二、props 对照表(Vue 开发者速查):
|
||||
/// url ← 在线音频地址(与 asset 二选一)
|
||||
/// asset ← 本地音频资源 assets/audio/xxx.mp3(本地文件 seek 秒响应)
|
||||
/// title ← 标题文本
|
||||
/// cover ← 封面缩略图 Widget(可选,显示在播放键左侧)
|
||||
/// autoPlay ← autoplay(默认 false)
|
||||
/// looping ← loop(默认 false,单曲循环)
|
||||
/// volume ← volume(默认 1.0)
|
||||
/// playbackSpeeds ← 倍速档位列表(默认 0.5x/1x/1.5x/2x)
|
||||
///
|
||||
/// 三、生命周期(最重要):
|
||||
/// 1. initState 里 setUrl 预加载,dispose 里 _player.dispose() 释放;
|
||||
/// 2. 通过 WidgetsBindingObserver 监听 App 生命周期,
|
||||
/// 切后台/来电自动暂停,防止声音还在后台播放;
|
||||
/// 3. UI 用 StreamBuilder 监听 position / duration / playerState 三个流,
|
||||
/// 播放进度、按钮状态实时刷新(Flutter 响应式写法)。
|
||||
///
|
||||
/// 四、高频坑:
|
||||
/// 1. setUrl 返回 Future,必须 await 完成后再让用户操作,
|
||||
/// 期间展示 loading(buffering 状态也走 loading 样式);
|
||||
/// 2. just_audio 是 0.x 版本,0.9→0.10 改过 setAudioSource API,
|
||||
/// 升级前先看 pub.dev changelog;
|
||||
/// 3. Slider 的 max 不能是 0(未加载到时长时 clamp 到 ≥1);
|
||||
/// 4. 切后台必须暂停:Android 上不暂停声音会一直播到结束,
|
||||
/// 且会被系统判定为"异常后台播放";
|
||||
/// 5. 网络流 seek 跳播后必有一次重新缓冲(ExoPlayer 机制),
|
||||
/// 本组件用 bufferedPositionStream 把缓冲进度画在进度条上
|
||||
/// (secondaryTrackValue),并显示"缓冲中 X%"提示,避免误以为卡死;
|
||||
/// 6. ⚠ seek 能不能跳对位置,取决于服务器是否支持 HTTP Range:
|
||||
/// 支持(返回 206 Partial Content)→ 从目标字节开始续传,跳播正常;
|
||||
/// 不支持(返回 200 全量)→ ExoPlayer 无法字节定位,只能从头重新
|
||||
/// 下载,表现就是"点中间段位置被重置回起点"(且第二次打开有系统
|
||||
/// 缓存时会看似正常,极易误判)。验收新音频源:
|
||||
/// curl -s -D - -o NUL -H "Range: bytes=0-1023" <url> 看状态码。
|
||||
/// ============================================================
|
||||
class AudioPlayerCard extends StatefulWidget {
|
||||
const AudioPlayerCard({
|
||||
super.key,
|
||||
this.url,
|
||||
this.asset,
|
||||
this.title,
|
||||
this.cover,
|
||||
this.autoPlay = false,
|
||||
this.looping = false,
|
||||
this.volume = 1.0,
|
||||
this.playbackSpeeds = const [0.5, 1.0, 1.5, 2.0],
|
||||
}) : assert(url != null || asset != null, 'url 与 asset 必须传一个');
|
||||
|
||||
/// 在线音频地址(http/https 直链,与 asset 二选一)
|
||||
final String? url;
|
||||
|
||||
/// 本地音频资源(assets/audio/xxx.mp3,与 url 二选一;
|
||||
/// 本地文件 seek 跳播无需重新缓冲,秒响应)
|
||||
final String? asset;
|
||||
|
||||
/// 标题文本(不传则显示"音频")
|
||||
final String? title;
|
||||
|
||||
/// 封面缩略图(可选,如课程封面)
|
||||
final Widget? cover;
|
||||
|
||||
/// 是否自动播放
|
||||
final bool autoPlay;
|
||||
|
||||
/// 是否单曲循环
|
||||
final bool looping;
|
||||
|
||||
/// 初始音量(0.0 ~ 1.0)
|
||||
final double volume;
|
||||
|
||||
/// 倍速档位列表
|
||||
final List<double> playbackSpeeds;
|
||||
|
||||
@override
|
||||
State<AudioPlayerCard> createState() => _AudioPlayerCardState();
|
||||
}
|
||||
|
||||
class _AudioPlayerCardState extends State<AudioPlayerCard>
|
||||
with WidgetsBindingObserver {
|
||||
/// 底层播放器(just_audio)
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
|
||||
/// 是否正在加载(setUrl 未完成)
|
||||
bool _loading = true;
|
||||
|
||||
/// 加载/播放错误(null = 无错误)
|
||||
String? _error;
|
||||
|
||||
/// 当前倍速
|
||||
double _speed = 1.0;
|
||||
|
||||
/// 是否正在拖动进度条(拖动中不触发 seek,松手才 seek 一次)
|
||||
bool _dragging = false;
|
||||
|
||||
/// 拖动中的临时位置(毫秒,用于滑块跟手)
|
||||
double? _dragValue;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 监听 App 生命周期:切后台自动暂停
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initPlayer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// 必须释放播放器,否则音频解码资源泄漏
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// App 切后台 / 来电 → 自动暂停
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed) {
|
||||
_player.pause();
|
||||
}
|
||||
}
|
||||
|
||||
/// 预加载音频源,设置初始音量/循环/倍速
|
||||
Future<void> _initPlayer() async {
|
||||
try {
|
||||
// 本地资源用 setAsset(seek 秒响应),在线用 setUrl
|
||||
if (widget.asset != null) {
|
||||
await _player.setAsset(widget.asset!);
|
||||
} else {
|
||||
await _player.setUrl(widget.url!);
|
||||
}
|
||||
if (widget.volume != 1.0) {
|
||||
await _player.setVolume(widget.volume);
|
||||
}
|
||||
if (widget.looping) {
|
||||
await _player.setLoopMode(LoopMode.one);
|
||||
}
|
||||
if (widget.autoPlay) {
|
||||
_player.play();
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放/暂停切换
|
||||
Future<void> _togglePlay() async {
|
||||
if (_player.playing) {
|
||||
await _player.pause();
|
||||
} else {
|
||||
await _player.play();
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换倍速
|
||||
Future<void> _changeSpeed(double speed) async {
|
||||
await _player.setSpeed(speed);
|
||||
setState(() => _speed = speed);
|
||||
}
|
||||
|
||||
/// 时间格式化:mm:ss
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.toString().padLeft(2, '0');
|
||||
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFEEF1F5)),
|
||||
),
|
||||
child: _buildContent(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 三种状态:loading / 错误 / 播放器
|
||||
Widget _buildContent() {
|
||||
if (_error != null) {
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'音频加载失败\n$_error',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFF999999), fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (_loading) {
|
||||
return const Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text('加载中...', style: TextStyle(color: Color(0xFF999999))),
|
||||
],
|
||||
);
|
||||
}
|
||||
return _buildPlayer();
|
||||
}
|
||||
|
||||
/// 播放器主体:按钮行 + 进度行(StreamBuilder 实时刷新)
|
||||
Widget _buildPlayer() {
|
||||
return StreamBuilder<PlayerState>(
|
||||
stream: _player.playerStateStream,
|
||||
builder: (context, stateSnap) {
|
||||
final state = stateSnap.data;
|
||||
final playing = state?.playing ?? false;
|
||||
final buffering = state?.processingState == ProcessingState.buffering;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: _player.positionStream,
|
||||
builder: (context, posSnap) {
|
||||
final position = posSnap.data ?? Duration.zero;
|
||||
return StreamBuilder<Duration?>(
|
||||
// bufferedPositionStream:当前已缓冲到的位置。
|
||||
// 网络流 seek 跳播后必有一次重新缓冲,用这个把
|
||||
// "已缓冲区域"画在进度条上(secondaryTrackValue)
|
||||
stream: _player.bufferedPositionStream,
|
||||
builder: (context, buffSnap) {
|
||||
final buffered = buffSnap.data ?? Duration.zero;
|
||||
return StreamBuilder<Duration>(
|
||||
// just_audio 0.10 的 durationStream 是 Stream<Duration?>,
|
||||
// 用 map 把 null(未加载到时长)归一成 Duration.zero
|
||||
stream: _player.durationStream.map(
|
||||
(d) => d ?? Duration.zero,
|
||||
),
|
||||
builder: (context, durSnap) {
|
||||
final duration = durSnap.data ?? Duration.zero;
|
||||
// Slider 的 max 不能为 0,没拿到时长前保底 1
|
||||
//(clamp 返回 num,要 toDouble)
|
||||
final max = duration.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(1, double.infinity)
|
||||
.toDouble();
|
||||
final value = position.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0, max)
|
||||
.toDouble();
|
||||
final bufferedValue = buffered.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0, max)
|
||||
.toDouble();
|
||||
// 缓冲百分比(duration 为 0 时显示 0%)
|
||||
final percent = duration.inMilliseconds > 0
|
||||
? (buffered.inMilliseconds /
|
||||
duration.inMilliseconds *
|
||||
100)
|
||||
.round()
|
||||
: 0;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ---- 第一行:封面 + 播放键 + 标题 + 倍速 ----
|
||||
Row(
|
||||
children: [
|
||||
if (widget.cover != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: widget.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
_buildPlayButton(playing, buffering),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: _buildTitle(
|
||||
buffering: buffering,
|
||||
percent: percent,
|
||||
),
|
||||
),
|
||||
_buildSpeedMenu(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ---- 第二行:当前时间 + 进度条 + 总时长 ----
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_fmt(position),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 14,
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
// 拖动中显示手指位置(不被 positionStream 拉回)
|
||||
value: _dragging && _dragValue != null
|
||||
? _dragValue!
|
||||
: value,
|
||||
max: max,
|
||||
// 次要轨道 = 已缓冲区域(浅色)
|
||||
secondaryTrackValue: bufferedValue,
|
||||
activeColor: const Color(0xFF3B82F6),
|
||||
inactiveColor: const Color(0xFFE3E8EF),
|
||||
// 缓冲中禁用拖动,避免无效 seek 把播放位置打回开头
|
||||
onChanged: buffering
|
||||
? null
|
||||
: (v) => setState(() {
|
||||
_dragging = true;
|
||||
_dragValue = v;
|
||||
}),
|
||||
// 松手才真正 seek 一次:
|
||||
// 拖动中每 tick 都 seek 会让网络流反复重建缓冲,
|
||||
// 导致位置被重置回起始(核心修复)
|
||||
onChangeEnd: buffering
|
||||
? null
|
||||
: (v) {
|
||||
setState(() => _dragging = false);
|
||||
_player.seek(
|
||||
Duration(milliseconds: v.round()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_fmt(duration),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 播放/暂停按钮(buffering 时转圈)
|
||||
Widget _buildPlayButton(bool playing, bool buffering) {
|
||||
return IconButton(
|
||||
iconSize: 42,
|
||||
padding: EdgeInsets.zero,
|
||||
color: const Color(0xFF3B82F6),
|
||||
icon: buffering
|
||||
? const SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
)
|
||||
: Icon(
|
||||
playing ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
),
|
||||
onPressed: buffering ? null : _togglePlay,
|
||||
);
|
||||
}
|
||||
|
||||
/// 标题区:标题 + 地址/缓冲提示小字
|
||||
Widget _buildTitle({required bool buffering, required int percent}) {
|
||||
// 小字:缓冲中显示百分比,否则显示源地址
|
||||
final String subText;
|
||||
if (buffering) {
|
||||
subText = '缓冲中 $percent%...';
|
||||
} else if (widget.asset != null) {
|
||||
subText = widget.asset!;
|
||||
} else {
|
||||
subText = widget.url ?? '';
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.title ?? (widget.asset != null ? '本地音频' : '音频'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: buffering ? const Color(0xFFF59E0B) : const Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 倍速菜单(PopupMenuButton,对标倍速选择器)
|
||||
Widget _buildSpeedMenu() {
|
||||
return PopupMenuButton<double>(
|
||||
initialValue: _speed,
|
||||
onSelected: _changeSpeed,
|
||||
itemBuilder: (context) => widget.playbackSpeeds
|
||||
.map(
|
||||
(s) => PopupMenuItem<double>(
|
||||
value: s,
|
||||
child: Text(
|
||||
'${s}x',
|
||||
style: TextStyle(
|
||||
fontWeight: s == _speed ? FontWeight.w700 : FontWeight.w400,
|
||||
color: s == _speed
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${_speed}x',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF3B82F6),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, color: Color(0xFF3B82F6)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【CommonPagedList】通用分页列表组件(对标 z-paging,基于 infinite_scroll_pagination 5.x)
|
||||
///
|
||||
/// 一、为什么封装(对齐"很多很多分页接口和页面"的诉求):
|
||||
/// 分页页面的共性逻辑——PagingController 生命周期、下拉刷新、触底加载、
|
||||
/// hasMore 判断、错误重试、空状态、没有更多 footer——全部内聚在组件内部,
|
||||
/// 业务页面只需实现两个回调:load(网络请求)+ buildItem(渲染每行)。
|
||||
/// 写法体验对齐 z-paging,PagingController 完全隐藏,杜绝外部乱调用。
|
||||
///
|
||||
/// 二、核心 API(5.x 与 4.x 完全不同,别搜 4.x 老教程):
|
||||
/// - PagingController 构造直接传 getNextPageKey + fetchPage 两个回调;
|
||||
/// - PagingListener 监听 controller,把 state + fetchNextPage 交给渲染层;
|
||||
/// - PagedListView/PagedGridView 接收 state/fetchNextPage/builderDelegate;
|
||||
/// - PagedChildBuilderDelegate 提供 6 个 indicator builder(首屏/翻页的
|
||||
/// loading、错误、空状态、没有更多),不传则用库内置英文默认。
|
||||
///
|
||||
/// 三、封装层做的 4 件事:
|
||||
/// 1. firstPageKey 透传:getNextPageKey 在 keys 为空时返回 firstPageKey
|
||||
/// (默认 1,正好对齐 pageIndex 从 1 开始),之后每次 +1;
|
||||
/// 2. hasMore 翻译:业务 load 返回 PagingResult.hasNext,封装层记录到
|
||||
/// _hasMore,getNextPageKey 读到 false 就返回 null(库自动停);
|
||||
/// 3. 错误接管:业务 load 直接抛异常即可,库自动渲染错误+重试按钮,
|
||||
/// 业务不写 try-catch;
|
||||
/// 4. 生命周期:controller 在 State.dispose 里销毁,防内存泄漏。
|
||||
///
|
||||
/// 四、游标分页(本项目接口特色)适配:
|
||||
/// 接口要 pageIndex + prevPageId 两个参数,而 load 只给 pageKey。
|
||||
/// 解法:pageKey 当 pageIndex,游标 prevPageId 在业务页面闭包里维护一个
|
||||
/// 私有变量(首次 0,每次从响应更新)。见 my_point_page.dart 的 load。
|
||||
///
|
||||
/// 五、高频坑(本组件已处理):
|
||||
/// 1. PagedChildBuilderDelegate 没有 separatorBuilder 参数,分隔线要走
|
||||
/// PagedListView.separated 构造,本组件已按参数自动切换;
|
||||
/// 2. RefreshIndicator 要求子组件 AlwaysScrollableScrollPhysics,否则
|
||||
/// 内容不满一屏下拉手势无效,本组件已内置;
|
||||
/// 3. 首屏加载由库自动触发(status == loadingFirstPage 时挂载即 fetch),
|
||||
/// 下拉刷新调 controller.refresh() 重置后同样自动重载;
|
||||
/// 4. 【致命坑】_hasMore 必须在刷新时重置为 true:全部分页加载完后
|
||||
/// _hasMore=false,若不重置,refresh 重置 state 后首屏请求会被
|
||||
/// getNextPageKey 拒绝,状态永远卡在 loadingFirstPage 死循环,
|
||||
/// 表现为"不停转圈且不再拉接口"(本组件 _handleRefresh 已处理);
|
||||
/// 5. 若未来需要"返回列表页刷新",可给组件加 GlobalKey 暴露 refresh(),
|
||||
/// 当前页面每次 push 都是新 State 天然重载,无需此能力。
|
||||
/// ============================================================
|
||||
|
||||
/// 统一分页返回契约:所有分页接口都翻译成这个结构返回给组件
|
||||
class PagingResult<T> {
|
||||
const PagingResult({required this.data, this.hasNext = false});
|
||||
|
||||
/// 本页数据列表
|
||||
final List<T> data;
|
||||
|
||||
/// 是否还有下一页(组件根据它决定是否继续加载)
|
||||
final bool hasNext;
|
||||
}
|
||||
|
||||
/// 分页加载回调:业务页面实现它,只写网络请求 + 返回数据,不接触分页库
|
||||
typedef PagedLoad<T> = Future<PagingResult<T>> Function(int pageKey);
|
||||
|
||||
class CommonPagedList<T> extends StatefulWidget {
|
||||
const CommonPagedList({
|
||||
super.key,
|
||||
required this.load,
|
||||
required this.buildItem,
|
||||
this.firstPageKey = 1,
|
||||
this.gridDelegate,
|
||||
this.separatorBuilder,
|
||||
this.padding,
|
||||
this.emptyWidget,
|
||||
this.noMoreText = '— 没有更多了 —',
|
||||
this.invisibleItemsThreshold = 0,
|
||||
this.enableRefresh = true,
|
||||
});
|
||||
|
||||
/// 分页加载回调(业务页面唯一必须实现的逻辑之一)
|
||||
/// [pageKey] 首屏为 [firstPageKey],之后自动 +1,业务直接当 pageIndex 用
|
||||
final PagedLoad<T> load;
|
||||
|
||||
/// 单条渲染回调 (context, item, index)
|
||||
final Widget Function(BuildContext context, T item, int index) buildItem;
|
||||
|
||||
/// 首页页码,默认 1(对齐 pageIndex 从 1 开始)
|
||||
final int firstPageKey;
|
||||
|
||||
/// 传了 gridDelegate 就渲染 PagedGridView,否则 PagedListView
|
||||
final SliverGridDelegate? gridDelegate;
|
||||
|
||||
/// 行间分隔线(不传则无分隔线)
|
||||
final Widget Function(BuildContext context, int index)? separatorBuilder;
|
||||
|
||||
/// 列表内边距
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// 空列表占位(不传显示默认"暂无数据")
|
||||
final Widget? emptyWidget;
|
||||
|
||||
/// "没有更多"底部文案
|
||||
final String noMoreText;
|
||||
|
||||
/// 距离底部还剩几项时触发加载下一页。
|
||||
/// 默认 0 = 滚到最后一个 item 才开始加载(避免一进页面就预拉第二页);
|
||||
/// 若希望滚动更顺滑可调大(如 3),代价是提前多拉一页。
|
||||
final int invisibleItemsThreshold;
|
||||
|
||||
/// 是否启用下拉刷新(默认开启)
|
||||
final bool enableRefresh;
|
||||
|
||||
@override
|
||||
State<CommonPagedList<T>> createState() => _CommonPagedListState<T>();
|
||||
}
|
||||
|
||||
class _CommonPagedListState<T> extends State<CommonPagedList<T>> {
|
||||
/// 业务返回的"是否还有更多",getNextPageKey 据此决定是否继续
|
||||
bool _hasMore = true;
|
||||
|
||||
/// PagingController 完全封装在 State 内部(对标 z-paging 内置 controller),
|
||||
/// 业务页面永远拿不到它,防止外部乱调用破坏分页状态。
|
||||
late final PagingController<int, T> _controller = PagingController<int, T>(
|
||||
// 每次 fetch 前调用:返回本次要加载的 pageKey,返回 null 表示没有更多
|
||||
getNextPageKey: (state) {
|
||||
if (!_hasMore) return null; // 业务说没有更多了
|
||||
if (state.lastPageIsEmpty) return null; // 兜底:空页即结束
|
||||
final lastKey = state.keys?.last;
|
||||
return lastKey == null ? widget.firstPageKey : lastKey + 1;
|
||||
},
|
||||
// fetch 一页:调用业务 load,把 PagingResult 翻译成 List 交给分页库
|
||||
fetchPage: (pageKey) async {
|
||||
final result = await widget.load(pageKey);
|
||||
_hasMore = result.hasNext;
|
||||
return result.data;
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 内存泄漏红线:controller 必须销毁(它会释放内部监听和 fetch 操作)
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 下拉刷新:
|
||||
/// ① 先重置 _hasMore(否则全部分页加载完后 _hasMore=false,
|
||||
/// refresh 重置 state 后第一页请求会被 getNextPageKey 拒绝,
|
||||
/// 导致永远停在 loadingFirstPage 死循环不拉接口——实测 bug);
|
||||
/// ② controller.refresh() 重置分页状态(库自动重新请求第一页);
|
||||
/// ③ 等待首屏加载结束再收起 RefreshIndicator 转圈(体验对齐原生)。
|
||||
Future<void> _handleRefresh() async {
|
||||
_hasMore = true;
|
||||
_controller.refresh();
|
||||
|
||||
// 监听状态直到离开 loadingFirstPage(加载完成/失败/空)
|
||||
final completer = Completer<void>();
|
||||
void listener() {
|
||||
if (_controller.status != PagingStatus.loadingFirstPage &&
|
||||
!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
_controller.addListener(listener);
|
||||
listener(); // 立即检查一次:refresh 后可能已同步进入 loading
|
||||
await completer.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {},
|
||||
);
|
||||
_controller.removeListener(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PagingListener<int, T>(
|
||||
controller: _controller,
|
||||
builder: (context, state, fetchNextPage) {
|
||||
final delegate = _buildDelegate(state, fetchNextPage);
|
||||
final Widget pagedView = _buildPagedView(state, fetchNextPage, delegate);
|
||||
if (!widget.enableRefresh) return pagedView;
|
||||
return RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
child: pagedView,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据参数组装 PagedListView / PagedListView.separated / PagedGridView
|
||||
Widget _buildPagedView(
|
||||
PagingState<int, T> state,
|
||||
VoidCallback fetchNextPage,
|
||||
PagedChildBuilderDelegate<T> delegate,
|
||||
) {
|
||||
// RefreshIndicator 要求子组件可滚动;内容不满一屏时也必须能下拉
|
||||
final physics =
|
||||
widget.enableRefresh ? const AlwaysScrollableScrollPhysics() : null;
|
||||
|
||||
if (widget.gridDelegate != null) {
|
||||
return PagedGridView<int, T>(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
gridDelegate: widget.gridDelegate!,
|
||||
builderDelegate: delegate,
|
||||
padding: widget.padding,
|
||||
physics: physics,
|
||||
);
|
||||
}
|
||||
if (widget.separatorBuilder != null) {
|
||||
return PagedListView<int, T>.separated(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
separatorBuilder: widget.separatorBuilder!,
|
||||
builderDelegate: delegate,
|
||||
padding: widget.padding,
|
||||
physics: physics,
|
||||
);
|
||||
}
|
||||
return PagedListView<int, T>(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
builderDelegate: delegate,
|
||||
padding: widget.padding,
|
||||
physics: physics,
|
||||
);
|
||||
}
|
||||
|
||||
/// 组装 6 个状态 UI(loading / 错误 / 空 / 没有更多),业务可传空状态覆盖
|
||||
PagedChildBuilderDelegate<T> _buildDelegate(
|
||||
PagingState<int, T> state,
|
||||
VoidCallback fetchNextPage,
|
||||
) {
|
||||
return PagedChildBuilderDelegate<T>(
|
||||
itemBuilder: widget.buildItem,
|
||||
invisibleItemsThreshold: widget.invisibleItemsThreshold,
|
||||
// ---------- 首屏 loading ----------
|
||||
firstPageProgressIndicatorBuilder: (_) =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
// ---------- 首屏失败:整页错误 + 重试按钮 ----------
|
||||
firstPageErrorIndicatorBuilder: (_) => _FirstPageError(
|
||||
error: state.error,
|
||||
onRetry: fetchNextPage,
|
||||
),
|
||||
// ---------- 翻页 loading(列表底部) ----------
|
||||
newPageProgressIndicatorBuilder: (_) => Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
// ---------- 翻页失败(列表底部,点击重试) ----------
|
||||
newPageErrorIndicatorBuilder: (_) => _ErrorFooter(onRetry: fetchNextPage),
|
||||
// ---------- 空状态 ----------
|
||||
noItemsFoundIndicatorBuilder: (_) =>
|
||||
widget.emptyWidget ??
|
||||
Center(
|
||||
child: Text(
|
||||
'暂无数据',
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
),
|
||||
// ---------- 没有更多(列表底部) ----------
|
||||
noMoreItemsIndicatorBuilder: (_) => Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.noMoreText,
|
||||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 首屏加载失败:错误图标 + 文案 + 重新加载按钮
|
||||
class _FirstPageError extends StatelessWidget {
|
||||
const _FirstPageError({required this.error, required this.onRetry});
|
||||
|
||||
final Object? error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.wifi_off_rounded, size: 48.w, color: Colors.grey.shade400),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
'加载失败,请检查网络',
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
FilledButton(onPressed: onRetry, child: const Text('重新加载')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页失败 footer:底部"加载失败,点击重试"
|
||||
class _ErrorFooter extends StatelessWidget {
|
||||
const _ErrorFooter({required this.onRetry});
|
||||
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onRetry,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'加载失败,点击重试',
|
||||
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF3B82F6)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ImagePreviewViewer】通用图片预览组件
|
||||
///
|
||||
/// 一、作用:
|
||||
/// 统一承接项目里的图片大图预览:头像、题目图、讲义图、笔记图,
|
||||
/// 都走这一套,不再每个页面各自写 Dialog/PageView。
|
||||
///
|
||||
/// 二、能力:
|
||||
/// 1. 单图 / 多图预览;
|
||||
/// 2. 双指缩放、拖拽、双击放大;
|
||||
/// 3. 多图左右切换;
|
||||
/// 4. 可选 HeroTag,缩略图到大图过渡更顺滑。
|
||||
///
|
||||
/// 三、高频坑:
|
||||
/// 1. 预览页是全屏页面,外层最好直接 push,不要塞进受限高度的 Dialog;
|
||||
/// 2. Widget 是配置,不是真实 View。这里传入的是 ImageProvider,
|
||||
/// 所以网络图、本地图、内存图都能统一处理;
|
||||
/// 3. 多图切换本质还是 PageView 横向滚动,PhotoView 负责缩放和平移。
|
||||
/// ============================================================
|
||||
class PreviewImageItem {
|
||||
const PreviewImageItem({
|
||||
required this.imageProvider,
|
||||
this.heroTag,
|
||||
this.label,
|
||||
});
|
||||
|
||||
final ImageProvider imageProvider;
|
||||
final Object? heroTag;
|
||||
final String? label;
|
||||
}
|
||||
|
||||
class ImagePreviewViewer extends StatefulWidget {
|
||||
const ImagePreviewViewer({
|
||||
super.key,
|
||||
required this.items,
|
||||
this.initialIndex = 0,
|
||||
});
|
||||
|
||||
final List<PreviewImageItem> items;
|
||||
final int initialIndex;
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required List<PreviewImageItem> items,
|
||||
int initialIndex = 0,
|
||||
}) async {
|
||||
if (items.isEmpty) return;
|
||||
// 进入预览:隐藏状态栏 + 导航栏,图片铺满全屏
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky,
|
||||
);
|
||||
await Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ImagePreviewViewer(
|
||||
items: items,
|
||||
initialIndex: initialIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
// 退出预览:恢复系统 UI
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.edgeToEdge,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> showSingle(
|
||||
BuildContext context, {
|
||||
required ImageProvider imageProvider,
|
||||
Object? heroTag,
|
||||
String? label,
|
||||
}) {
|
||||
return show(
|
||||
context,
|
||||
items: [
|
||||
PreviewImageItem(
|
||||
imageProvider: imageProvider,
|
||||
heroTag: heroTag,
|
||||
label: label,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ImagePreviewViewer> createState() => _ImagePreviewViewerState();
|
||||
}
|
||||
|
||||
class _ImagePreviewViewerState extends State<ImagePreviewViewer> {
|
||||
late final PageController _pageController;
|
||||
late int _currentIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentIndex = widget.initialIndex;
|
||||
_pageController = PageController(initialPage: widget.initialIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = widget.items;
|
||||
final topPadding = MediaQuery.paddingOf(context).top;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
itemCount: items.length,
|
||||
pageController: _pageController,
|
||||
backgroundDecoration: const BoxDecoration(color: Colors.black),
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
},
|
||||
builder: (context, index) {
|
||||
final item = items[index];
|
||||
return PhotoViewGalleryPageOptions(
|
||||
imageProvider: item.imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: PhotoViewComputedScale.covered * 3,
|
||||
heroAttributes: item.heroTag == null
|
||||
? null
|
||||
: PhotoViewHeroAttributes(tag: item.heroTag!),
|
||||
onTapUp: (tapContext, details, value) =>
|
||||
Navigator.of(tapContext).maybePop(),
|
||||
);
|
||||
},
|
||||
loadingBuilder: (context, event) {
|
||||
final expected = event?.expectedTotalBytes;
|
||||
final loaded = event?.cumulativeBytesLoaded ?? 0;
|
||||
final progress =
|
||||
expected != null && expected > 0 ? loaded / expected : null;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (items.length > 1)
|
||||
Positioned(
|
||||
top: topPadding + 16,
|
||||
right: 16,
|
||||
child: Text(
|
||||
'${_currentIndex + 1}/${items.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:smooth_sheets/smooth_sheets.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【PersistentSnapSheet】常驻多停靠底部面板(smooth_sheets 封装)
|
||||
///
|
||||
/// 一、为什么封装:
|
||||
/// smooth_sheets 原生 API 组件多(SheetViewport / Sheet / SheetSnapGrid /
|
||||
/// SheetPhysics / MaterialSheetDecoration / SheetScrollConfiguration),
|
||||
/// 每次使用都要写一长串配置。本组件把"常驻 + 多停靠点 + 内部可滚动"
|
||||
/// 这个最常见场景封装成一个开箱即用的 Widget。
|
||||
///
|
||||
/// 二、核心参数:
|
||||
/// snapFactors : 停靠点列表(视口高度比例),如 [0.12, 0.5, 0.85]
|
||||
/// · 最少 2 个,推荐 3 个
|
||||
/// · 值域 0.0~1.0,0 = 完全隐藏,1 = 全屏
|
||||
/// child : 面板内容(通常顶层是 Column,内部放 ListView 等)
|
||||
/// controller : 可选,用于代码控制面板跳到某个停靠点
|
||||
///
|
||||
/// 三、默认行为:
|
||||
/// · 初始位置:snapFactors 中间值
|
||||
/// · 物理引擎:BouncingSheetPhysics(iOS 弹性风格)
|
||||
/// · 外观:白色背景 + 顶部圆角 16 + 轻微阴影
|
||||
/// · 滚动联动:onlyFromTop(列表滚到顶后才能拖面板)
|
||||
///
|
||||
/// 四、使用示例:
|
||||
/// PersistentSnapSheet(
|
||||
/// snapFactors: [0.12, 0.5, 0.85],
|
||||
/// child: Column(
|
||||
/// children: [
|
||||
/// // 拖拽手柄
|
||||
/// _buildHandle(),
|
||||
/// // 可滚动内容
|
||||
/// Expanded(child: ListView(...)),
|
||||
/// ],
|
||||
/// ),
|
||||
/// )
|
||||
///
|
||||
/// 五、smooth_sheets 关键组件对照:
|
||||
/// SheetViewport → 非模态面板的容器(提供布局边界)
|
||||
/// Sheet → 面板本体(拖拽 + 吸附 + 滚动联动)
|
||||
/// SheetSnapGrid → 定义停靠点集合
|
||||
/// SheetOffset → 停靠点位置(factor = 视口高度比例)
|
||||
/// SheetPhysics → 物理引擎(弹性 / 夹紧)
|
||||
/// MaterialSheetDecoration → 外观(Material 风格:圆角/阴影/颜色)
|
||||
/// SheetScrollConfiguration → 滚动联动策略
|
||||
/// ============================================================
|
||||
class PersistentSnapSheet extends StatelessWidget {
|
||||
const PersistentSnapSheet({
|
||||
super.key,
|
||||
required this.snapFactors,
|
||||
required this.child,
|
||||
this.controller,
|
||||
this.physics,
|
||||
this.decoration,
|
||||
this.initialOffsetIndex,
|
||||
}) : assert(snapFactors.length >= 2, '至少需要 2 个停靠点');
|
||||
|
||||
/// 停靠点因子列表(视口高度比例,0.0~1.0)
|
||||
/// 例如 [0.12, 0.5, 0.85] = 底部收起 / 半屏 / 大半屏
|
||||
final List<double> snapFactors;
|
||||
|
||||
/// 面板内容(推荐顶层 Column:手柄 + Expanded(可滚动内容))
|
||||
final Widget child;
|
||||
|
||||
/// 可选控制器(代码控制面板位置:animateTo / snap 等)
|
||||
final SheetController? controller;
|
||||
|
||||
/// 物理引擎(默认 BouncingSheetPhysics = iOS 弹性风格)
|
||||
final SheetPhysics? physics;
|
||||
|
||||
/// 面板外观(默认白色 + 圆角 16 + 阴影 4)
|
||||
final SheetDecoration? decoration;
|
||||
|
||||
/// 初始停靠点索引(默认取中间值)
|
||||
final int? initialOffsetIndex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 将因子列表转为 SheetOffset 列表
|
||||
final snaps = snapFactors.map((f) => SheetOffset(f)).toList();
|
||||
|
||||
// 初始位置:取中间停靠点
|
||||
final initialIdx = initialOffsetIndex ?? (snapFactors.length ~/ 2);
|
||||
|
||||
return SheetViewport(
|
||||
child: Sheet(
|
||||
controller: controller,
|
||||
initialOffset: SheetOffset(snapFactors[initialIdx]),
|
||||
snapGrid: SheetSnapGrid(snaps: snaps),
|
||||
physics: physics ?? const BouncingSheetPhysics(),
|
||||
// 滚动联动:onlyFromTop = 列表滚到顶后才能下拉面板
|
||||
scrollConfiguration: const SheetScrollConfiguration(
|
||||
scrollSyncMode: SheetScrollHandlingBehavior.onlyFromTop,
|
||||
),
|
||||
decoration: decoration ??
|
||||
MaterialSheetDecoration(
|
||||
size: SheetSize.stretch,
|
||||
color: Colors.white,
|
||||
elevation: 4,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:new_learn/core/providers/privacy_provider.dart';
|
||||
import 'package:new_learn/features/auth/privacy_consent_page.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【PrivacyStartupGate】启动层隐私拦截
|
||||
///
|
||||
/// 方案 A:
|
||||
/// 放在 MaterialApp.router.builder 这一层。
|
||||
/// 好处是冷启动时不会先闪一下登录页/主页,再被重定向到隐私页,
|
||||
/// 而是直接用全屏 Gate 先拦住。
|
||||
///
|
||||
/// 和 go_router 守卫的分工:
|
||||
/// - StartupGate:负责首次启动的"视觉第一拦";
|
||||
/// - Router redirect:负责深链 / 刷新 / 运行中导航时的兜底拦截。
|
||||
/// ============================================================
|
||||
class PrivacyStartupGate extends StatelessWidget {
|
||||
const PrivacyStartupGate({
|
||||
super.key,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: privacyAcceptedNotifier,
|
||||
builder: (context, accepted, _) {
|
||||
if (accepted) return child;
|
||||
|
||||
// 关键点:
|
||||
// 启动隐私 Gate 不能把 router child 整个替换掉,否则底下真正的根
|
||||
// Navigator 不会建立起来,协议正文页就没有地方 push。
|
||||
// 这里改成"路由树先建好但隐藏",再用隐私页盖在最上层。
|
||||
//
|
||||
// 注意:PrivacyConsentPage 在 builder 层,context 位于 MaterialApp
|
||||
// 内部 Navigator 之上,showDialog 找不到 Navigator 会失败。
|
||||
// 所以必须额外包一层 Navigator,给弹窗提供 push 目标。
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Offstage(
|
||||
offstage: true,
|
||||
child: child,
|
||||
),
|
||||
Navigator(
|
||||
onGenerateRoute: (settings) => PageRouteBuilder(
|
||||
settings: settings,
|
||||
pageBuilder: (_, __, ___) => const PrivacyConsentPage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
|
||||
import 'package:new_learn/core/components/audio_player_card.dart';
|
||||
import 'package:new_learn/core/components/video_player_view.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【RichTextContent】富文本渲染组件(题目/解析 HTML → Flutter widget)
|
||||
///
|
||||
/// 一、为什么用 flutter_widget_from_html_core:
|
||||
/// 1. 纯 widget 渲染(非 WebView)→ 题目分页列表性能好,全平台含 Web;
|
||||
/// 2. 支持 70+ 标签、内联 CSS、表格横滚、sub/sup 上标下标;
|
||||
/// 3. 活跃维护(0.17.2),要求 Flutter 3.32+(本项目满足)。
|
||||
/// 用 core 版而不是 enhanced:audio/video 标签我们要用自己封装的
|
||||
/// 组件渲染(见下文扩展点),不需要 fwfh 全家桶。
|
||||
///
|
||||
/// 二、props 对照表:
|
||||
/// html ← HTML 字符串(必填)
|
||||
/// textStyle ← 全局文本样式(字号调节/深色模式的入口)
|
||||
/// onTapUrl ← 链接点击回调(返回 true 表示已处理)
|
||||
/// customStylesBuilder ← 自定义标签样式(如把 .answer 高亮)
|
||||
/// customWidgetBuilder ← 自定义元素替换(把任意标签换成业务 widget)
|
||||
/// renderMode ← 渲染模式(默认 column;列表预览可用 listView)
|
||||
///
|
||||
/// 三、内置扩展点(本组件已经帮你处理的):
|
||||
/// 1. <audio> 标签 → 自动替换成 AudioPlayerCard(本项目封装的音频控制条)
|
||||
/// 有 title 属性则作为标题,无则显示"题目音频";
|
||||
/// 2. <video> 标签 → 自动替换成 VideoPlayerView(本项目内嵌播放器,
|
||||
/// 无 Scaffold/AppBar),默认 autoPlay: false(避免富文本里
|
||||
/// 多个视频同时解码占内存),取 title 属性作为错误提示;
|
||||
/// 3. 公式(\(...\) LaTeX)→ 需要 flutter_math_fork,后续扩展。
|
||||
///
|
||||
/// 四、学生端场景:
|
||||
/// - 字号调节:外部用 Slider 控制 textStyle.fontSize 即可(demo 页示范);
|
||||
/// - 深色模式:外部传 textStyle.color,页面背景由业务层控制;
|
||||
/// - 链接安全:默认拦截所有链接(防止题目里夹带外部危险链接),
|
||||
/// 需要放行时传 onTapUrl 自行处理。
|
||||
///
|
||||
/// 五、高频坑:
|
||||
/// 1. <img> 相对路径 src(如 /uploads/xxx.png)必须由后端返回完整 URL,
|
||||
/// 或业务层在渲染前拼接 baseUrl,否则图片加载 404;
|
||||
/// 2. 内联 style 只支持常用 CSS,复杂 flex/grid 布局会降级为块级排列;
|
||||
/// 3. customWidgetBuilder 返回的 widget 是块级(占整行),
|
||||
/// 想做行内替换请用 InlineCustomWidget 包裹;
|
||||
/// 4. 不要用 WebView 方案渲染题目(每题一个 WebView,列表会卡死)。
|
||||
/// ============================================================
|
||||
class RichTextContent extends StatelessWidget {
|
||||
const RichTextContent({
|
||||
super.key,
|
||||
required this.html,
|
||||
this.textStyle,
|
||||
this.onTapUrl,
|
||||
this.customStylesBuilder,
|
||||
this.customWidgetBuilder,
|
||||
this.renderMode = RenderMode.column,
|
||||
});
|
||||
|
||||
/// HTML 字符串(后端返回)
|
||||
final String html;
|
||||
|
||||
/// 全局文本样式(字号/颜色的统一入口)
|
||||
final TextStyle? textStyle;
|
||||
|
||||
/// 链接点击回调(返回 true 表示已处理,不返回 true 且是锚点时自动滚动)
|
||||
final Future<bool> Function(String url)? onTapUrl;
|
||||
|
||||
/// 自定义标签样式(如 .answer 高亮、图片圆角等)
|
||||
final CustomStylesBuilder? customStylesBuilder;
|
||||
|
||||
/// 自定义元素替换(audio 已被内置替换,video/公式等在此扩展)
|
||||
final CustomWidgetBuilder? customWidgetBuilder;
|
||||
|
||||
/// 渲染模式:column(默认,嵌在滚动容器里)/ listView(页面级)
|
||||
final RenderMode renderMode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HtmlWidget(
|
||||
html,
|
||||
textStyle: textStyle,
|
||||
onTapUrl: onTapUrl ?? _defaultOnTapUrl,
|
||||
customStylesBuilder: customStylesBuilder,
|
||||
customWidgetBuilder: _combinedCustomWidget,
|
||||
renderMode: renderMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// 合并默认替换 + 用户自定义替换
|
||||
CustomWidgetBuilder get _combinedCustomWidget => (element) {
|
||||
// 内置:<audio> → 本项目 AudioPlayerCard
|
||||
if (element.localName == 'audio') {
|
||||
final src = element.attributes['src'];
|
||||
if (src != null && src.isNotEmpty) {
|
||||
// 支持 asset:// 协议 → 本地音频(seek 秒响应)
|
||||
// 注意:网络源必须支持 HTTP Range(响应 206),
|
||||
// 否则 ExoPlayer 无法字节定位,seek 会失效"回到起始"。
|
||||
// 免费测试站(如 samplelib)常返回 200 不支持 Range,请勿用。
|
||||
final isAsset = src.startsWith('asset://');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: AudioPlayerCard(
|
||||
url: isAsset ? null : src,
|
||||
asset: isAsset ? src.substring('asset://'.length) : null,
|
||||
title: element.attributes['title'] ?? '题目音频',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 内置:<video> → 本项目 VideoPlayerView(内嵌播放器)
|
||||
// 默认不自动播,避免富文本里多个视频同时解码占内存
|
||||
if (element.localName == 'video') {
|
||||
final src = element.attributes['src'];
|
||||
if (src != null && src.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: VideoPlayerView(
|
||||
url: src,
|
||||
title: element.attributes['title'] ?? '题目视频',
|
||||
autoPlay: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 透传用户自定义替换(公式等扩展点)
|
||||
return customWidgetBuilder?.call(element);
|
||||
};
|
||||
|
||||
/// 默认链接拦截:防止题目里夹带外部危险链接
|
||||
static Future<bool> _defaultOnTapUrl(String url) async {
|
||||
debugPrint('[RichTextContent] 已拦截外部链接: $url');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【UserCircleAvatar】圆形网络头像
|
||||
///
|
||||
/// 通用组件:加载失败兜底灰底、占位背景。班级动态、积分排行等页面共用。
|
||||
/// ============================================================
|
||||
class UserCircleAvatar extends StatelessWidget {
|
||||
const UserCircleAvatar({super.key, required this.url, required this.radius});
|
||||
|
||||
final String url;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: const Color(0xFFF0F4FA),
|
||||
backgroundImage: CachedNetworkImageProvider(url),
|
||||
onBackgroundImageError: (_, _) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:new_learn/core/components/video_player_view.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【VideoPlayerPage】视频播放整页(Scaffold + VideoPlayerView)
|
||||
///
|
||||
/// 一、和 VideoPlayerView 的关系(拆分说明):
|
||||
/// - 本页是"整页播放"场景:黑底 + AppBar(带返回)+ 居中播放器;
|
||||
/// - 内嵌场景(富文本 <video>、列表卡片)请直接用 VideoPlayerView,
|
||||
/// 它没有 Scaffold/AppBar,可嵌任意布局。
|
||||
/// 两者共用同一套 props 和播放核心,逻辑都在 video_player_view.dart。
|
||||
///
|
||||
/// 二、props 全部透传给 VideoPlayerView,用法一致:
|
||||
/// url / title / aspectRatio / autoPlay / looping / muted / volume /
|
||||
/// startAt / isLive / showControls / allowFullScreen /
|
||||
/// allowPlaybackSpeedChanging / allowMuting / allowDragging /
|
||||
/// placeholder / playbackSpeeds / hideControlsTimer / materialProgressColors
|
||||
/// 详见 video_player_view.dart 的文档注释。
|
||||
/// ============================================================
|
||||
class VideoPlayerPage extends StatelessWidget {
|
||||
const VideoPlayerPage({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.title,
|
||||
this.aspectRatio,
|
||||
this.autoPlay = true,
|
||||
this.looping = false,
|
||||
this.muted = false,
|
||||
this.volume = 1.0,
|
||||
this.startAt = Duration.zero,
|
||||
this.isLive = false,
|
||||
this.showControls = true,
|
||||
this.allowFullScreen = true,
|
||||
this.allowPlaybackSpeedChanging = true,
|
||||
this.allowMuting = true,
|
||||
this.allowDragging = true,
|
||||
this.placeholder,
|
||||
this.playbackSpeeds,
|
||||
this.hideControlsTimer,
|
||||
this.materialProgressColors,
|
||||
});
|
||||
|
||||
/// 视频地址(http/https 直链,建议后端统一转 H.264 MP4)
|
||||
final String url;
|
||||
|
||||
/// 页面标题(AppBar 显示)
|
||||
final String? title;
|
||||
|
||||
/// 视频宽高比;不传 = 跟随视频本身尺寸
|
||||
final double? aspectRatio;
|
||||
|
||||
/// 是否自动播放
|
||||
final bool autoPlay;
|
||||
|
||||
/// 是否循环播放
|
||||
final bool looping;
|
||||
|
||||
/// 初始是否静音
|
||||
final bool muted;
|
||||
|
||||
/// 初始音量(0.0 ~ 1.0)
|
||||
final double volume;
|
||||
|
||||
/// 起始播放位置
|
||||
final Duration startAt;
|
||||
|
||||
/// 直播模式:隐藏进度条/快进/倍速
|
||||
final bool isLive;
|
||||
|
||||
/// 是否显示控制条
|
||||
final bool showControls;
|
||||
|
||||
/// 是否允许全屏
|
||||
final bool allowFullScreen;
|
||||
|
||||
/// 是否允许切换倍速
|
||||
final bool allowPlaybackSpeedChanging;
|
||||
|
||||
/// 是否允许静音(控制条上的静音按钮)
|
||||
final bool allowMuting;
|
||||
|
||||
/// 是否允许拖拽进度
|
||||
final bool allowDragging;
|
||||
|
||||
/// 封面占位图(加载完成前展示,对标 poster)
|
||||
final Widget? placeholder;
|
||||
|
||||
/// 倍速档位列表(默认 0.25x ~ 2.0x,传 null 用默认)
|
||||
final List<double>? playbackSpeeds;
|
||||
|
||||
/// 控制条自动隐藏延迟(默认 3 秒)
|
||||
final Duration? hideControlsTimer;
|
||||
|
||||
/// 进度条配色(Material 风格)
|
||||
final ChewieProgressColors? materialProgressColors;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(title ?? '视频播放'),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: VideoPlayerView(
|
||||
url: url,
|
||||
title: title,
|
||||
aspectRatio: aspectRatio,
|
||||
autoPlay: autoPlay,
|
||||
looping: looping,
|
||||
muted: muted,
|
||||
volume: volume,
|
||||
startAt: startAt,
|
||||
isLive: isLive,
|
||||
showControls: showControls,
|
||||
allowFullScreen: allowFullScreen,
|
||||
allowPlaybackSpeedChanging: allowPlaybackSpeedChanging,
|
||||
allowMuting: allowMuting,
|
||||
allowDragging: allowDragging,
|
||||
placeholder: placeholder,
|
||||
playbackSpeeds: playbackSpeeds,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
materialProgressColors: materialProgressColors,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【VideoPlayerView】通用视频播放器(内嵌版,无 Scaffold/AppBar)
|
||||
///
|
||||
/// 一、和 VideoPlayerPage 的关系(拆分说明):
|
||||
/// - VideoPlayerView:纯播放器,可嵌进任何页面/富文本/列表卡片,
|
||||
/// 自带圆角黑底 + loading/错误态 + 切后台自动暂停;
|
||||
/// - VideoPlayerPage:整页 = Scaffold + VideoPlayerView,
|
||||
/// 给"独立播放页"场景用。
|
||||
/// 两处共用同一套 props 和播放核心,一处改两处生效。
|
||||
///
|
||||
/// 二、props 对照表(同 VideoPlayerPage,Vue 开发者速查):
|
||||
/// url ← Vue 的 src(必填)
|
||||
/// title ← 标题(本组件内用于加载失败提示)
|
||||
/// aspectRatio ← 宽高比(默认跟随视频本身,兜底 16:9)
|
||||
/// autoPlay / looping / muted / volume / startAt / isLive
|
||||
/// showControls / allowFullScreen / allowPlaybackSpeedChanging
|
||||
/// allowMuting / allowDragging / placeholder / playbackSpeeds
|
||||
/// hideControlsTimer / materialProgressColors
|
||||
///
|
||||
/// 三、高频坑(承接 VideoPlayerPage):
|
||||
/// 1. dispose 顺序不能反:先 chewie 再 video_player(反了闪退);
|
||||
/// 2. await initialize() 后必须检查 mounted 再 setState;
|
||||
/// 3. Web 端不支持 VideoPlayerController.file,一律传网络 URL;
|
||||
/// 4. 列表页/富文本里嵌多个实例时:只让当前可见的实例播放,
|
||||
/// 其余用 autoPlay: false(否则多路同时解码占满内存);
|
||||
/// 5. 初始化成功后 aspectRatio 跟随视频真实比例,未成功前兜底 16:9。
|
||||
/// ============================================================
|
||||
class VideoPlayerView extends StatefulWidget {
|
||||
const VideoPlayerView({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.title,
|
||||
this.aspectRatio,
|
||||
this.autoPlay = false,
|
||||
this.looping = false,
|
||||
this.muted = false,
|
||||
this.volume = 1.0,
|
||||
this.startAt = Duration.zero,
|
||||
this.isLive = false,
|
||||
this.showControls = true,
|
||||
this.allowFullScreen = true,
|
||||
this.allowPlaybackSpeedChanging = true,
|
||||
this.allowMuting = true,
|
||||
this.allowDragging = true,
|
||||
this.placeholder,
|
||||
this.playbackSpeeds,
|
||||
this.hideControlsTimer,
|
||||
this.materialProgressColors,
|
||||
});
|
||||
|
||||
/// 视频地址(http/https 直链,建议后端统一转 H.264 MP4)
|
||||
final String url;
|
||||
|
||||
/// 标题(加载失败提示用)
|
||||
final String? title;
|
||||
|
||||
/// 视频宽高比;不传 = 跟随视频本身,初始化前兜底 16:9
|
||||
final double? aspectRatio;
|
||||
|
||||
/// 是否自动播放(列表/富文本内嵌默认 false,避免多路同时解码)
|
||||
final bool autoPlay;
|
||||
|
||||
/// 是否循环播放
|
||||
final bool looping;
|
||||
|
||||
/// 初始是否静音
|
||||
final bool muted;
|
||||
|
||||
/// 初始音量(0.0 ~ 1.0)
|
||||
final double volume;
|
||||
|
||||
/// 起始播放位置
|
||||
final Duration startAt;
|
||||
|
||||
/// 直播模式:隐藏进度条/快进/倍速
|
||||
final bool isLive;
|
||||
|
||||
/// 是否显示控制条
|
||||
final bool showControls;
|
||||
|
||||
/// 是否允许全屏
|
||||
final bool allowFullScreen;
|
||||
|
||||
/// 是否允许切换倍速
|
||||
final bool allowPlaybackSpeedChanging;
|
||||
|
||||
/// 是否允许静音(控制条上的静音按钮)
|
||||
final bool allowMuting;
|
||||
|
||||
/// 是否允许拖拽进度
|
||||
final bool allowDragging;
|
||||
|
||||
/// 封面占位图(加载完成前展示,对标 poster)
|
||||
final Widget? placeholder;
|
||||
|
||||
/// 倍速档位列表(默认 0.25x ~ 2.0x,传 null 用默认)
|
||||
final List<double>? playbackSpeeds;
|
||||
|
||||
/// 控制条自动隐藏延迟(默认 3 秒)
|
||||
final Duration? hideControlsTimer;
|
||||
|
||||
/// 进度条配色(Material 风格)
|
||||
final ChewieProgressColors? materialProgressColors;
|
||||
|
||||
@override
|
||||
State<VideoPlayerView> createState() => _VideoPlayerViewState();
|
||||
}
|
||||
|
||||
class _VideoPlayerViewState extends State<VideoPlayerView>
|
||||
with WidgetsBindingObserver {
|
||||
/// 官方底层控制器
|
||||
VideoPlayerController? _videoController;
|
||||
|
||||
/// chewie UI 控制器(初始化成功后才创建)
|
||||
ChewieController? _chewieController;
|
||||
|
||||
/// 是否正在初始化(loading 态)
|
||||
bool _initializing = true;
|
||||
|
||||
/// 初始化/播放错误信息(null = 无错误)
|
||||
String? _error;
|
||||
|
||||
/// 初始化成功后视频的真实宽高比(未成功前 null → 兜底 16:9)
|
||||
double? _aspectRatio;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 监听 App 生命周期:切后台自动暂停
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initPlayer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// 释放顺序不能反:先 chewie 再 video_player(反了会闪退)
|
||||
_chewieController?.dispose();
|
||||
_videoController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// App 切后台 / 来电 → 自动暂停,防止声音继续播
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed) {
|
||||
_videoController?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化播放器:解析视频头 → 设初始状态 → 建 chewie 控制器
|
||||
Future<void> _initPlayer() async {
|
||||
// 1. 创建官方底层控制器(此时还没真正加载视频)
|
||||
final videoController =
|
||||
VideoPlayerController.networkUrl(Uri.parse(widget.url));
|
||||
_videoController = videoController;
|
||||
|
||||
try {
|
||||
// 2. 必须 await 初始化,才能拿到 duration / aspectRatio
|
||||
await videoController.initialize();
|
||||
|
||||
// 3. 初始静音 / 音量(muted 优先,两者互斥时静音生效)
|
||||
if (widget.muted) {
|
||||
await videoController.setVolume(0);
|
||||
} else if (widget.volume != 1.0) {
|
||||
await videoController.setVolume(widget.volume);
|
||||
}
|
||||
|
||||
// 异步返回后页面可能已被销毁,必须检查 mounted
|
||||
if (!mounted) return;
|
||||
|
||||
// 4. 用 chewie 包一层 UI 控制器(所有 props 在这里落地)
|
||||
_chewieController = ChewieController(
|
||||
videoPlayerController: videoController,
|
||||
// 宽高比:传了用传的,没传用视频本身的
|
||||
aspectRatio: widget.aspectRatio ?? videoController.value.aspectRatio,
|
||||
autoPlay: widget.autoPlay,
|
||||
looping: widget.looping,
|
||||
showControls: widget.showControls,
|
||||
allowFullScreen: widget.allowFullScreen,
|
||||
allowPlaybackSpeedChanging: widget.allowPlaybackSpeedChanging,
|
||||
allowMuting: widget.allowMuting,
|
||||
draggableProgressBar: widget.allowDragging,
|
||||
startAt: widget.startAt,
|
||||
isLive: widget.isLive,
|
||||
placeholder: widget.placeholder,
|
||||
playbackSpeeds:
|
||||
widget.playbackSpeeds ?? const [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||
hideControlsTimer:
|
||||
widget.hideControlsTimer ?? const Duration(seconds: 3),
|
||||
materialProgressColors: widget.materialProgressColors,
|
||||
errorBuilder: (context, errorMessage) => Center(
|
||||
child: Text(
|
||||
'播放出错:$errorMessage',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_initializing = false;
|
||||
_aspectRatio = videoController.value.aspectRatio;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_initializing = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 内嵌布局:圆角黑底 + 宽高比容器(初始化后跟随视频真实比例)
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: widget.aspectRatio ?? _aspectRatio ?? 16 / 9,
|
||||
child: ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(child: _buildBody()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 三种状态渲染:loading / 错误 / 播放器
|
||||
Widget _buildBody() {
|
||||
if (_error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.white38),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${widget.title != null ? '${widget.title}:' : ''}加载失败\n$_error',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_initializing || _chewieController == null) {
|
||||
return const CircularProgressIndicator(color: Colors.white);
|
||||
}
|
||||
return Chewie(controller: _chewieController!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:new_learn/core/theme/brand_theme.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AppBrand】品牌配置表 —— 运行时静态表(对标 uni-app 的 appList)
|
||||
///
|
||||
/// 一、概念:
|
||||
/// 每所学校/客户 = 一个品牌(App 一个包)。品牌决定三件事:
|
||||
/// appName 桌面/标题显示的 App 名;
|
||||
/// baseUrl 正式环境的专属接口地址(prod 时覆盖通用地址);
|
||||
/// themeId 关联 core/theme/brand_theme.dart 的主题注册表,
|
||||
/// 换品牌 = 换主题色板 + 换接口 + 换名字,一套全换。
|
||||
///
|
||||
/// 二、新增品牌(三步,复刻 uni-app appList 的加一行风格):
|
||||
/// 1. 本文件 appBrands 里加一行;
|
||||
/// 2. 若要用新主题,core/theme/brands/ 下建主题文件并注册;
|
||||
/// 3. 构建时 --dart-define=BRAND=新id。
|
||||
///
|
||||
/// 三、与主题的关系:
|
||||
/// 主题走运行时(BrandTheme,可切换/持久化);
|
||||
/// 品牌走发布期(打包定死)。品牌. themeId 指定该品牌默认主题,
|
||||
/// 运行时仍可在 brandThemes 里切换(互不干扰)。
|
||||
/// ============================================================
|
||||
class AppBrand {
|
||||
const AppBrand({
|
||||
required this.id,
|
||||
required this.appName,
|
||||
this.baseUrl,
|
||||
required this.themeId,
|
||||
});
|
||||
|
||||
/// 品牌标识(--dart-define=BRAND= 用的值,必须与 Map key 一致)
|
||||
final String id;
|
||||
|
||||
/// App 显示名(桌面图标名 + MaterialApp.title)
|
||||
final String appName;
|
||||
|
||||
/// 正式环境专属接口地址(prod 时覆盖通用地址;null = 走环境通用地址)
|
||||
final String? baseUrl;
|
||||
|
||||
/// 关联主题:brand_theme.dart 注册表里 BrandTheme.name
|
||||
final String themeId;
|
||||
}
|
||||
|
||||
/// 品牌注册表:与 uni-app appList 一一对应。
|
||||
/// themeId 用 BrandNames 常量(一个主题可对应多个品牌,改一处全同步)。
|
||||
const Map<String, AppBrand> appBrands = {
|
||||
'aixue': AppBrand(
|
||||
id: 'aixue',
|
||||
appName: '爱学蝶变',
|
||||
baseUrl: 'https://ylax-api.cqbzyx.com',
|
||||
themeId: BrandNames.skyBlue,
|
||||
),
|
||||
'chucai': AppBrand(
|
||||
id: 'chucai',
|
||||
appName: '楚才爱学',
|
||||
baseUrl: 'https://ccax-api.hbxyszzhyx.com',
|
||||
themeId: BrandNames.red,
|
||||
),
|
||||
'yule': AppBrand(
|
||||
id: 'yule',
|
||||
appName: '渝乐爱学',
|
||||
baseUrl: 'https://ylax-api.cqbzyx.com',
|
||||
themeId: BrandNames.skyBlue,
|
||||
),
|
||||
'yuzhi': AppBrand(
|
||||
id: 'yuzhi',
|
||||
appName: '豫志爱学',
|
||||
baseUrl: 'https://yzax-api.cnzhyx.cn',
|
||||
themeId: BrandNames.skyBlue,
|
||||
),
|
||||
'qinxiao': AppBrand(
|
||||
id: 'qinxiao',
|
||||
appName: '秦骁爱学-智略行',
|
||||
baseUrl: 'https://qxax-api.xatyzzhyx.com',
|
||||
themeId: BrandNames.skyBlue,
|
||||
),
|
||||
'wansi': AppBrand(
|
||||
id: 'wansi',
|
||||
appName: '皖思爱学',
|
||||
baseUrl: 'https://wsax-api.ahsdfzyx.com',
|
||||
themeId: BrandNames.skyBlue,
|
||||
),
|
||||
'shucai': AppBrand(
|
||||
id: 'shucai',
|
||||
appName: '蜀才爱学-弘昇智',
|
||||
baseUrl: 'https://scax-api.mynszxyx.com',
|
||||
themeId: BrandNames.skyBlue,
|
||||
),
|
||||
// 新增品牌:直接加一行即可(对标 uni-app appList 的注释示例)
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:new_learn/core/config/app_brand.dart';
|
||||
import 'package:new_learn/core/config/app_env.dart';
|
||||
import 'package:new_learn/core/theme/brand_theme.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AppConfig】配置聚合层 —— 全项目唯一取配置入口
|
||||
///
|
||||
/// 组合规则(复刻 uni-app:appList × envList):
|
||||
/// baseUrl = 品牌专属正式地址(prod 时覆盖) ?? 环境通用地址
|
||||
/// appName = 品牌的名字(桌面图标名 / title)
|
||||
/// theme = 按品牌的 themeId 查主题注册表(现有 BrandTheme 体系)
|
||||
///
|
||||
/// 消费方(Dio / TabApp / 页面)只 import 这里,不碰 AppEnv/AppBrand:
|
||||
/// 1. ApiConfig.baseUrl → AppConfig.baseUrl
|
||||
/// 2. TabApp theme/title → AppConfig.theme / AppConfig.appName
|
||||
///
|
||||
/// 三层解耦:
|
||||
/// 环境 = 接口在哪(编译期定死) 品牌 = 这是谁家的 App(发布期定死)
|
||||
/// 主题 = 长什么样(运行时可切换,与上面两层互不依赖)
|
||||
/// ============================================================
|
||||
class AppConfig {
|
||||
AppConfig._();
|
||||
|
||||
/// 当前品牌(--dart-define=BRAND 指定;未知标识兜底回 aixue 防崩)
|
||||
static AppBrand get currentBrand =>
|
||||
appBrands[AppEnv.brand] ?? appBrands['aixue']!;
|
||||
|
||||
/// App 显示名(桌面图标名 / MaterialApp.title)
|
||||
static String get appName => currentBrand.appName;
|
||||
|
||||
/// 接口地址:prod 用品牌专属地址(同 uni-app 按 appType 覆盖),
|
||||
/// 其他环境用环境通用地址。
|
||||
static String get baseUrl =>
|
||||
AppEnv.isProd ? (currentBrand.baseUrl ?? AppEnv.baseUrl) : AppEnv.baseUrl;
|
||||
|
||||
/// 当前品牌对应的主题(查现有 BrandTheme 注册表)
|
||||
static BrandTheme get theme {
|
||||
for (final t in brandThemes) {
|
||||
if (t.name == currentBrand.themeId) return t;
|
||||
}
|
||||
return brandThemes.first; // 未知 themeId 兜底第一个主题,防崩
|
||||
}
|
||||
|
||||
/// 小游戏地址(预留,对标 envList.gameUrl)
|
||||
static String get gameUrl => AppEnv.gameUrl;
|
||||
|
||||
/// 学习报告地址(预留,对标 envList.reportUrl)
|
||||
static String get reportUrl => AppEnv.reportUrl;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/// ============================================================
|
||||
/// 【AppEnv】环境配置 —— 编译期注入(对标 uni-app 的 envList)
|
||||
///
|
||||
/// 一、核心概念:
|
||||
/// 环境是"打包时定死"的维度:测试包连测试地址,正式包连正式地址。
|
||||
/// 所以用 `--dart-define` 在编译期注入,代码里是 const,零运行时开销。
|
||||
///
|
||||
/// 二、怎么用:
|
||||
/// flutter run --dart-define=ENV=dev
|
||||
/// flutter build apk --release --dart-define=ENV=prod
|
||||
/// 不传时默认 prod(日常 flutter run 不用带参数,行为不变)。
|
||||
///
|
||||
/// 三、对标 uni-app(app.config.js envList):
|
||||
/// dev = 开发/本地调试 test = 测试服 prod = 正式服
|
||||
/// 每个环境一套 {baseUrl / gameUrl / reportUrl}。
|
||||
/// ============================================================
|
||||
class AppEnv {
|
||||
AppEnv._();
|
||||
|
||||
/// 环境:dev / test / prod(默认 prod,日常运行零参数)
|
||||
static const String env = String.fromEnvironment('ENV', defaultValue: 'prod');
|
||||
|
||||
/// 品牌标识:aixue / chucai / yule / ...(默认 aixue=爱学蝶变)
|
||||
/// 品牌表见 app_brand.dart;主题/接口/App 名都随它走(AppConfig 聚合)
|
||||
static const String brand =
|
||||
String.fromEnvironment('BRAND', defaultValue: 'aixue');
|
||||
|
||||
/// 三套环境地址表(与 uni-app envList 完全对齐)
|
||||
static const Map<String, Map<String, String>> _envTable = {
|
||||
'dev': {
|
||||
'baseUrl': 'https://k12-student-api-test.23544.com:16400',
|
||||
'gameUrl': 'https://cocos-test.cnzhyx.cn',
|
||||
'reportUrl': 'https://report-test.cnzhyx.cn/',
|
||||
},
|
||||
'test': {
|
||||
'baseUrl': 'https://axdb-test.cnzhyx.cn',
|
||||
'gameUrl': 'https://cocos-test.cnzhyx.cn',
|
||||
'reportUrl': 'https://report-test.cnzhyx.cn/',
|
||||
},
|
||||
'prod': {
|
||||
// 正式环境的 baseUrl 在 AppConfig 里会被品牌专属地址覆盖(同 uni-app)
|
||||
'baseUrl': 'https://ylax-api.cqbzyx.com',
|
||||
'gameUrl': 'https://axdb-cocos.cnzhyx.cn/',
|
||||
'reportUrl': 'https://axdb-report.cnzhyx.cn/',
|
||||
},
|
||||
};
|
||||
|
||||
/// 取当前环境某个 key 的地址(key 不存在返回空串,避免线上崩)
|
||||
static String value(String key) =>
|
||||
_envTable[env]?[key] ?? _envTable['prod']?[key] ?? '';
|
||||
|
||||
/// 当前环境接口地址
|
||||
static String get baseUrl => value('baseUrl');
|
||||
|
||||
/// 当前环境小游戏地址(预留,对标 envList.gameUrl)
|
||||
static String get gameUrl => value('gameUrl');
|
||||
|
||||
/// 当前环境学习报告地址(预留,对标 envList.reportUrl)
|
||||
static String get reportUrl => value('reportUrl');
|
||||
|
||||
/// 是否正式环境(组合规则里判断用:prod 才走品牌专属地址)
|
||||
static bool get isProd => env == 'prod';
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:talker_flutter/talker_flutter.dart';
|
||||
import 'package:new_learn/core/router/router_keys.dart';
|
||||
|
||||
final Talker talker = Talker();
|
||||
|
||||
class DebugTalkerBubble extends StatefulWidget {
|
||||
const DebugTalkerBubble({
|
||||
super.key,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<DebugTalkerBubble> createState() => _DebugTalkerBubbleState();
|
||||
}
|
||||
|
||||
class _DebugTalkerBubbleState extends State<DebugTalkerBubble> {
|
||||
Offset _offset = const Offset(16, 120);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (kReleaseMode) return widget.child;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxX = constraints.maxWidth - 56;
|
||||
final maxY = constraints.maxHeight - 56;
|
||||
|
||||
final clamped = Offset(
|
||||
_offset.dx.clamp(0.0, maxX.isFinite ? maxX : 0.0),
|
||||
_offset.dy.clamp(0.0, maxY.isFinite ? maxY : 0.0),
|
||||
);
|
||||
|
||||
if (clamped != _offset) {
|
||||
_offset = clamped;
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
widget.child,
|
||||
Positioned(
|
||||
left: _offset.dx,
|
||||
top: _offset.dy,
|
||||
child: GestureDetector(
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
_offset += details.delta;
|
||||
});
|
||||
},
|
||||
child: FloatingActionButton(
|
||||
onPressed: () {
|
||||
final nav = rootNavigatorKey.currentState;
|
||||
if (nav == null) return;
|
||||
nav.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => TalkerScreen(talker: talker),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Icon(Icons.bug_report),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/// ============================================================
|
||||
/// 【PostModel】数据模型 —— JSON ↔ 强类型对象的桥梁
|
||||
/// TS:接口返回就是 JS 对象,interface 只做类型提示,不用转换;
|
||||
// Flutter:接口返回是通用 Map 字典,**必须通过 fromJson 把 Map 转成 Dart 类实例,才能拥有强类型、点访问、编译检查**。
|
||||
///
|
||||
/// 一、为什么必须有 fromJson?
|
||||
/// Dio 只会把响应 JSON 解码成 Map/List,如果页面里到处写
|
||||
/// data['title'],容易拼错、无类型提示、改字段要全局搜。
|
||||
/// 模型类把 JSON 映射成强类型对象,编译期就能发现错误。
|
||||
///
|
||||
/// 二、高频坑:
|
||||
/// 1. 服务端数字类型可能不稳定(int 变 double),用
|
||||
/// (json['id'] as num?)?.toInt() 做防御性转换;
|
||||
/// 2. 字段缺失会直接抛异常,生产环境给 ?? 兜底值;
|
||||
/// 3. fromJson(响应→对象)和 toJson(对象→提交)必须成对维护。
|
||||
/// ============================================================
|
||||
class PostModel {
|
||||
const PostModel({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.title,
|
||||
required this.body,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final int userId;
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
/// JSON → 对象(Dio 已把 JSON 字符串解码成 Map)
|
||||
factory PostModel.fromJson(Map<String, dynamic> json) {
|
||||
return PostModel(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
userId: (json['userId'] as num?)?.toInt() ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
body: json['body'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 对象 → JSON(用于 POST 提交给服务器)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'id': id, 'userId': userId, 'title': title, 'body': body};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/// ============================================================
|
||||
/// 【UserModel】用户相关模型
|
||||
///
|
||||
/// CurrentUserExtension:GET /api/v1/User/GetCurrentUserExtension 的响应体,
|
||||
/// 对齐 Vue 端 api/user/type.ts 的 CurrentUserExtension 全部字段(17 个)。
|
||||
///
|
||||
/// 一、为什么字段全都要:
|
||||
/// 登录后要缓存这套信息(user_storage.dart 里存 JSON),
|
||||
/// "我的"页 / 任务页都可能用到,缺一个就得多调一次接口。
|
||||
///
|
||||
/// 二、高频坑:
|
||||
/// 1. fromJson 全部 ?? 兜底,服务端缺字段/类型漂移不会闪崩;
|
||||
/// 2. 数字统一 (json['x'] as num?)?.toInt(),防 int/double 不一致;
|
||||
/// 3. toJson 与 fromJson 必须成对,否则缓存/回填会丢字段。
|
||||
/// ============================================================
|
||||
class CurrentUserExtension {
|
||||
const CurrentUserExtension({
|
||||
this.userId = 0,
|
||||
this.userName = '',
|
||||
this.userPoints = 0.0,
|
||||
this.levelName = '',
|
||||
this.avatar = '',
|
||||
this.className = '',
|
||||
this.classId = 0,
|
||||
this.gradeLevel = '',
|
||||
this.graduationYear = 0,
|
||||
this.targetConfigId = 0,
|
||||
this.targetConfigName = '',
|
||||
this.userAvatarStatus = 0,
|
||||
this.userAvatarReason = '',
|
||||
this.isShowGuide = false,
|
||||
this.isShowReport = false,
|
||||
this.id = 0,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final String userName;
|
||||
final double userPoints;
|
||||
final String levelName;
|
||||
final String avatar;
|
||||
final String className;
|
||||
final int classId;
|
||||
final String gradeLevel;
|
||||
final int graduationYear;
|
||||
final int targetConfigId;
|
||||
final String targetConfigName;
|
||||
final int userAvatarStatus;
|
||||
final String userAvatarReason;
|
||||
final bool isShowGuide;
|
||||
final bool isShowReport;
|
||||
final int id;
|
||||
|
||||
/// JSON → 对象(服务器响应)
|
||||
///
|
||||
/// 两个兼容点(对齐接口真实返回 {code, message, data: {...}}):
|
||||
/// 1. data 包装:data 存在就取 data,直接返回 {userId,...} 也支持;
|
||||
/// 2. 字符串数字:后端 userId/classId 返回的是字符串
|
||||
/// ("820466383020136"),不能直接 as num,用 _toInt 统一转换。
|
||||
factory CurrentUserExtension.fromJson(Map<String, dynamic> json) {
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return CurrentUserExtension(
|
||||
userId: _toInt(payload['userId']),
|
||||
userName: payload['userName'] as String? ?? '',
|
||||
userPoints: _toDouble(payload['userPoints']),
|
||||
levelName: payload['levelName'] as String? ?? '',
|
||||
avatar: payload['avatar'] as String? ?? '',
|
||||
className: payload['className'] as String? ?? '',
|
||||
classId: _toInt(payload['classId']),
|
||||
gradeLevel: payload['gradeLevel'] as String? ?? '',
|
||||
graduationYear: _toInt(payload['graduationYear']),
|
||||
targetConfigId: _toInt(payload['targetConfigId']),
|
||||
targetConfigName: payload['targetConfigName'] as String? ?? '',
|
||||
userAvatarStatus: _toInt(payload['userAvatarStatus']),
|
||||
userAvatarReason: payload['userAvatarReason'] as String? ?? '',
|
||||
isShowGuide: payload['isShowGuide'] as bool? ?? false,
|
||||
isShowReport: payload['isShowReport'] as bool? ?? false,
|
||||
id: _toInt(payload['id']),
|
||||
);
|
||||
}
|
||||
|
||||
/// 对象 → JSON(写入本地缓存)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'userId': userId,
|
||||
'userName': userName,
|
||||
'userPoints': userPoints,
|
||||
'levelName': levelName,
|
||||
'avatar': avatar,
|
||||
'className': className,
|
||||
'classId': classId,
|
||||
'gradeLevel': gradeLevel,
|
||||
'graduationYear': graduationYear,
|
||||
'targetConfigId': targetConfigId,
|
||||
'targetConfigName': targetConfigName,
|
||||
'userAvatarStatus': userAvatarStatus,
|
||||
'userAvatarReason': userAvatarReason,
|
||||
'isShowGuide': isShowGuide,
|
||||
'isShowReport': isShowReport,
|
||||
'id': id,
|
||||
};
|
||||
}
|
||||
|
||||
/// 局部更新(目前只用得上 avatar:头像上传成功后替换 URL)
|
||||
CurrentUserExtension copyWith({String? avatar}) {
|
||||
return CurrentUserExtension(
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
userPoints: userPoints,
|
||||
levelName: levelName,
|
||||
avatar: avatar ?? this.avatar,
|
||||
className: className,
|
||||
classId: classId,
|
||||
gradeLevel: gradeLevel,
|
||||
graduationYear: graduationYear,
|
||||
targetConfigId: targetConfigId,
|
||||
targetConfigName: targetConfigName,
|
||||
userAvatarStatus: userAvatarStatus,
|
||||
userAvatarReason: userAvatarReason,
|
||||
isShowGuide: isShowGuide,
|
||||
isShowReport: isShowReport,
|
||||
id: id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户积分等级(GET /api/v1/User/GetUserPointLevel,备用)
|
||||
class UserPointLevel {
|
||||
const UserPointLevel({
|
||||
this.userId = 0,
|
||||
this.totalPoints = 0,
|
||||
this.level = 0,
|
||||
this.levelPoints = 0,
|
||||
this.levelMaxPoints = 0,
|
||||
this.levelUpPoints = 0,
|
||||
});
|
||||
|
||||
final int userId;
|
||||
final int totalPoints;
|
||||
final int level;
|
||||
final int levelPoints;
|
||||
final int levelMaxPoints;
|
||||
final int levelUpPoints;
|
||||
|
||||
factory UserPointLevel.fromJson(Map<String, dynamic> json) {
|
||||
return UserPointLevel(
|
||||
userId: (json['userId'] as num?)?.toInt() ?? 0,
|
||||
totalPoints: (json['totalPoints'] as num?)?.toInt() ?? 0,
|
||||
level: (json['level'] as num?)?.toInt() ?? 0,
|
||||
levelPoints: (json['levelPoints'] as num?)?.toInt() ?? 0,
|
||||
levelMaxPoints: (json['levelMaxPoints'] as num?)?.toInt() ?? 0,
|
||||
levelUpPoints: (json['levelUpPoints'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 兼容"字符串数字"(后端 userId/classId 返回 "820466383020136")
|
||||
int _toInt(dynamic v) {
|
||||
if (v is num) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// 兼容小数积分(后端 userPoints 返回 105.5)
|
||||
double _toDouble(dynamic v) {
|
||||
if (v is num) return v.toDouble();
|
||||
if (v is String) return double.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:new_learn/core/config/app_config.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ApiConfig】接口配置 —— 全项目唯一接口地址出处
|
||||
///
|
||||
/// 地址来源:品牌 × 环境 组合解析(对标 uni-app appList × envList):
|
||||
/// prod 环境 → 当前品牌专属地址(如 aixue → ylax-api.cqbzyx.com)
|
||||
/// 其他环境 → 环境通用地址(dev/test)
|
||||
/// 具体组合规则见 core/config/app_config.dart。
|
||||
///
|
||||
/// 日常开发连测试环境,不用改代码,运行时带参数即可:
|
||||
/// flutter run --dart-define=ENV=test
|
||||
/// ============================================================
|
||||
class ApiConfig {
|
||||
ApiConfig._();
|
||||
|
||||
/// 接口地址(品牌 × 环境 组合后的最终值)
|
||||
static String get baseUrl => AppConfig.baseUrl;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
import 'package:new_learn/core/network/api_config.dart';
|
||||
import 'package:new_learn/core/storage/user_storage.dart';
|
||||
import 'package:talker_dio_logger/talker_dio_logger.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【DioClient】Dio 网络请求封装(生产级单例)
|
||||
///
|
||||
/// 一、为什么必须封装?
|
||||
/// 裸用 Dio 时,每个页面都要重复写 baseUrl / 超时 / Token / 错误处理,
|
||||
/// 项目一大会失控。封装后全项目共享【一个 Dio 实例】+【一套拦截器】。
|
||||
///
|
||||
/// 二、核心设计(4 层):
|
||||
/// 1. 单例 :DioClient.instance 全局唯一,复用底层连接池;
|
||||
/// 2. BaseOptions:统一 baseUrl / 超时 / 公共请求头;
|
||||
/// 3. 拦截器三件套(InterceptorsWrapper):
|
||||
/// - onRequest :打印请求日志 + 自动加 Authorization Token;
|
||||
/// - onResponse :打印响应日志;
|
||||
/// - onError :401 清 Token 跳登录 + 把 DioException 翻译成 ApiException;
|
||||
/// 4. 泛型方法 :get / post,内部统一把错误转成 ApiException 再抛出,
|
||||
/// UI 层只 catch 一个类型,永不接触 Dio 细节。
|
||||
///
|
||||
/// 三、Token 生命周期:
|
||||
/// 登录成功 → user_storage.dart 存安全存储 + 这里写内存 accessToken;
|
||||
/// 冷启动 → main() 里调 restoreToken(),把磁盘 token 回填内存,
|
||||
/// 后续请求自动带 Authorization。
|
||||
///
|
||||
/// 四、高频坑(Dio 5.x 专属,别再用老教程):
|
||||
/// 1. timeout 是 Duration 类型,不是旧版的 int 毫秒数;
|
||||
/// 2. DioError 已改名 DioException(5.0 起),import 错了编译不过;
|
||||
/// 3. 错误拦截器必须以 handler.reject() / handler.resolve() 结束,
|
||||
/// 否则请求会永久挂起不返回;
|
||||
/// 4. handler.reject 的参数类型是 DioException(不是任意对象),
|
||||
/// 把翻译好的 ApiException 挂在 error 字段再抛给调用方;
|
||||
/// 5. Android 真机请求网络必须加 INTERNET 权限(本工程 Manifest 已加)。
|
||||
/// ============================================================
|
||||
class DioClient {
|
||||
// ---------- 1. 单例:整个 App 只有一个 Dio ----------
|
||||
DioClient._()
|
||||
: _dio = Dio(
|
||||
BaseOptions(
|
||||
// 正式环境接口:统一从 ApiConfig 取,换环境只改 api_config.dart
|
||||
baseUrl: ApiConfig.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10), // 连接超时
|
||||
sendTimeout: const Duration(seconds: 10), // 发送超时
|
||||
receiveTimeout: const Duration(seconds: 10), // 接收超时
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
),
|
||||
) {
|
||||
_dio.interceptors.add(
|
||||
TalkerDioLogger(
|
||||
talker: talker,
|
||||
settings: const TalkerDioLoggerSettings(
|
||||
printResponseTime: true,
|
||||
hiddenHeaders: <String>{
|
||||
'authorization',
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
_dio.interceptors.add(_buildInterceptors());
|
||||
}
|
||||
|
||||
static final DioClient instance = DioClient._();
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// 内存中的 Token(拦截器用它拼 Authorization)。
|
||||
/// 登录成功后写这里;冷启动由 restoreToken() 从安全存储回填。
|
||||
String? accessToken;
|
||||
|
||||
/// 冷启动回填 Token:从安全存储读出来放进内存。
|
||||
/// 必须在 runApp 之前 await(见 main.dart)。
|
||||
Future<void> restoreToken() async {
|
||||
accessToken = await UserStorage.instance.readToken();
|
||||
if (accessToken != null) {
|
||||
debugPrint('▶ 【Token】已从安全存储恢复登录态');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 2. 拦截器 ----------
|
||||
InterceptorsWrapper _buildInterceptors() {
|
||||
return InterceptorsWrapper(
|
||||
// ① 请求发出前:加 Token + 打印日志
|
||||
onRequest: (options, handler) {
|
||||
// OSS 直传会带自己的 authorization 头(OSS4-HMAC-SHA256 签名),
|
||||
// 任何形式存在 authorization 都不再加 Bearer,否则会覆盖 OSS 签名
|
||||
final hasAuth = options.headers['Authorization'] != null ||
|
||||
options.headers['authorization'] != null;
|
||||
if (accessToken != null && !hasAuth) {
|
||||
options.headers['Authorization'] = 'Bearer $accessToken';
|
||||
}
|
||||
debugPrint('▶ 【请求】${options.method} ${options.uri}');
|
||||
handler.next(options); // 放行
|
||||
},
|
||||
|
||||
// ② 收到响应后:打印日志
|
||||
onResponse: (response, handler) {
|
||||
debugPrint(
|
||||
'◀ 【响应】${response.statusCode} '
|
||||
'${response.requestOptions.method} ${response.requestOptions.uri}',
|
||||
);
|
||||
handler.next(response); // 放行
|
||||
},
|
||||
|
||||
// ③ 出错时:统一翻译成 ApiException
|
||||
onError: (err, handler) {
|
||||
final apiException = _mapError(err);
|
||||
debugPrint(
|
||||
'✖ 【请求失败】${err.requestOptions.method} '
|
||||
'${err.requestOptions.uri}\n → ${apiException.message}',
|
||||
);
|
||||
// 401 未登录/登录过期:清内存 Token 并提示(真实项目在这里跳登录页)
|
||||
if (err.response?.statusCode == 401) {
|
||||
accessToken = null;
|
||||
debugPrint('⚠ 【401】登录已过期,请重新登录');
|
||||
}
|
||||
// 注意:必须以 reject 收尾,且 reject 的参数类型是 DioException。
|
||||
// 把翻译好的 ApiException 挂在 error 字段,调用方 catch 后再拆出来。
|
||||
handler.reject(
|
||||
DioException(
|
||||
requestOptions: err.requestOptions,
|
||||
response: err.response,
|
||||
type: err.type,
|
||||
error: apiException,
|
||||
stackTrace: err.stackTrace,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 把 DioException 映射成带中文提示的 ApiException
|
||||
/// (错误拦截器 reject 的 ApiException 会被 Dio 包进 error 字段再抛回来)
|
||||
ApiException _mapError(DioException e) {
|
||||
final inner = e.error;
|
||||
if (inner is ApiException) return inner; // 已在拦截器翻译过,直接返回
|
||||
|
||||
final message = switch (e.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout =>
|
||||
'网络连接超时,请稍后重试',
|
||||
DioExceptionType.badResponse =>
|
||||
'服务器开小差了(${e.response?.statusCode})',
|
||||
DioExceptionType.cancel => '请求已取消',
|
||||
DioExceptionType.connectionError => '网络异常,请检查网络连接',
|
||||
_ => '未知错误(${e.type.name})',
|
||||
};
|
||||
return ApiException(e, message);
|
||||
}
|
||||
|
||||
// ---------- 3. 泛型请求方法(业务层只调这几个) ----------
|
||||
|
||||
/// GET 请求:path 为相对路径,如 '/posts/1';返回服务器 JSON 解码后的数据
|
||||
///
|
||||
/// 生产级参数:
|
||||
/// - [cancelToken] :可取消请求(页面销毁 / 用户点取消时 cancel());
|
||||
/// - [onReceiveProgress]:下载进度回调(大文件/图片下载显示进度条);
|
||||
/// - [retries] :失败自动重试次数(指数退避)。只对网络类错误
|
||||
/// 生效,业务错误 4xx/5xx 和主动取消都不重试(见 _canRetry);
|
||||
/// - [headers] :本次请求额外/覆盖的头。典型场景:下载媒体文件
|
||||
/// 时覆盖全局 Content-Type 为 text/plain——application/json 是非简单
|
||||
/// 头,Web 跨域会触发 OPTIONS 预检,服务器不支持就 405 被拦(CORS)。
|
||||
Future<dynamic> get(
|
||||
String path, {
|
||||
Map<String, dynamic>? query,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
int retries = 0,
|
||||
Map<String, dynamic>? headers,
|
||||
}) async {
|
||||
var attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
final resp = await _dio.get<dynamic>(
|
||||
path,
|
||||
queryParameters: query,
|
||||
cancelToken: cancelToken,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
options: headers == null ? null : Options(headers: headers),
|
||||
);
|
||||
return resp.data;
|
||||
} on DioException catch (e) {
|
||||
// 主动取消 / 业务错误 / 已重试满 → 直接抛(翻译成 ApiException)
|
||||
if (!_canRetry(e) || attempt >= retries) {
|
||||
throw _mapError(e);
|
||||
}
|
||||
attempt++;
|
||||
// 指数退避:300ms → 600ms → 1.2s...(弱网时给服务器喘息时间)
|
||||
await Future<void>.delayed(_backoff(attempt));
|
||||
// 退避期间用户点了取消 → 下一次请求会立即抛 cancel,先主动感知
|
||||
if (cancelToken?.isCancelled ?? false) {
|
||||
throw DioException(
|
||||
requestOptions: e.requestOptions,
|
||||
type: DioExceptionType.cancel,
|
||||
error: ApiException(e, '请求已取消'),
|
||||
);
|
||||
}
|
||||
debugPrint('↻ 【重试】$path 第 $attempt 次(${e.type.name})');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST 请求:data 为要提交的对象(Map / 模型.toJson() / FormData 上传)
|
||||
///
|
||||
/// 生产级参数:
|
||||
/// - [cancelToken] :可取消请求;
|
||||
/// - [onSendProgress] :上传进度回调,如 onSendProgress: (sent, total);
|
||||
/// - [onReceiveProgress]:下载进度回调;
|
||||
/// - [retries] :POST 默认 0 = 不重试!重试会重复提交数据,
|
||||
/// 有副作用风险;只有接口本身幂等时才显式传;
|
||||
/// - [headers] :本次请求额外/覆盖的头(同 get 的 CORS 说明)。
|
||||
Future<dynamic> post(
|
||||
String path, {
|
||||
dynamic data,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
ProgressCallback? onReceiveProgress,
|
||||
int retries = 0,
|
||||
Map<String, dynamic>? headers,
|
||||
}) async {
|
||||
var attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
final resp = await _dio.post<dynamic>(
|
||||
path,
|
||||
data: data,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
onReceiveProgress: onReceiveProgress,
|
||||
options: headers == null ? null : Options(headers: headers),
|
||||
);
|
||||
return resp.data;
|
||||
} on DioException catch (e) {
|
||||
if (!_canRetry(e) || attempt >= retries) {
|
||||
throw _mapError(e);
|
||||
}
|
||||
attempt++;
|
||||
await Future<void>.delayed(_backoff(attempt));
|
||||
if (cancelToken?.isCancelled ?? false) {
|
||||
throw DioException(
|
||||
requestOptions: e.requestOptions,
|
||||
type: DioExceptionType.cancel,
|
||||
error: ApiException(e, '请求已取消'),
|
||||
);
|
||||
}
|
||||
debugPrint('↻ 【重试】$path 第 $attempt 次(${e.type.name})');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否值得自动重试:只重试"网络层问题"(连不上/超时/闪断)。
|
||||
/// 主动取消(cancel)和业务错误(badResponse 4xx/5xx)都不重试,
|
||||
/// 前者是用户意图,后者重试也大概率同样报错。
|
||||
bool _canRetry(DioException e) {
|
||||
return switch (e.type) {
|
||||
DioExceptionType.connectionError ||
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.sendTimeout ||
|
||||
DioExceptionType.receiveTimeout =>
|
||||
true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// 指数退避延迟:300ms * 2^(attempt-1)
|
||||
Duration _backoff(int attempt) =>
|
||||
Duration(milliseconds: 300 * (1 << (attempt - 1)));
|
||||
|
||||
// ---------- 4. OSS 直传(PUT) ----------
|
||||
|
||||
/// PUT 请求:阿里云 OSS 直传专用。
|
||||
///
|
||||
/// 与 get/post 的区别:
|
||||
/// 1. [url] 是【绝对地址】(https://{bucket}.{region}.aliyuncs.com/...),
|
||||
/// Dio 检测到绝对 URL 会自动忽略 baseUrl;
|
||||
/// 2. [data] 传文件字节(Uint8List);
|
||||
/// 3. [headers] 必须传 OSS 签名头(Content-Type / authorization / x-oss-*),
|
||||
/// 拦截器识别 authorization 后不会再拼 Bearer;
|
||||
/// 4. 不做自动重试:大文件重传成本高,失败交给业务层决定。
|
||||
Future<dynamic> put(
|
||||
String url, {
|
||||
dynamic data,
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
Map<String, dynamic>? headers,
|
||||
}) async {
|
||||
final resp = await _dio.put<dynamic>(
|
||||
url,
|
||||
data: data,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
options: headers == null ? null : Options(headers: headers),
|
||||
);
|
||||
return resp.data;
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一业务异常:UI 层只 catch 这一个类型,永不接触 DioException
|
||||
class ApiException implements Exception {
|
||||
ApiException(this.dioException, this.message);
|
||||
|
||||
final DioException dioException;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【OssApi】阿里云 OSS 上传相关接口(对照 UniApp api/userNote/index.ts)
|
||||
///
|
||||
/// 一、上传新流程(不传业务服务器,客户端直传 OSS):
|
||||
/// 1. GetXxxOSSUploadUrl(FileName) → 后端分配 OSS 相对路径(objectName)
|
||||
/// 2. GetAliOSSSetting() → 后端返回 STS 临时凭证
|
||||
/// 3. 客户端用 V4 签名(oss_signer.dart)计算 authorization
|
||||
/// 4. PUT 直传 https://{bucket}.{region}.aliyuncs.com{objectName}
|
||||
/// → 全程走 DioClient 统一拦截器(自动带 Token、错误翻译)。
|
||||
///
|
||||
/// 二、接口清单(与 UniApp 一一对应):
|
||||
/// GET /api/v1/Public/GetAliOSSSetting → STS 凭证
|
||||
/// GET /api/v1/Public/GetNoteOSSUploadUrl → 笔记图片路径
|
||||
/// GET /api/v1/Public/GetAvatarOSSUploadUrl → 头像路径
|
||||
/// GET /api/v1/Public/GetAIGradingOSSUploadUrl → AI 判题路径
|
||||
///
|
||||
/// 三、坑:
|
||||
/// 1. GetXxxUploadUrl 的 query 参数名是 FileName(首字母大写),
|
||||
/// 对齐 UniApp 的 { FileName },别写成 fileName;
|
||||
/// 2. 后端返回统一 {code, message, data} 包装,fromJson 里必须
|
||||
/// 先解 data(与 user_model.dart 一致),否则 STS/路径全解析为空,
|
||||
/// 直传 URL 会变成 https://..aliyuncs.com/(bucket/region 都丢了)。
|
||||
/// ============================================================
|
||||
|
||||
/// 上传场景(对应 UniApp ossUpload.js 的 mode 参数)
|
||||
enum UploadScene { note, avatar, aiGrading }
|
||||
|
||||
/// STS 临时凭证(对应 GetAliOSSSetting 返回)
|
||||
class OssStsConfig {
|
||||
const OssStsConfig({
|
||||
required this.regionId,
|
||||
required this.bucket,
|
||||
required this.accessKeyId,
|
||||
required this.accessKeySecret,
|
||||
required this.securityToken,
|
||||
});
|
||||
|
||||
/// OSS 区域,如 oss-cn-beijing(签名时要去掉 'oss-' 前缀)
|
||||
final String regionId;
|
||||
final String bucket;
|
||||
final String accessKeyId;
|
||||
final String accessKeySecret;
|
||||
|
||||
/// 临时安全令牌(x-oss-security-token)
|
||||
final String securityToken;
|
||||
|
||||
factory OssStsConfig.fromJson(Map<String, dynamic> json) {
|
||||
// 后端统一 {code, message, data} 包装:data 存在取 data,否则用顶层
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return OssStsConfig(
|
||||
regionId: payload['regionId'] as String? ?? '',
|
||||
bucket: payload['bucket'] as String? ?? '',
|
||||
accessKeyId: payload['accessKeyID'] as String? ?? '',
|
||||
accessKeySecret: payload['accessKeySecret'] as String? ?? '',
|
||||
securityToken: payload['aliyunOSSToken'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// OSS 上传目标(对应 GetXxxOSSUploadUrl 返回的 resourceInfo)
|
||||
class OssUploadTarget {
|
||||
const OssUploadTarget({required this.url, this.ossPathUrl});
|
||||
|
||||
/// OSS 相对路径(objectName,直传 URL 拼在域名后面)
|
||||
final String url;
|
||||
|
||||
/// CDN 域名前缀(存在时用它拼最终访问 URL;null 用直传地址)
|
||||
final String? ossPathUrl;
|
||||
|
||||
factory OssUploadTarget.fromJson(Map<String, dynamic> json) {
|
||||
// 与 OssStsConfig 相同:统一解 data 包装,兼容两种返回
|
||||
final payload = (json['data'] as Map<String, dynamic>?) ?? json;
|
||||
return OssUploadTarget(
|
||||
url: payload['url'] as String? ?? '',
|
||||
ossPathUrl: payload['ossPathUrl'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OssApi {
|
||||
OssApi._();
|
||||
|
||||
/// 获取 STS 临时凭证 → GET /api/v1/Public/GetAliOSSSetting
|
||||
static Future<OssStsConfig> getSts() async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/api/v1/Public/GetAliOSSSetting',
|
||||
);
|
||||
return OssStsConfig.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// 按场景申请 OSS 上传路径 → GET /api/v1/Public/GetXxxOSSUploadUrl
|
||||
static Future<OssUploadTarget> getUploadUrl(
|
||||
UploadScene scene,
|
||||
String fileName,
|
||||
) async {
|
||||
final path = switch (scene) {
|
||||
UploadScene.note => '/api/v1/Public/GetNoteOSSUploadUrl',
|
||||
UploadScene.avatar => '/api/v1/Public/GetAvatarOSSUploadUrl',
|
||||
UploadScene.aiGrading => '/api/v1/Public/GetAIGradingOSSUploadUrl',
|
||||
};
|
||||
final data = await DioClient.instance.get(
|
||||
path,
|
||||
query: {'FileName': fileName},
|
||||
);
|
||||
return OssUploadTarget.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:new_learn/core/network/oss_api.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【OssV4Signer】阿里云 OSS V4 签名(对照 UniApp utils/ossUpload.js)
|
||||
///
|
||||
/// 一、为什么客户端要手写签名:
|
||||
/// OSS 直传必须带 Authorization 头(OSS4-HMAC-SHA256 ...),
|
||||
/// 它是用【临时密钥 + 请求内容】算出来的,不能由后端代签(每次请求独立)。
|
||||
/// 流程:GetAliOSSSetting 拿 STS → 本地算签名 → PUT 时带上。
|
||||
///
|
||||
/// 二、对照 JS 的核心坑(ossUpload.js 已踩过):
|
||||
/// 1. 签名 region 要去掉 'oss-' 前缀(oss-cn-beijing → cn-beijing);
|
||||
/// 2. CanonicalURI 必须包含 bucketName(/bucketName/objectName);
|
||||
/// 3. SignedHeaders 留空(不在 Authorization 里声明 AdditionalHeaders),
|
||||
/// 但 canonicalHeaders 仍要按字典序拼 content-type + 全部 x-oss-*;
|
||||
/// 4. payloadHash 用 UNSIGNED-PAYLOAD(不校验 Body 哈希,上传性能好);
|
||||
/// 5. amzDate 是 UTC 的 YYYYMMDDTHHMMSSZ,不要用本地时间。
|
||||
/// ============================================================
|
||||
|
||||
/// 一次已签名的 PUT 请求(直传 URL + 全部请求头)
|
||||
class OssSignedRequest {
|
||||
const OssSignedRequest({required this.url, required this.headers});
|
||||
|
||||
/// 直传地址 https://{bucket}.{region}.aliyuncs.com{objectName}
|
||||
final String url;
|
||||
|
||||
/// OSS 要求的全部请求头(Content-Type / authorization / x-oss-*)
|
||||
final Map<String, dynamic> headers;
|
||||
}
|
||||
|
||||
class OssV4Signer {
|
||||
OssV4Signer._();
|
||||
|
||||
/// 计算 V4 签名,返回可直接 PUT 的 URL + headers
|
||||
static OssSignedRequest build({
|
||||
required OssStsConfig sts,
|
||||
required OssUploadTarget target,
|
||||
String contentType = 'image/jpeg',
|
||||
}) {
|
||||
final objectName = '/${target.url}';
|
||||
// 核心坑 1:签名 region 去掉 'oss-' 前缀
|
||||
final signRegion = sts.regionId.replaceFirst('oss-', '');
|
||||
final host = '${sts.bucket}.${sts.regionId}.aliyuncs.com';
|
||||
final uploadUrl = 'https://$host$objectName';
|
||||
|
||||
final amzDate = _amzDate();
|
||||
final dateStamp = amzDate.substring(0, 8);
|
||||
const method = 'PUT';
|
||||
const service = 'oss';
|
||||
const payloadHash = 'UNSIGNED-PAYLOAD';
|
||||
|
||||
// 核心坑 2:CanonicalURI 包含 bucketName
|
||||
final canonicalUri = _jsEncodeUri('/${sts.bucket}$objectName');
|
||||
const canonicalQuery = '';
|
||||
// 核心坑 3:按字典序(content-type < x-oss-*),每行末尾带 \n
|
||||
final canonicalHeaders = 'content-type:$contentType\n'
|
||||
'x-oss-content-sha256:$payloadHash\n'
|
||||
'x-oss-date:$amzDate\n'
|
||||
'x-oss-security-token:${sts.securityToken}\n';
|
||||
const signedHeaders = '';
|
||||
|
||||
final canonicalRequest = '$method\n$canonicalUri\n$canonicalQuery\n'
|
||||
'$canonicalHeaders\n$signedHeaders\n$payloadHash';
|
||||
final hashedRequest =
|
||||
sha256.convert(utf8.encode(canonicalRequest)).toString();
|
||||
|
||||
final scope = '$dateStamp/$signRegion/$service/aliyun_v4_request';
|
||||
final stringToSign = 'OSS4-HMAC-SHA256\n$amzDate\n$scope\n$hashedRequest';
|
||||
|
||||
final signingKey =
|
||||
_getSignatureKey(sts.accessKeySecret, dateStamp, signRegion, service);
|
||||
final signature =
|
||||
Hmac(sha256, signingKey).convert(utf8.encode(stringToSign)).toString();
|
||||
|
||||
return OssSignedRequest(
|
||||
url: uploadUrl,
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
// 注意:OSS 头名用小写 authorization,DioClient 拦截器据此跳过加 Bearer
|
||||
'authorization':
|
||||
'OSS4-HMAC-SHA256 Credential=${sts.accessKeyId}/$scope,'
|
||||
'Signature=$signature',
|
||||
'x-oss-date': amzDate,
|
||||
'x-oss-content-sha256': payloadHash,
|
||||
'x-oss-security-token': sts.securityToken,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 4 层 HMAC 派生签名密钥(对照 JS getSignatureKey)
|
||||
static Uint8List _getSignatureKey(
|
||||
String secret,
|
||||
String dateStamp,
|
||||
String region,
|
||||
String service,
|
||||
) {
|
||||
Uint8List hmac(List<int> key, String msg) =>
|
||||
Uint8List.fromList(Hmac(sha256, key).convert(utf8.encode(msg)).bytes);
|
||||
|
||||
final kDate = hmac(utf8.encode('aliyun_v4$secret'), dateStamp);
|
||||
final kRegion = hmac(kDate, region);
|
||||
final kService = hmac(kRegion, service);
|
||||
return hmac(kService, 'aliyun_v4_request');
|
||||
}
|
||||
|
||||
/// YYYYMMDDTHHMMSSZ(UTC,对照 JS getAmzDate)
|
||||
static String _amzDate() {
|
||||
final now = DateTime.now().toUtc();
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return '${now.year}${two(now.month)}${two(now.day)}'
|
||||
'T${two(now.hour)}${two(now.minute)}${two(now.second)}Z';
|
||||
}
|
||||
|
||||
/// JS encodeURI 等价:保留 URI 保留字符(:/?#[]@!$&'()*+,;=),编码其余。
|
||||
/// Dart 的 Uri.encodeFull 行为一致;OSS 对象名一般是安全字符,差异可忽略。
|
||||
static String _jsEncodeUri(String s) => Uri.encodeFull(s);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
import 'package:new_learn/core/network/oss_api.dart';
|
||||
import 'package:new_learn/core/network/oss_signer.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【OssUploader】OSS 直传执行器(完整链路,对照 UniApp ossUpload.js)
|
||||
///
|
||||
/// 一、链路(4 步,全部走 DioClient 统一拦截器):
|
||||
/// ① OssApi.getUploadUrl → 后端分配 OSS 相对路径
|
||||
/// ② OssApi.getSts → 后端下发 STS 临时凭证
|
||||
/// ③ OssV4Signer.build → 本地计算 OSS4-HMAC-SHA256 签名
|
||||
/// ④ DioClient.put(绝对URL) → 字节直传 OSS,返回最终访问 URL
|
||||
///
|
||||
/// 二、使用方式:
|
||||
/// OssUploader.uploadImage(
|
||||
/// bytes: `Uint8List`, // 图片字节(原生可来自 AssetEntity.originBytes)
|
||||
/// fileName: 'xxx.jpg', // 文件名(含扩展名,后端分配路径用)
|
||||
/// scene: UploadScene.avatar, // 场景:avatar/note/aiGrading
|
||||
/// onSendProgress: (s, t) {}, // 上传进度回调(生产可用)
|
||||
/// );
|
||||
///
|
||||
/// 三、坑:
|
||||
/// 1. 本类只收字节不读文件 —— 读文件平台差异交给调用方
|
||||
/// (web 用 bytes;原生用 AssetEntity.originBytes,避免 import dart:io);
|
||||
/// 2. 上传失败抛 ApiException(拦截器统一翻译),调用方 catch 展示即可。
|
||||
/// ============================================================
|
||||
class OssUploader {
|
||||
OssUploader._();
|
||||
|
||||
/// 上传图片到 OSS,成功返回最终访问 URL
|
||||
///
|
||||
/// [bytes] :图片原始字节(Uint8List)
|
||||
/// [fileName] :文件名(含扩展名,如 'photo_123.jpg')
|
||||
/// [scene] :上传场景(决定用哪个申请接口)
|
||||
/// [contentType]:字节对应的 MIME(必须和真实编码一致)
|
||||
/// [onSendProgress]:上传进度回调 (已发送, 总大小),可显示进度条
|
||||
static Future<String> uploadImage({
|
||||
required Uint8List bytes,
|
||||
required String fileName,
|
||||
required UploadScene scene,
|
||||
String contentType = 'image/jpeg',
|
||||
CancelToken? cancelToken,
|
||||
ProgressCallback? onSendProgress,
|
||||
}) async {
|
||||
// ① 后端分配 OSS 相对路径(objectName)
|
||||
final target = await OssApi.getUploadUrl(scene, fileName);
|
||||
|
||||
// ② 获取 STS 临时凭证
|
||||
final sts = await OssApi.getSts();
|
||||
|
||||
// ③ 计算 V4 签名(URL + authorization 等全部请求头)
|
||||
final req = OssV4Signer.build(
|
||||
sts: sts,
|
||||
target: target,
|
||||
contentType: contentType,
|
||||
);
|
||||
|
||||
// ④ PUT 直传(绝对 URL;拦截器识别 authorization 后不再拼 Bearer)
|
||||
await DioClient.instance.put(
|
||||
req.url,
|
||||
data: bytes,
|
||||
headers: req.headers,
|
||||
cancelToken: cancelToken,
|
||||
onSendProgress: onSendProgress,
|
||||
);
|
||||
|
||||
// ⑤ 最终访问 URL:有 CDN 前缀用 CDN(体验好),否则用直传地址
|
||||
final prefix = target.ossPathUrl;
|
||||
return (prefix != null && prefix.isNotEmpty)
|
||||
? '$prefix/${target.url}'
|
||||
: req.url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:new_learn/core/models/post_model.dart';
|
||||
import 'dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【PostApi】业务接口层 —— 学习 Demo 用(帖子 CRUD)
|
||||
///
|
||||
/// 职责与 UserApi 相同:页面只调这里,永远不直接碰 Dio。
|
||||
/// 每个业务模块一个文件:PostApi / UserApi / ...,按需新增。
|
||||
///
|
||||
/// 高频坑:
|
||||
/// 1. fromJson 转换失败会抛 TypeError,若服务端结构不稳定,
|
||||
/// 应在模型里做防御性解析(见 post_model.dart);
|
||||
/// 2. 不要在这里 catch ApiException —— 交给页面统一处理展示。
|
||||
/// ============================================================
|
||||
class PostApi {
|
||||
/// GET:查询单个帖子 → /posts/1
|
||||
static Future<PostModel> fetchPost(int id) async {
|
||||
final data = await DioClient.instance.get('/posts/$id');
|
||||
return PostModel.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// GET:查询帖子列表 → /posts?userId=1
|
||||
static Future<List<PostModel>> fetchPosts({int? userId}) async {
|
||||
final data = await DioClient.instance.get(
|
||||
'/posts',
|
||||
query: {'userId': ?userId},
|
||||
);
|
||||
return (data as List)
|
||||
.map((e) => PostModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// POST:新建帖子 → POST /posts(jsonplaceholder 会模拟创建成功)
|
||||
static Future<PostModel> createPost({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
final data = await DioClient.instance.post('/posts', data: {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'userId': 1,
|
||||
});
|
||||
return PostModel.fromJson(data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
import 'package:new_learn/core/services/picker_service.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【UploadApi】文件上传接口(占位,待接口文档)
|
||||
///
|
||||
/// 一、当前状态:
|
||||
/// 接口文档待用户提供,本文件已按生产级封装思路预留完整方法签名:
|
||||
/// - 上传单张图片(头像 / 笔记共用)
|
||||
/// - 支持 Web(bytes)与原生(filePath)双端
|
||||
/// - 统一走 DioClient 拦截器(自动带 Token / 错误翻译)
|
||||
///
|
||||
/// 二、等接口文档后只需补两处:
|
||||
/// 1. 上面 _path 常量改成真实接口地址(如 /api/v1/File/UploadImage);
|
||||
/// 2. _upload 方法体里把 FormData 字段名 / 响应解析补全。
|
||||
///
|
||||
/// 三、高频坑(multipart 上传的 3 个经典错误):
|
||||
/// 1. Web 端没有 File 文件系统,必须用 MultipartFile.fromBytes,
|
||||
/// 原生端用 fromFile(从 picker_service 的 PickedImage 区分);
|
||||
/// 2. FormData 的 key 必须和后端字段名完全一致(如 'file' / 'files'),
|
||||
/// 大小写/下划线错一个后端就收不到;
|
||||
/// 3. Dio 上传要把 contentType 交给 FormData 自带,
|
||||
/// 不要再手动拼 multipart/form-data,否则会少 boundary。
|
||||
/// ============================================================
|
||||
class UploadApi {
|
||||
UploadApi._();
|
||||
|
||||
static final UploadApi instance = UploadApi._();
|
||||
|
||||
/// 上传接口地址(TODO: 用户提供接口文档后替换)
|
||||
static const String _path = '/api/v1/File/UploadImage';
|
||||
|
||||
/// 上传单张图片,成功返回图片 URL(网络地址,可直接塞进 avatar 字段)
|
||||
///
|
||||
/// [image] 由 PickerService 统一产出:
|
||||
/// - 原生:image.path 有值 → MultipartFile.fromFile
|
||||
/// - Web :image.bytes 有值 → MultipartFile.fromBytes
|
||||
Future<String?> uploadImage(PickedImage image) async {
|
||||
final data = await _upload([image]);
|
||||
// TODO: 根据真实接口返回结构解析图片 URL(可能返回字符串或对象)
|
||||
return data?.toString();
|
||||
}
|
||||
|
||||
/// 批量上传多张图片(笔记多图),成功返回 URL 列表
|
||||
Future<List<String>> uploadImages(List<PickedImage> images) async {
|
||||
await _upload(images);
|
||||
// TODO: 根据真实接口返回结构解析 URL 列表
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// 通用 multipart 上传实现
|
||||
Future<dynamic> _upload(List<PickedImage> images) async {
|
||||
final formData = FormData();
|
||||
|
||||
// 双端文件归一:原生走文件路径,Web 走字节
|
||||
for (int i = 0; i < images.length; i++) {
|
||||
final image = images[i];
|
||||
final fileName = image.name ?? 'image_$i.jpg';
|
||||
|
||||
final MultipartFile file;
|
||||
if (image.bytes != null) {
|
||||
// Web:没有文件系统,直接传字节(filename 必须带扩展名)
|
||||
file = MultipartFile.fromBytes(
|
||||
image.bytes!,
|
||||
filename: fileName,
|
||||
);
|
||||
} else {
|
||||
// 原生:传本地文件路径
|
||||
file = await MultipartFile.fromFile(
|
||||
image.path ?? '',
|
||||
filename: fileName,
|
||||
);
|
||||
}
|
||||
formData.files.add(MapEntry('files', file));
|
||||
}
|
||||
|
||||
return DioClient.instance.post(_path, data: formData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:internet_connection_checker_plus/internet_connection_checker_plus.dart';
|
||||
|
||||
class NetworkStatus {
|
||||
const NetworkStatus({
|
||||
required this.networkTypes,
|
||||
required this.hasInternet,
|
||||
required this.hasBackend,
|
||||
});
|
||||
|
||||
final List<ConnectivityResult> networkTypes;
|
||||
final bool hasInternet;
|
||||
final bool hasBackend;
|
||||
|
||||
static const NetworkStatus initial = NetworkStatus(
|
||||
networkTypes: <ConnectivityResult>[ConnectivityResult.none],
|
||||
hasInternet: false,
|
||||
hasBackend: false,
|
||||
);
|
||||
|
||||
NetworkStatus copyWith({
|
||||
List<ConnectivityResult>? networkTypes,
|
||||
bool? hasInternet,
|
||||
bool? hasBackend,
|
||||
}) {
|
||||
return NetworkStatus(
|
||||
networkTypes: networkTypes ?? this.networkTypes,
|
||||
hasInternet: hasInternet ?? this.hasInternet,
|
||||
hasBackend: hasBackend ?? this.hasBackend,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkStatusNotifier extends Notifier<NetworkStatus>
|
||||
with WidgetsBindingObserver {
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||||
bool _disposed = false;
|
||||
int _probeSeq = 0;
|
||||
|
||||
@override
|
||||
NetworkStatus build() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
ref.onDispose(() {
|
||||
_disposed = true;
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_connectivitySub?.cancel();
|
||||
});
|
||||
|
||||
unawaited(_init());
|
||||
|
||||
return NetworkStatus.initial;
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
unawaited(refresh());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
final types = await _connectivity.checkConnectivity();
|
||||
_handleConnectivityChanged(types);
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
final types = await _connectivity.checkConnectivity();
|
||||
_handleConnectivityChanged(types);
|
||||
|
||||
_connectivitySub = _connectivity.onConnectivityChanged.listen(
|
||||
_handleConnectivityChanged,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleConnectivityChanged(List<ConnectivityResult> types) {
|
||||
if (_disposed) return;
|
||||
|
||||
state = state.copyWith(networkTypes: types);
|
||||
|
||||
if (!_hasConnectivity(types)) {
|
||||
state = state.copyWith(hasInternet: false, hasBackend: false);
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(_probeInternetAndBackend());
|
||||
}
|
||||
|
||||
Future<void> _probeInternetAndBackend() async {
|
||||
final int seq = ++_probeSeq;
|
||||
|
||||
final bool hasInternet = await InternetConnection().hasInternetAccess;
|
||||
if (_disposed || seq != _probeSeq) return;
|
||||
|
||||
if (!hasInternet) {
|
||||
state = state.copyWith(hasInternet: false, hasBackend: false);
|
||||
return;
|
||||
}
|
||||
|
||||
final bool hasBackend = await _checkBackendAvailabilityStub();
|
||||
if (_disposed || seq != _probeSeq) return;
|
||||
|
||||
state = state.copyWith(hasInternet: true, hasBackend: hasBackend);
|
||||
}
|
||||
|
||||
Future<bool> _checkBackendAvailabilityStub() async {
|
||||
// TODO: 接入后端健康探针接口(例如 GET /health),将结果映射到 hasBackend。
|
||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _hasConnectivity(List<ConnectivityResult> types) {
|
||||
if (types.isEmpty) return false;
|
||||
if (types.length == 1 && types.first == ConnectivityResult.none) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final networkStatusProvider =
|
||||
NotifierProvider<NetworkStatusNotifier, NetworkStatus>(
|
||||
NetworkStatusNotifier.new,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【privacyAcceptedNotifier】启动隐私协议同意状态
|
||||
///
|
||||
/// 为什么这里不用 Riverpod:
|
||||
/// 1. go_router 的 redirect 需要一个全局可监听的 Listenable;
|
||||
/// 2. ValueNotifier 足够轻量,适合这种"一个布尔值控制全局门禁"的场景;
|
||||
/// 3. main.dart 在 runApp 前就能直接把本地状态恢复进来。
|
||||
///
|
||||
/// 值含义:
|
||||
/// true = 已同意,可以正常进入登录/主页
|
||||
/// false = 未同意,启动 Gate 和路由守卫都会先拦住
|
||||
/// ============================================================
|
||||
final ValueNotifier<bool> privacyAcceptedNotifier = ValueNotifier<bool>(false);
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:new_learn/core/models/user_model.dart';
|
||||
import 'package:new_learn/core/storage/user_storage.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【currentUserProvider】全局当前用户状态(Riverpod,对标 Vue 的 Pinia store)
|
||||
///
|
||||
/// //冷启动 → build() 从 SharedPreferences 读缓存 → 页面拿到用户信息
|
||||
/// 登录成功 → set(user) → 所有 watch 的页面自动刷新 + 路由守卫知道已登录
|
||||
/// 退出登录 → set(null) → 所有 watch 的页面刷新 + 路由守卫踢回 /login
|
||||
/// 换头像 → updateAvatar(url) → 内存 + 本地缓存都更新,页面自动刷新
|
||||
///
|
||||
/// 一、为什么用 AsyncNotifier 而不是 StateProvider:
|
||||
/// AsyncNotifier 的 build() 可以【异步加载初始值】——冷启动时自动从
|
||||
/// shared_preferences 读缓存回填,页面 ref.watch 后无需自己判断加载态;
|
||||
/// StateProvider 的初始值是同步写死的,不适合"启动时从磁盘读"。
|
||||
///
|
||||
/// 二、怎么用(三句话):
|
||||
/// 1. 读取:`final user = ref.watch(currentUserProvider).value;`
|
||||
/// 页面必须用 ConsumerWidget(build 有 ref)或 Consumer 包裹;
|
||||
/// 2. 更新:`ref.read(currentUserProvider.notifier).set(user);`
|
||||
/// 登录成功后调用,所有 ref.watch 的页面自动刷新;
|
||||
/// 3. 全局:ProviderScope 包在 main.dart 根,任何页面都能拿到。
|
||||
///
|
||||
/// 三、State 生命周期(AsyncValue):
|
||||
/// - AsyncData(user) → 有值:已登录/有缓存
|
||||
/// - AsyncData(null) → 明确未登录
|
||||
/// - AsyncLoading() → 正在读缓存(一闪而过)
|
||||
/// - AsyncError() → 读缓存异常
|
||||
///
|
||||
/// 四、高频坑:
|
||||
/// 1. 不要直接改 state 引用,要用 set() 统一入口,方便以后加日志;
|
||||
/// 2. 退出登录时调 set(null) → authTick 自增 → GoRouter.refresh(),
|
||||
/// 登录守卫 redirect 会自动把当前页踢回 /login,无需手动跳转;
|
||||
/// 3. AsyncNotifier 的 build 只在首次访问时执行,全局只有一份状态;
|
||||
/// 4. 冷启动走 build()(读缓存),不走 set(),所以 authTick 不触发;
|
||||
/// 运行中登录/登出才走 set(),此时守卫才会重新计算跳转。
|
||||
/// ============================================================
|
||||
class CurrentUserNotifier extends AsyncNotifier<CurrentUserExtension?> {
|
||||
/// 冷启动初始值:从 shared_preferences 读缓存(登录时已写入)
|
||||
@override
|
||||
Future<CurrentUserExtension?> build() async {
|
||||
return UserStorage.instance.readUserInfo();
|
||||
}
|
||||
|
||||
/// 登录成功 / 手动刷新后,写入全局状态(所有 ref.watch 页面自动刷新)
|
||||
void set(CurrentUserExtension? user) {
|
||||
state = AsyncData(user);
|
||||
// 通知路由守卫:登录态变化了,触发 GoRouter.refresh() 重算跳转
|
||||
authTick.value++;
|
||||
}
|
||||
|
||||
/// 只更新头像:登录态没变,不需要触发路由守卫重算。
|
||||
///
|
||||
/// 使用场景:
|
||||
/// 头像上传成功后,后端接口 /api/v1/User/UpdateUserAvatar 已经把服务器里的
|
||||
/// avatar 字段改掉了。此时前端不需要再去"我的"页重新请求一次用户信息,
|
||||
/// 只要把 currentUserProvider 里的 avatar 同步改掉即可。
|
||||
///
|
||||
/// 这段代码做了两件事:
|
||||
/// 1. 更新 Riverpod 内存状态:让 ref.watch(currentUserProvider) 的页面立刻刷新;
|
||||
/// 2. 更新本地缓存:保证 App 重启后还是新头像,不会回退成旧值。
|
||||
///
|
||||
/// 注意:
|
||||
/// 这里只是"用户资料局部字段更新",不是登录/登出,所以不需要 authTick++。
|
||||
Future<void> updateAvatar(String avatar) async {
|
||||
// 先拿到当前全局用户对象。
|
||||
// state 是 AsyncValue<CurrentUserExtension?>,value 就是里面当前的用户数据。
|
||||
final current = state.value;
|
||||
|
||||
// 防御:如果当前没有用户信息(比如未登录),就没有可更新的对象,直接返回。
|
||||
if (current == null) return;
|
||||
|
||||
// 基于旧对象复制一个新对象,只替换 avatar 字段,其他字段保持不变。
|
||||
// 这是状态管理里的常见写法:不要直接改旧对象,而是生成一个"新状态"。
|
||||
final updated = current.copyWith(avatar: avatar);
|
||||
|
||||
// 把 Riverpod 状态更新成新对象。
|
||||
// 一旦这里赋值成功,所有 ref.watch(currentUserProvider) 的页面都会自动 rebuild,
|
||||
// 所以"我的"页头像会立刻刷新,不用再手动 setState 整个页面。
|
||||
state = AsyncData(updated);
|
||||
|
||||
// 同步写回本地缓存:
|
||||
// 这样不仅当前界面是新头像,App 下次重启时从 SharedPreferences 读出来的
|
||||
// 也还是最新头像。
|
||||
await UserStorage.instance.saveUserInfo(updated);
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录态变化信号(给 go_router 的 refreshListenable 用)
|
||||
///
|
||||
/// 为什么需要它:go_router 的 redirect 只在【导航发生时】执行。
|
||||
/// 登出/登录不导航,守卫就不会重新判断——需要一个外部信号去主动
|
||||
/// 触发一次重算。用 ValueNotifier 当信号源:
|
||||
/// set(null) 登出 → authTick.value++ → GoRouter.refresh()
|
||||
/// → redirect 重跑 → 未登录访问 /my → 自动重定向 /login。
|
||||
/// 这就是「响应式登录守卫」:业务代码永远不用手动跳登录页。
|
||||
final ValueNotifier<int> authTick = ValueNotifier<int>(0);
|
||||
|
||||
/// 全局用户信息 Provider
|
||||
final currentUserProvider =
|
||||
AsyncNotifierProvider<CurrentUserNotifier, CurrentUserExtension?>(
|
||||
CurrentUserNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/providers/privacy_provider.dart';
|
||||
import 'package:new_learn/core/router/router_keys.dart';
|
||||
import 'package:new_learn/features/auth/legal_document_page.dart';
|
||||
import 'package:new_learn/features/auth/legal_documents.dart';
|
||||
import 'package:new_learn/features/auth/login_page.dart';
|
||||
import 'package:new_learn/features/auth/privacy_consent_page.dart';
|
||||
import 'package:new_learn/features/class/class_page.dart';
|
||||
import 'package:new_learn/features/my/my_page.dart';
|
||||
import 'package:new_learn/features/my/my_point/my_point_page.dart';
|
||||
import 'package:new_learn/features/recite/recite_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/network/dio_client.dart';
|
||||
import 'package:new_learn/core/providers/user_provider.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AppRouter】全局路由表(对标 Vue 的 router/index.ts)
|
||||
///
|
||||
/// 用户点退出
|
||||
/// → ref.read(currentUserProvider.notifier).set(null)
|
||||
/// → state = AsyncData(null) // 清空用户信息
|
||||
/// → authTick.value++ // 0 变成 1
|
||||
/// → GoRouter 收到通知,执行 refresh()
|
||||
/// → 重新跑 _guardLogin()
|
||||
/// → 发现 accessToken == null(登出时已清除)
|
||||
/// → 当前不在 /login
|
||||
/// → return '/login' // 自动跳转到登录页
|
||||
///
|
||||
/// 一、为什么用 go_router(Flutter 官方推荐声明式路由):
|
||||
/// 页面以后会很多,集中管路由表比散落各页面的 Navigator.push
|
||||
/// 更可控,且天然支持登录守卫 / tab 嵌套 / 深链。
|
||||
///
|
||||
/// 二、与 vue-router 的对应关系:
|
||||
/// routes 路由表 = vue-router 的 routes
|
||||
/// redirect 登录守卫 = 路由前置守卫 beforeEach
|
||||
/// ShellRoute 4 tab 壳 = uni-app 的 tabBar + 子页面
|
||||
/// context.push('/x') = router.push('/x')
|
||||
/// context.go('/x') = router.replace('/x')(不留历史,如登录后)
|
||||
///
|
||||
/// 三、路由结构:
|
||||
/// /login → 登录页(无 token 时被 redirect 强制到这里)
|
||||
/// / → 任务页(4 tab 第 1 个)
|
||||
/// /recite /class /my → 其余 3 个 tab(由 ShellRoute 包住,共用一个底部导航)
|
||||
/// 以后新业务页:在 routes 里加一行 GoRoute 即可。
|
||||
///
|
||||
/// 四、登录守卫说明:
|
||||
/// 判断依据是 DioClient.accessToken(内存)——main() 里已 restoreToken
|
||||
/// 回填,所以启动时第一个跳转就能正确判断。
|
||||
/// ⚠ redirect 只在【导航发生时】触发,登录/登出不导航守卫不会动,
|
||||
/// 所以配了 refreshListenable: authTick —— 登录态一变(user_provider
|
||||
/// 的 set() 里 authTick.value++)就主动 GoRouter.refresh() 重算一次,
|
||||
/// 未登录访问受限页 → 自动踢 /login,已登录访问 /login → 踢回 /。
|
||||
/// 业务代码永远不用手动跳登录页(这就是"响应式登录守卫")。
|
||||
///
|
||||
/// 五、Web 地址栏同步(go_router 17.x 大坑):
|
||||
/// 17.x 起 optionURLReflectsImperativeAPIs 默认 false:context.push/go
|
||||
/// 导航时页面正常切换,但浏览器地址栏不更新(刷新会退回上一个路由)。
|
||||
/// 必须在创建 GoRouter 前全局开启,见 main.dart 的 main()。
|
||||
/// ============================================================
|
||||
|
||||
/// 全局路由表(对标 Vue 的 router/index.ts)
|
||||
final GoRouter appRouter = GoRouter(
|
||||
navigatorKey:
|
||||
rootNavigatorKey, // 告诉 GoRouter:这是根 Navigator 作用: 在拿不到 BuildContext 的地方,也能操作导航和弹窗
|
||||
redirect: _guardLogin, // 登录守卫:未登录访问受限页 → 自动跳转到登录页
|
||||
// 两类全局门禁都要能触发路由重算:
|
||||
// 1. authTick:登录/登出变化;
|
||||
// 2. privacyAcceptedNotifier:隐私协议同意状态变化。
|
||||
// 响应式登录守卫的核心:监听登录态变化信号(user_provider 的 set()
|
||||
// 里自增),变化时自动 GoRouter.refresh() 重算 redirect。
|
||||
// 没有它,登出后只有下一次手动导航才会被拦截。
|
||||
refreshListenable: Listenable.merge([
|
||||
authTick,
|
||||
privacyAcceptedNotifier,
|
||||
]), // 响应式登录守卫:监听登录态变化信号,变化时自动重算 redirect
|
||||
routes: [
|
||||
// ---------- 4 tab 主框架(ShellRoute:子页面共享这个壳) ----------
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => MainTabPage(child: child),
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const TaskPage()),
|
||||
GoRoute(path: '/recite', builder: (_, _) => const RecitePage()),
|
||||
GoRoute(path: '/class', builder: (_, _) => const ClassPage()),
|
||||
GoRoute(path: '/my', builder: (_, _) => const MyPage()),
|
||||
],
|
||||
),
|
||||
// ---------- 首次启动隐私协议页 ----------
|
||||
GoRoute(
|
||||
path: '/privacy-consent',
|
||||
builder: (_, _) => const PrivacyConsentPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/user-agreement',
|
||||
builder: (_, _) => const LegalDocumentPage(
|
||||
title: kUserAgreementTitle,
|
||||
content: kUserAgreementContent,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/privacy-policy',
|
||||
builder: (_, _) => const LegalDocumentPage(
|
||||
title: kPrivacyPolicyTitle,
|
||||
content: kPrivacyPolicyContent,
|
||||
),
|
||||
),
|
||||
// ---------- 登录页 ----------
|
||||
GoRoute(path: '/login', builder: (_, _) => const LoginPage()),
|
||||
// ---------- 业务子页(从 tab 页 push 进来,不占 tab) ----------
|
||||
GoRoute(path: '/myPoint', builder: (_, _) => const MyPointPage()),
|
||||
// app_router.dart 的 routes 里加,不用管跳转逻辑
|
||||
// GoRoute(path: '/task/:id', builder: (_, _) => const TaskDetailPage()),
|
||||
// ---------- 以后新业务页在这里加,一行一个 ----------
|
||||
],
|
||||
);
|
||||
|
||||
/// 登录守卫(对标 vue-router beforeEach):
|
||||
/// 没登录 → 强制去 /login;已登录还访问 /login → 踢回首页
|
||||
String? _guardLogin(BuildContext context, GoRouterState state) {
|
||||
final acceptedPrivacy = privacyAcceptedNotifier.value;
|
||||
final loggedIn = DioClient.instance.accessToken != null;
|
||||
final atPrivacyConsent = state.matchedLocation == '/privacy-consent';
|
||||
final atLegalDocument =
|
||||
state.matchedLocation == '/user-agreement' ||
|
||||
state.matchedLocation == '/privacy-policy';
|
||||
final atLogin = state.matchedLocation == '/login';
|
||||
if (!acceptedPrivacy && !atPrivacyConsent && !atLegalDocument) {
|
||||
return '/privacy-consent';
|
||||
}
|
||||
if (acceptedPrivacy && atPrivacyConsent) return loggedIn ? '/' : '/login';
|
||||
if (!loggedIn && !atLogin && !atLegalDocument) return '/login';
|
||||
if (loggedIn && atLogin) return '/';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:new_learn/core/components/app_update_dialog.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
import 'package:new_learn/core/version/app_version_service.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【MainTabPage】App 主框架 —— 底部 4 tab 壳(go_router ShellRoute 模式)
|
||||
///
|
||||
/// 一、结构(go_router 标准写法):
|
||||
/// app_router.dart 里用 ShellRoute 包住本组件,ShellRoute 提供 child
|
||||
/// (当前 tab 页面),本组件负责画壳:
|
||||
/// Scaffold
|
||||
/// ├── body: child(来自 ShellRoute,随路由切换)
|
||||
/// └── bottomNavigationBar: BottomNavigationBar(4 项,PNG 图标)
|
||||
///
|
||||
/// 二、tab 高亮原理:
|
||||
/// 通过 GoRouterState.of(context) 读当前路由 path 反推 tab 序号;
|
||||
/// 点底部导航 → context.go('/recite') 等,路由变了壳自动重绘。
|
||||
/// 对比旧版自己管理 IndexedStack + _index:现在页面栈交给 go_router,
|
||||
/// 切换 tab 的状态保留由 go_router 的页面缓存机制负责。
|
||||
///
|
||||
/// 三、图标方案(重点):
|
||||
/// 两套 PNG,标准做法:BottomNavigationBarItem 的
|
||||
/// - icon = 未选中图(assets/img/tabbar/*.png)
|
||||
/// - activeIcon = 选中图(assets/img/tabbar/default/*Choose.png)
|
||||
/// BottomNavigationBar 会根据 currentIndex 自动切换,无需手动处理。
|
||||
/// ⚠ 不要用 ColorFiltered 染色:会把多色/渐变/镂空压平成一团色块。
|
||||
///
|
||||
/// 四、高频坑:
|
||||
/// 1. BottomNavigationBarItem.icon 必须提供,activeIcon 可选;
|
||||
/// 2. 4 个 tab 时 type 默认 fixed(label 都会显示);
|
||||
/// 3. Image.asset 图片要先把 assets 目录注册进 pubspec.yaml
|
||||
/// (本工程已注册整个 assets/ 目录);
|
||||
/// 4. 在 tab 内 push 子页面(如 /task/1)时,ShellRoute 保证
|
||||
/// 底部导航不消失、子页面盖在内容区上——这是 ShellRoute 的价值。
|
||||
/// ============================================================
|
||||
class MainTabPage extends StatefulWidget {
|
||||
const MainTabPage({super.key, required this.child});
|
||||
|
||||
/// 当前 tab 页面(由 go_router ShellRoute 提供)
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<MainTabPage> createState() => _MainTabPageState();
|
||||
}
|
||||
|
||||
class _MainTabPageState extends State<MainTabPage> {
|
||||
/// 标记:本页面生命周期只执行一次冷启动版本检查
|
||||
bool _versionChecked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
// addPostFrameCallback:保证页面首帧渲染完成,context 可用再弹窗
|
||||
if (_versionChecked) return;
|
||||
_versionChecked = true;
|
||||
await _checkVersion();
|
||||
});
|
||||
}
|
||||
|
||||
/// 版本检查流程:初始化 → 拉取远端 → 比对 → 弹窗
|
||||
/// 仅冷启动执行一次,不回前台重复检查(避免骚扰用户)
|
||||
///
|
||||
/// ⚠ 前置校验登录态:路由守卫 redirect 理论上会拦住未登录用户,
|
||||
/// 但 MainTabPage 可能在 redirect 生效前就被构建(首帧回调时序),
|
||||
/// 所以这里兜底:没登录就不弹窗,避免未登录用户看到版本更新弹窗。
|
||||
Future<void> _checkVersion() async {
|
||||
// 未登录 → 路由守卫即将重定向到 /login,版本弹窗没有意义
|
||||
if (DioClient.instance.accessToken == null) return;
|
||||
try {
|
||||
final service = AppVersionService.instance;
|
||||
await service.init();
|
||||
final update = await service.checkForUpdate();
|
||||
// 二次校验:异步等待期间可能已被路由守卫重定向到登录页
|
||||
if (update == null || !mounted) return;
|
||||
if (DioClient.instance.accessToken == null) return;
|
||||
final isForce = service.isForceUpdate();
|
||||
await AppUpdateDialog.show(context, update, isForce: isForce);
|
||||
} catch (e) {
|
||||
debugPrint('▶ 【版本】检查更新异常:$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 未选中态图标(assets/img/tabbar/ 根目录)
|
||||
static const List<String> _icons = [
|
||||
'assets/img/tabbar/index.png', // 任务
|
||||
'assets/img/tabbar/recite.png', // 背诵
|
||||
'assets/img/tabbar/class.png', // 班级
|
||||
'assets/img/tabbar/my.png', // 我的
|
||||
];
|
||||
|
||||
/// 选中态图标(assets/img/tabbar/default/ 下的 *Choose.png)
|
||||
static const List<String> _activeIcons = [
|
||||
'assets/img/tabbar/default/indexChoose.png',
|
||||
'assets/img/tabbar/default/reciteChoose.png',
|
||||
'assets/img/tabbar/default/classChoose.png',
|
||||
'assets/img/tabbar/default/myChoose.png',
|
||||
];
|
||||
|
||||
/// 当前路由 path → tab 序号(默认第 1 个"任务")
|
||||
int _indexOf(String path) {
|
||||
return switch (path) {
|
||||
'/recite' => 1,
|
||||
'/class' => 2,
|
||||
'/my' => 3,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// tab 序号 → 路由 path(点击底部导航时跳转)
|
||||
String _pathOf(int index) {
|
||||
return switch (index) {
|
||||
1 => '/recite',
|
||||
2 => '/class',
|
||||
3 => '/my',
|
||||
_ => '/',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 从 go_router 拿当前路由,反推底部高亮 tab
|
||||
final path = GoRouterState.of(context).uri.path;
|
||||
final currentIndex = _indexOf(path);
|
||||
|
||||
return Scaffold(
|
||||
body: widget.child,
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: currentIndex,
|
||||
onTap: (i) => context.go(_pathOf(i)),
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: Colors.white,
|
||||
selectedItemColor: const Color(0xFF3B82F6),
|
||||
unselectedItemColor: const Color(0xFF9E9E9E),
|
||||
selectedFontSize: 11.sp,
|
||||
unselectedFontSize: 11.sp,
|
||||
iconSize: 24.w,
|
||||
items: [
|
||||
for (int i = 0; i < _icons.length; i++)
|
||||
BottomNavigationBarItem(
|
||||
// icon = 未选中图,activeIcon = 选中图,两套图自动切换
|
||||
icon: Image.asset(_icons[i], width: 24.w, height: 24.h),
|
||||
activeIcon: Image.asset(
|
||||
_activeIcons[i],
|
||||
width: 24.w,
|
||||
height: 24.h,
|
||||
),
|
||||
label: switch (i) {
|
||||
0 => '任务',
|
||||
1 => '背诵',
|
||||
2 => '班级',
|
||||
_ => '我的',
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 全局根导航 key:
|
||||
/// 1. 给 GoRouter 作为根 Navigator 使用;
|
||||
/// 2. 供启动隐私 Gate 这类"路由外层"页面直接打开子页面。
|
||||
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
@@ -0,0 +1,340 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
|
||||
/// ============================================================
|
||||
/// 【ImageProcessor】图片压缩 + 方向修正 + 尺寸过滤
|
||||
///
|
||||
/// 一、平台策略:
|
||||
/// 1. Android/iOS/macOS 的拍照/JPG:优先走 flutter_image_compress
|
||||
/// → 原生编解码器 + autoCorrectionAngle:true,直接修正拍照方向;
|
||||
/// 2. Web 与 PNG:继续走 image 纯 Dart 方案,避免 PNG 被原生重编码后变大;
|
||||
/// 3. 两条链都先做尺寸过滤:宽高必须在 14~4000 像素之间。
|
||||
///
|
||||
/// 二、为什么这样分:
|
||||
/// 1. 本次真机调试已经确认:部分机型拍照文件没有可用 EXIF orientation,
|
||||
/// 只靠 Dart 侧 bakeOrientation 不够稳;
|
||||
/// 2. flutter_image_compress 的 autoCorrectionAngle 更适合原生拍照文件;
|
||||
/// 3. PNG/截图则更适合保留现有纯 Dart 方案,避免体积反涨。
|
||||
///
|
||||
/// 三、高频坑:
|
||||
/// 1. Flutter 布局本质是:父组件给子组件约束,子组件决定自身大小。
|
||||
/// 本服务只处理字节,不参与布局,但上传预览时别把大图直接塞进
|
||||
/// 无约束的 Row/Column,否则照样会溢出;
|
||||
/// 2. image 包的 `Image` 类和 Flutter 的 `Image` widget 同名,
|
||||
/// 所以这里必须 `as img`;
|
||||
/// 3. 原生压缩不是所有平台都支持,Web/Windows 会自动回退纯 Dart 路径。
|
||||
/// ============================================================
|
||||
class ProcessedImage {
|
||||
const ProcessedImage({
|
||||
required this.bytes,
|
||||
required this.extension,
|
||||
required this.contentType,
|
||||
required this.originalBytes,
|
||||
required this.processedBytes,
|
||||
});
|
||||
|
||||
final Uint8List bytes;
|
||||
final String extension;
|
||||
final String contentType;
|
||||
final int originalBytes;
|
||||
final int processedBytes;
|
||||
}
|
||||
|
||||
class ImageProcessor {
|
||||
ImageProcessor._();
|
||||
|
||||
static const int minWidth = 14;
|
||||
static const int minHeight = 14;
|
||||
static const int maxWidth = 4000;
|
||||
static const int maxHeight = 4000;
|
||||
|
||||
static Future<ProcessedImage?> process({
|
||||
required Uint8List bytes,
|
||||
String? fileName,
|
||||
int maxDimension = 1920,
|
||||
int quality = 85,
|
||||
}) async {
|
||||
final inputMeta = _detectInputMeta(bytes, fileName);
|
||||
|
||||
if (_shouldUseNativeCompressor(inputMeta.kind)) {
|
||||
final native = await _processWithNative(
|
||||
bytes: bytes,
|
||||
fileName: fileName,
|
||||
maxDimension: maxDimension,
|
||||
quality: quality,
|
||||
inputMeta: inputMeta,
|
||||
);
|
||||
if (native != null) return native;
|
||||
}
|
||||
|
||||
final result = await compute(
|
||||
_processInIsolate,
|
||||
(
|
||||
bytes: bytes,
|
||||
fileName: fileName,
|
||||
maxDimension: maxDimension,
|
||||
quality: quality,
|
||||
),
|
||||
);
|
||||
if (result == null) return null;
|
||||
|
||||
return ProcessedImage(
|
||||
bytes: result.bytes,
|
||||
extension: result.extension,
|
||||
contentType: result.contentType,
|
||||
originalBytes: result.originalBytes,
|
||||
processedBytes: result.processedBytes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _ProcessTask = ({
|
||||
Uint8List bytes,
|
||||
String? fileName,
|
||||
int maxDimension,
|
||||
int quality,
|
||||
});
|
||||
|
||||
typedef _ProcessResult = ({
|
||||
Uint8List bytes,
|
||||
String extension,
|
||||
String contentType,
|
||||
int originalBytes,
|
||||
int processedBytes,
|
||||
});
|
||||
|
||||
typedef _ImageMeta = ({
|
||||
_EncodedKind kind,
|
||||
String extension,
|
||||
String contentType,
|
||||
});
|
||||
|
||||
typedef _TargetSize = ({int width, int height});
|
||||
|
||||
enum _EncodedKind { jpeg, png, other }
|
||||
|
||||
bool _shouldUseNativeCompressor(_EncodedKind kind) {
|
||||
if (kIsWeb || kind == _EncodedKind.png) return false;
|
||||
return switch (defaultTargetPlatform) {
|
||||
TargetPlatform.android => true,
|
||||
TargetPlatform.iOS => true,
|
||||
TargetPlatform.macOS => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
Future<ProcessedImage?> _processWithNative({
|
||||
required Uint8List bytes,
|
||||
required String? fileName,
|
||||
required int maxDimension,
|
||||
required int quality,
|
||||
required _ImageMeta inputMeta,
|
||||
}) async {
|
||||
final decoded = img.decodeImage(bytes);
|
||||
if (decoded == null) return null;
|
||||
|
||||
if (!_isInAllowedSize(decoded.width, decoded.height)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final target = _targetSize(
|
||||
width: decoded.width,
|
||||
height: decoded.height,
|
||||
maxDimension: maxDimension,
|
||||
);
|
||||
|
||||
final compressed = await FlutterImageCompress.compressWithList(
|
||||
bytes,
|
||||
minWidth: target.width,
|
||||
minHeight: target.height,
|
||||
quality: quality,
|
||||
format:
|
||||
inputMeta.kind == _EncodedKind.png ? CompressFormat.png : CompressFormat.jpeg,
|
||||
autoCorrectionAngle: true,
|
||||
);
|
||||
|
||||
final outputBytes =
|
||||
compressed.isNotEmpty ? Uint8List.fromList(compressed) : bytes;
|
||||
final outputMeta = _detectOutputMeta(
|
||||
originalMeta: inputMeta,
|
||||
outputBytes: outputBytes,
|
||||
);
|
||||
|
||||
return ProcessedImage(
|
||||
bytes: outputBytes,
|
||||
extension: outputMeta.extension,
|
||||
contentType: outputMeta.contentType,
|
||||
originalBytes: bytes.length,
|
||||
processedBytes: outputBytes.length,
|
||||
);
|
||||
}
|
||||
|
||||
_ProcessResult? _processInIsolate(_ProcessTask task) {
|
||||
final inputMeta = _detectInputMeta(task.bytes, task.fileName);
|
||||
final decoded = img.decodeImage(task.bytes);
|
||||
if (decoded == null) return null;
|
||||
|
||||
if (!_isInAllowedSize(decoded.width, decoded.height)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final hasOrientation =
|
||||
decoded.exif.imageIfd.hasOrientation &&
|
||||
decoded.exif.imageIfd.orientation != 1;
|
||||
final oriented = img.bakeOrientation(decoded);
|
||||
|
||||
var output = oriented;
|
||||
var resized = false;
|
||||
final longest = oriented.width >= oriented.height ? oriented.width : oriented.height;
|
||||
if (longest > task.maxDimension) {
|
||||
resized = true;
|
||||
final scale = task.maxDimension / longest;
|
||||
output = img.copyResize(
|
||||
oriented,
|
||||
width: (oriented.width * scale).round(),
|
||||
height: (oriented.height * scale).round(),
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasOrientation && !resized) {
|
||||
final originalMeta = _preserveOriginalMeta(task.bytes, task.fileName);
|
||||
return (
|
||||
bytes: task.bytes,
|
||||
extension: originalMeta.extension,
|
||||
contentType: originalMeta.contentType,
|
||||
originalBytes: task.bytes.length,
|
||||
processedBytes: task.bytes.length,
|
||||
);
|
||||
}
|
||||
|
||||
final encoded = switch (inputMeta.kind) {
|
||||
_EncodedKind.png => img.encodePng(output),
|
||||
_ => img.encodeJpg(output, quality: task.quality),
|
||||
};
|
||||
|
||||
return (
|
||||
bytes: Uint8List.fromList(encoded),
|
||||
extension: inputMeta.extension,
|
||||
contentType: inputMeta.contentType,
|
||||
originalBytes: task.bytes.length,
|
||||
processedBytes: encoded.length,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isInAllowedSize(int width, int height) {
|
||||
return width >= ImageProcessor.minWidth &&
|
||||
height >= ImageProcessor.minHeight &&
|
||||
width <= ImageProcessor.maxWidth &&
|
||||
height <= ImageProcessor.maxHeight;
|
||||
}
|
||||
|
||||
_TargetSize _targetSize({
|
||||
required int width,
|
||||
required int height,
|
||||
required int maxDimension,
|
||||
}) {
|
||||
final longest = width >= height ? width : height;
|
||||
if (longest <= maxDimension) return (width: width, height: height);
|
||||
|
||||
final scale = maxDimension / longest;
|
||||
return (
|
||||
width: (width * scale).round(),
|
||||
height: (height * scale).round(),
|
||||
);
|
||||
}
|
||||
|
||||
_ImageMeta _detectInputMeta(Uint8List bytes, String? fileName) {
|
||||
if (_isPng(bytes)) {
|
||||
return (
|
||||
kind: _EncodedKind.png,
|
||||
extension: 'png',
|
||||
contentType: 'image/png',
|
||||
);
|
||||
}
|
||||
if (_isJpeg(bytes)) {
|
||||
return (
|
||||
kind: _EncodedKind.jpeg,
|
||||
extension: 'jpg',
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
}
|
||||
|
||||
final ext = _extensionFromName(fileName);
|
||||
if (ext == 'png') {
|
||||
return (
|
||||
kind: _EncodedKind.png,
|
||||
extension: 'png',
|
||||
contentType: 'image/png',
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
kind: _EncodedKind.other,
|
||||
extension: 'jpg',
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
}
|
||||
|
||||
_ImageMeta _detectOutputMeta({
|
||||
required _ImageMeta originalMeta,
|
||||
required Uint8List outputBytes,
|
||||
}) {
|
||||
if (_isPng(outputBytes)) {
|
||||
return (
|
||||
kind: _EncodedKind.png,
|
||||
extension: 'png',
|
||||
contentType: 'image/png',
|
||||
);
|
||||
}
|
||||
if (_isJpeg(outputBytes)) {
|
||||
return (
|
||||
kind: _EncodedKind.jpeg,
|
||||
extension: 'jpg',
|
||||
contentType: 'image/jpeg',
|
||||
);
|
||||
}
|
||||
return originalMeta;
|
||||
}
|
||||
|
||||
({String extension, String contentType}) _preserveOriginalMeta(
|
||||
Uint8List bytes,
|
||||
String? fileName,
|
||||
) {
|
||||
final ext = _extensionFromName(fileName);
|
||||
switch (ext) {
|
||||
case 'png':
|
||||
return (extension: 'png', contentType: 'image/png');
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return (extension: 'jpg', contentType: 'image/jpeg');
|
||||
case 'webp':
|
||||
return (extension: 'webp', contentType: 'image/webp');
|
||||
case 'gif':
|
||||
return (extension: 'gif', contentType: 'image/gif');
|
||||
}
|
||||
|
||||
if (_isPng(bytes)) return (extension: 'png', contentType: 'image/png');
|
||||
if (_isJpeg(bytes)) return (extension: 'jpg', contentType: 'image/jpeg');
|
||||
return (extension: 'jpg', contentType: 'image/jpeg');
|
||||
}
|
||||
|
||||
String _extensionFromName(String? fileName) {
|
||||
if (fileName == null || !fileName.contains('.')) return '';
|
||||
return fileName.split('.').last.toLowerCase();
|
||||
}
|
||||
|
||||
bool _isPng(Uint8List bytes) {
|
||||
return bytes.length >= 8 &&
|
||||
bytes[0] == 0x89 &&
|
||||
bytes[1] == 0x50 &&
|
||||
bytes[2] == 0x4E &&
|
||||
bytes[3] == 0x47;
|
||||
}
|
||||
|
||||
bool _isJpeg(Uint8List bytes) {
|
||||
return bytes.length >= 3 &&
|
||||
bytes[0] == 0xFF &&
|
||||
bytes[1] == 0xD8 &&
|
||||
bytes[2] == 0xFF;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
经验教训(Android 尤其是 vivo 等强省电 ROM):
|
||||
1) 立即通知(show)相对可靠:只要通知权限/渠道允许,通常能稳定投递。
|
||||
2) 定时通知(zonedSchedule)不可靠:即使 pending=1 表示“已注册到系统”,系统仍可能因为省电/后台冻结/厂商策略而不触发投递(active 不出现该 id)。
|
||||
3) exact 能力≠一定准点:canExact=true 仅代表系统允许“精确定时”接口,实际触发仍可能被 ROM 延迟/拦截;短延时(15 秒/1 分钟)更容易受影响。
|
||||
4) 调试定时链路的最小证据:canPost/canExact + pendingNotificationRequests + getActiveNotifications(能区分“没安排”vs“已安排但没触发”vs“已投递但展示被静默”)。
|
||||
5) 业务侧策略:做题过程的准点提醒优先用前台 Timer;必须后台稳定提醒建议用服务端推送(FCM/APNs)而不是本地闹钟。
|
||||
*/
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
import 'package:new_learn/core/logger/app_talker.dart';
|
||||
import 'package:new_learn/core/router/app_router.dart';
|
||||
import 'package:timezone/data/latest.dart' as tz_data;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
class LocalNotificationService {
|
||||
LocalNotificationService._();
|
||||
|
||||
static final LocalNotificationService instance = LocalNotificationService._();
|
||||
|
||||
final FlutterLocalNotificationsPlugin _plugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
int _newNotificationId() {
|
||||
final id = DateTime.now().microsecondsSinceEpoch & 0x7fffffff;
|
||||
return id == 0 ? 1 : id;
|
||||
}
|
||||
|
||||
Future<void> ensureInitialized() async {
|
||||
if (_initialized) return;
|
||||
if (kIsWeb) {
|
||||
_initialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
tz_data.initializeTimeZones();
|
||||
final tzInfo = await FlutterTimezone.getLocalTimezone();
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation(tzInfo.identifier));
|
||||
} catch (e) {
|
||||
talker.warning('初始化时区失败:$e');
|
||||
}
|
||||
|
||||
const androidInit = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosInit = DarwinInitializationSettings();
|
||||
const initSettings =
|
||||
InitializationSettings(android: androidInit, iOS: iosInit);
|
||||
|
||||
await _plugin.initialize(
|
||||
settings: initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||
);
|
||||
|
||||
_initialized = true;
|
||||
|
||||
final details = await _plugin.getNotificationAppLaunchDetails();
|
||||
final payload = details?.notificationResponse?.payload;
|
||||
if (details?.didNotificationLaunchApp == true && payload != null) {
|
||||
_go(payload);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
if (kIsWeb) return false;
|
||||
|
||||
final android =
|
||||
_plugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
final ios =
|
||||
_plugin.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin>();
|
||||
|
||||
final androidGranted = await android?.requestNotificationsPermission();
|
||||
final iosGranted = await ios?.requestPermissions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
|
||||
return (androidGranted ?? true) && (iosGranted ?? true);
|
||||
}
|
||||
|
||||
Future<void> showMyPointNow() async {
|
||||
await ensureInitialized();
|
||||
await requestPermissions();
|
||||
|
||||
final id = _newNotificationId();
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'reminder',
|
||||
'提醒',
|
||||
channelDescription: '学习提醒与业务通知',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _plugin.show(
|
||||
id: id,
|
||||
title: '通知测试(立即)',
|
||||
body: '如果你看到这条,说明通知权限/渠道正常',
|
||||
notificationDetails: details,
|
||||
payload: '/myPoint',
|
||||
);
|
||||
|
||||
talker.debug('已发送立即通知:id=$id');
|
||||
}
|
||||
|
||||
Future<void> scheduleMyPointAfter15Seconds() async {
|
||||
await ensureInitialized();
|
||||
await requestPermissions();
|
||||
|
||||
final id = _newNotificationId();
|
||||
|
||||
final android =
|
||||
_plugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
final canPost = await android?.areNotificationsEnabled();
|
||||
var canExact = await android?.canScheduleExactNotifications();
|
||||
if (canExact == false) {
|
||||
await android?.requestExactAlarmsPermission();
|
||||
canExact = await android?.canScheduleExactNotifications();
|
||||
}
|
||||
talker.debug('通知能力检测:canPost=$canPost canExact=$canExact');
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'reminder',
|
||||
'提醒',
|
||||
channelDescription: '学习提醒与业务通知',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
final scheduled =
|
||||
tz.TZDateTime.now(tz.local).add(const Duration(seconds: 15));
|
||||
|
||||
await _plugin.zonedSchedule(
|
||||
id: id,
|
||||
scheduledDate: scheduled,
|
||||
notificationDetails: details,
|
||||
androidScheduleMode: canExact == true
|
||||
? AndroidScheduleMode.exactAllowWhileIdle
|
||||
: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
title: '积分提醒(15秒)',
|
||||
body: '点我查看积分明细',
|
||||
payload: '/myPoint',
|
||||
);
|
||||
|
||||
final pending = await _plugin.pendingNotificationRequests();
|
||||
talker.debug(
|
||||
'已安排本地通知:pending=${pending.length} ids=${pending.map((e) => e.id).toList()}',
|
||||
);
|
||||
|
||||
Timer(const Duration(seconds: 25), () async {
|
||||
final pendingLater = await _plugin.pendingNotificationRequests();
|
||||
final activeLater = await _plugin.getActiveNotifications();
|
||||
talker.debug(
|
||||
'25秒后:pending=${pendingLater.length} ids=${pendingLater.map((e) => e.id).toList()} active=${activeLater.length} ids=${activeLater.map((e) => e.id).toList()}',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static void _onNotificationResponse(NotificationResponse response) {
|
||||
final payload = response.payload;
|
||||
if (payload == null || payload.isEmpty) return;
|
||||
_go(payload);
|
||||
}
|
||||
|
||||
static void _go(String location) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
appRouter.go(location);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
|
||||
import 'package:wechat_camera_picker/wechat_camera_picker.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【PickerService】图片选择服务(Web 自动降级)
|
||||
///
|
||||
/// 一、为什么包一层:
|
||||
/// 业务页不用关心平台差异,统一调 showPickSheet / pickFromGallery /
|
||||
/// pickFromCamera,返回统一的 PickedImage 模型,上传时直接拿去用。
|
||||
///
|
||||
/// 二、双平台策略(选型结论):
|
||||
/// 1. 生产端(Android/iOS):wechat_assets_picker + wechat_camera_picker
|
||||
/// → UI 对标微信 8.0.51,相册支持多选/预览/排序/压缩;
|
||||
/// 2. 调试端(Web):image_picker(官方,唯一支持 Web 的选择器)
|
||||
/// → 功能简化为单选/多选,保证桌面浏览器能跑通整条链路。
|
||||
///
|
||||
/// 三、高频坑:
|
||||
/// 1. wechat 系列【不支持 Web】!直接 import 在 Web 上编译不过,
|
||||
/// 所以本文件必须用 kIsWeb 在运行时分流,才能 Web 打包;
|
||||
/// 2. AssetEntity.file 在 Android 上要先等 photo_manager 权限回调,
|
||||
/// 本服务已统一在方法内 await,业务页不要再碰 picker 内部 API;
|
||||
/// 3. Web 端没有 File 文件系统,统一返回 bytes(Uint8List),
|
||||
/// 上传时对 Web 用 MultipartFile.fromBytes,原生用 fromFile。
|
||||
/// 4. Web 端不要给 image_picker 传 imageQuality/maxWidth/maxHeight,
|
||||
/// 否则浏览器可能在"选图阶段"就先重编码,PNG 会被提前转成 JPG,
|
||||
/// 后续业务层再也拿不到真正的原始字节,压缩策略就会失真。
|
||||
/// ============================================================
|
||||
|
||||
/// 选择来源
|
||||
enum PickSource { camera, gallery }
|
||||
|
||||
/// 选中图片的统一数据模型(屏蔽两套 picker 的差异)
|
||||
class PickedImage {
|
||||
const PickedImage({this.path, this.bytes, this.name, this.entity});
|
||||
|
||||
/// 本地文件路径(原生有值,Web 为 null;上传用)
|
||||
final String? path;
|
||||
|
||||
/// 图片字节(Web 有值,原生为 null;Web 预览/上传用)
|
||||
final Uint8List? bytes;
|
||||
|
||||
/// 文件名(上传时用,含扩展名)
|
||||
final String? name;
|
||||
|
||||
/// 原生相册实体(wechat_assets_picker 返回,原生端缩略图预览用)
|
||||
final AssetEntity? entity;
|
||||
}
|
||||
|
||||
class PickerService {
|
||||
PickerService._();
|
||||
|
||||
static final PickerService instance = PickerService._();
|
||||
|
||||
/// 底部弹层选择:拍照 / 从相册
|
||||
/// 返回 null = 用户取消;返回空数组 = 用户确定但没选任何图
|
||||
Future<List<PickedImage>?> showPickSheet(
|
||||
BuildContext context, {
|
||||
int maxCount = 9,
|
||||
}) async {
|
||||
final source = await showModalBottomSheet<PickSource>(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(height: 8),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined,
|
||||
color: Color(0xFF3B82F6)),
|
||||
title: const Text('拍照'),
|
||||
onTap: () => Navigator.pop(ctx, PickSource.camera),
|
||||
),
|
||||
const Divider(height: 1, indent: 56),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined,
|
||||
color: Color(0xFF3B82F6)),
|
||||
title: const Text('从相册选择'),
|
||||
onTap: () => Navigator.pop(ctx, PickSource.gallery),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (source == null) return null;
|
||||
if (!context.mounted) return null;
|
||||
if (source == PickSource.camera) {
|
||||
final ok = await _ensurePermission(
|
||||
context: context,
|
||||
permission: Permission.camera,
|
||||
title: '需要相机权限',
|
||||
purpose: '拍摄图片用于上传错题、作业图片及更换头像',
|
||||
);
|
||||
if (!ok) return null;
|
||||
if (!context.mounted) return null;
|
||||
final image = await pickFromCamera(context);
|
||||
return image == null ? null : [image];
|
||||
}
|
||||
// 相册权限:Android 13+ 用 photos (READ_MEDIA_IMAGES),旧版用 storage
|
||||
final ok = await _ensurePermission(
|
||||
permission: await _resolveGalleryPermission(),
|
||||
title: '需要相册权限',
|
||||
purpose: '从相册选取图片用于上传错题、作业图片及更换头像',
|
||||
context: context,
|
||||
);
|
||||
if (!ok) return null;
|
||||
if (!context.mounted) return null;
|
||||
return pickFromGallery(context, maxCount: maxCount);
|
||||
}
|
||||
|
||||
/// 从相册选择(最多 maxCount 张)
|
||||
Future<List<PickedImage>?> pickFromGallery(
|
||||
BuildContext context, {
|
||||
int maxCount = 9,
|
||||
}) async {
|
||||
// ---- Web:官方 image_picker 兜底 ----
|
||||
if (kIsWeb) {
|
||||
final files = await ImagePicker().pickMultiImage(
|
||||
limit: maxCount,
|
||||
);
|
||||
if (files.isEmpty) return null;
|
||||
final result = <PickedImage>[];
|
||||
for (final f in files) {
|
||||
result.add(
|
||||
PickedImage(
|
||||
path: f.path,
|
||||
bytes: await f.readAsBytes(),
|
||||
name: f.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- 原生:微信风格相册(中文) ----
|
||||
final assets = await AssetPicker.pickAssets(
|
||||
context,
|
||||
pickerConfig: AssetPickerConfig(
|
||||
maxAssets: maxCount,
|
||||
requestType: RequestType.image,
|
||||
textDelegate: const AssetPickerTextDelegate(),
|
||||
),
|
||||
);
|
||||
if (assets == null || assets.isEmpty) return null;
|
||||
final result = <PickedImage>[];
|
||||
for (final asset in assets) {
|
||||
final file = await asset.file;
|
||||
result.add(
|
||||
PickedImage(
|
||||
path: file?.path,
|
||||
name: file?.path.split('/').last,
|
||||
entity: asset,
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 拍照(单张)
|
||||
Future<PickedImage?> pickFromCamera(BuildContext context) async {
|
||||
// ---- Web:image_picker 拍照 ----
|
||||
if (kIsWeb) {
|
||||
final file = await ImagePicker().pickImage(
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (file == null) return null;
|
||||
return PickedImage(
|
||||
path: file.path,
|
||||
bytes: await file.readAsBytes(),
|
||||
name: file.name,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 原生:微信风格相机(中文,只拍照,不录视频) ----
|
||||
PickedImage? picked;
|
||||
await CameraPicker.pickFromCamera(
|
||||
context,
|
||||
pickerConfig: CameraPickerConfig(
|
||||
enableRecording: false,
|
||||
textDelegate: const CameraPickerTextDelegate(),
|
||||
// 不再强制锁拍照方向。题目/解答/讲义拍摄经常需要横拍,
|
||||
// 后续交给 ImageProcessor 的原生压缩链做 autoCorrectionAngle。
|
||||
onEntitySaving: (dialogContext, viewType, file) async {
|
||||
final xFile = XFile(file.path);
|
||||
picked = PickedImage(
|
||||
path: file.path,
|
||||
bytes: await xFile.readAsBytes(),
|
||||
name: file.path.split('/').last,
|
||||
);
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.of(dialogContext)
|
||||
..pop(null)
|
||||
..pop(null);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
return picked;
|
||||
}
|
||||
|
||||
/// 渲染选中图片的缩略图(双端统一,业务页直接用它当 Image 用)
|
||||
///
|
||||
/// 优先原生的 wechat 实体缩略图(秒开不卡顿),
|
||||
/// Web 兜底用字节渲染(Image.memory),
|
||||
/// 两者都没有时给灰色占位。
|
||||
static Widget buildThumb(
|
||||
PickedImage image, {
|
||||
BoxFit fit = BoxFit.cover,
|
||||
double? width,
|
||||
double? height,
|
||||
}) {
|
||||
if (image.entity != null) {
|
||||
// 原生:wechat 实体缩略图(快)
|
||||
return AssetEntityImage(
|
||||
image.entity!,
|
||||
isOriginal: false,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
if (image.bytes != null) {
|
||||
// Web:字节渲染
|
||||
return Image.memory(
|
||||
image.bytes!,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
);
|
||||
}
|
||||
// 兜底:都不存在时灰色占位
|
||||
return ColoredBox(color: const Color(0xFFEEF1F5));
|
||||
}
|
||||
|
||||
// ==================== 权限守卫(Android 合规必做) ====================
|
||||
|
||||
/// 根据 Android 版本返回相册对应的 Permission:
|
||||
/// Android 13+ (SDK 33) → Permission.photos (READ_MEDIA_IMAGES)
|
||||
/// Android 12 以下 → Permission.storage (READ_EXTERNAL_STORAGE)
|
||||
/// iOS → Permission.photos
|
||||
///
|
||||
/// 判断策略:先尝试 Permission.photos.status,
|
||||
/// 如果抛异常说明系统不认识这个权限(Android < 13),回退 storage。
|
||||
static Future<Permission> _resolveGalleryPermission() async {
|
||||
try {
|
||||
// 能正常查询 → Android 13+ 或 iOS
|
||||
await Permission.photos.status;
|
||||
return Permission.photos;
|
||||
} catch (_) {
|
||||
// 查询抛异常 → Android < 13,用 storage
|
||||
return Permission.storage;
|
||||
}
|
||||
}
|
||||
|
||||
/// 权限守卫:前置弹窗说明用途 → 请求系统权限 → 永久拒绝时引导去设置
|
||||
///
|
||||
/// 为什么需要这一层:
|
||||
/// Android 上架审核(应用宝 / 华为 / 小米)要求:
|
||||
/// 调用系统权限弹窗之前,必须先弹自定义弹窗告知用户权限用途。
|
||||
/// 直接弹系统权限框会被审核打回。
|
||||
/// iOS 的 Info.plist 描述字符串已经能说明用途,不需要这层。
|
||||
///
|
||||
/// 返回 true = 已授权,可以继续操作;false = 用户拒绝或取消
|
||||
Future<bool> _ensurePermission({
|
||||
required BuildContext context,
|
||||
required Permission permission,
|
||||
required String title,
|
||||
required String purpose,
|
||||
}) async {
|
||||
// Web 不需要系统权限(浏览器自己处理)
|
||||
if (kIsWeb) return true;
|
||||
|
||||
// ① 已授权 → 直接放行
|
||||
final status = await permission.status;
|
||||
if (status.isGranted) return true;
|
||||
|
||||
// ② 永久拒绝 → 引导去系统设置
|
||||
if (status.isPermanentlyDenied) {
|
||||
if (!context.mounted) return false;
|
||||
final goSetting = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('权限已被拒绝'),
|
||||
content: Text(
|
||||
'您已关闭相关权限且选择了不再询问,'
|
||||
'请前往系统设置手动开启权限后重试。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('去设置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (goSetting == true) {
|
||||
await openAppSettings();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ③ 首次请求 / 被拒绝过但还能再请求 → 先弹前置说明弹窗
|
||||
if (!context.mounted) return false;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(title),
|
||||
content: Text(
|
||||
'$purpose。权限仅用于上述功能,不会读取无关内容。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('知晓并继续'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return false;
|
||||
|
||||
// ④ 用户确认 → 触发系统权限弹窗
|
||||
final result = await permission.request();
|
||||
if (result.isGranted) return true;
|
||||
|
||||
// ⑤ 刚请求就被拒绝 → 检查是否永久拒绝,如果是引导去设置
|
||||
if (result.isPermanentlyDenied) {
|
||||
if (!context.mounted) return false;
|
||||
final goSetting = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('权限申请失败'),
|
||||
content: const Text(
|
||||
'权限被拒绝,如需继续使用此功能,请前往系统设置手动开启。',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('稍后再说'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('去设置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (goSetting == true) {
|
||||
await openAppSettings();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' show ImageByteFormat;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:gal/gal.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ShareService】分享服务(系统分享面板统一封装)
|
||||
///
|
||||
/// 一、为什么包一层:
|
||||
/// 业务页不用关心 share_plus 的 API 细节,统一调 shareText /
|
||||
/// shareImage / shareFromWidget,返回统一的 ShareResult。
|
||||
///
|
||||
/// 二、能力:
|
||||
/// 1. shareText → 分享纯文本 / 链接
|
||||
/// 2. shareImage → 分享单张图片(本地文件)
|
||||
/// 3. shareImages → 分享多张图片
|
||||
/// 4. shareFromWidget → 把任意 Widget 截图成图片 → 分享(海报核心能力)
|
||||
/// 5. saveToGallery → 保存图片到系统相册(Android/iOS)
|
||||
///
|
||||
/// 三、平台策略:
|
||||
/// Android → ACTION_SEND Intent(系统分享面板,用户自选微信/QQ/微博等)
|
||||
/// iOS → UIActivityViewController(系统分享面板)
|
||||
/// Web → Web Share API(浏览器原生,不支持则降级下载)
|
||||
///
|
||||
/// 四、后续扩展:
|
||||
/// 如需精确分享到微信好友/朋友圈,加 fluwx(微信 OpenSDK),
|
||||
/// 在 ShareService 内部加分支判断,业务页不用改。
|
||||
///
|
||||
/// 五、高频坑:
|
||||
/// 1. Android 分享图片必须用 FileProvider,share_plus 已内置处理,
|
||||
/// 但传入的必须是真实文件路径(XFile),不能是 Uint8List 直接传;
|
||||
/// 2. shareFromWidget 的截图依赖 RepaintBoundary,
|
||||
/// Widget 必须在渲染树中(不能是离屏未 build 的);
|
||||
/// 3. Web 端 shareXFiles 不支持 Linux,Windows 走 mailto。
|
||||
/// ============================================================
|
||||
class ShareService {
|
||||
ShareService._();
|
||||
|
||||
static final ShareService instance = ShareService._();
|
||||
|
||||
// ==================== 文本分享 ====================
|
||||
|
||||
/// 分享文本 / 链接
|
||||
///
|
||||
/// [text] 要分享的内容(纯文本或 URL)
|
||||
/// [subject] 邮件分享时的主题(可选)
|
||||
Future<ShareResult> shareText({
|
||||
required String text,
|
||||
String? subject,
|
||||
}) async {
|
||||
return SharePlus.instance.share(
|
||||
ShareParams(
|
||||
text: text,
|
||||
subject: subject,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 图片分享 ====================
|
||||
|
||||
/// 分享单张图片(本地文件路径)
|
||||
///
|
||||
/// [filePath] 本地图片文件路径
|
||||
/// [text] 附带的文字说明(可选,部分 App 会显示)
|
||||
Future<ShareResult> shareImage({
|
||||
required String filePath,
|
||||
String? text,
|
||||
}) async {
|
||||
return shareImages(filePaths: [filePath], text: text);
|
||||
}
|
||||
|
||||
/// 分享多张图片
|
||||
///
|
||||
/// [filePaths] 本地图片文件路径列表
|
||||
/// [text] 附带的文字说明(可选)
|
||||
Future<ShareResult> shareImages({
|
||||
required List<String> filePaths,
|
||||
String? text,
|
||||
}) async {
|
||||
final files = filePaths.map((p) => XFile(p)).toList();
|
||||
return SharePlus.instance.share(
|
||||
ShareParams(
|
||||
files: files,
|
||||
text: text,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 分享内存字节(Uint8List)为图片
|
||||
///
|
||||
/// 适用场景:网络图片下载后直接分享,不先写磁盘。
|
||||
/// 内部自动写入临时目录再分享。
|
||||
Future<ShareResult> shareImageBytes({
|
||||
required Uint8List bytes,
|
||||
String fileName = 'share_image.jpg',
|
||||
String? text,
|
||||
}) async {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File('${dir.path}/$fileName');
|
||||
await file.writeAsBytes(bytes);
|
||||
return shareImage(filePath: file.path, text: text);
|
||||
}
|
||||
|
||||
// ==================== Widget 截图分享(海报核心) ====================
|
||||
|
||||
/// 把任意 Widget 截图成图片 → 分享
|
||||
///
|
||||
/// 这是"攻克海报分享"的核心能力:
|
||||
/// 1. 用 RepaintBoundary 包裹目标 Widget;
|
||||
/// 2. 截图转 Uint8List;
|
||||
/// 3. 写入临时文件 → 调系统分享。
|
||||
///
|
||||
/// [builder] 构建要截图的 Widget(通常是海报布局)
|
||||
/// [text] 附带的文字说明
|
||||
/// [pixelRatio] 截图清晰度(默认 3.0,越高越清晰但越慢)
|
||||
Future<ShareResult> shareFromWidget({
|
||||
required WidgetBuilder builder,
|
||||
required BuildContext context,
|
||||
String? text,
|
||||
double pixelRatio = 3.0,
|
||||
}) async {
|
||||
final bytes = await captureWidget(
|
||||
builder: builder,
|
||||
context: context,
|
||||
pixelRatio: pixelRatio,
|
||||
);
|
||||
if (bytes == null) {
|
||||
return ShareResult.unavailable;
|
||||
}
|
||||
return shareImageBytes(bytes: bytes, text: text);
|
||||
}
|
||||
|
||||
/// 把任意 Widget 截图为 Uint8List(PNG)
|
||||
///
|
||||
/// 独立方法:截图后可以做任何事(保存、上传、分享),
|
||||
/// 不局限于分享场景。
|
||||
static Future<Uint8List?> captureWidget({
|
||||
required WidgetBuilder builder,
|
||||
required BuildContext context,
|
||||
double pixelRatio = 3.0,
|
||||
}) async {
|
||||
// 用 OverlayEntry 把 Widget 临时放到渲染树中
|
||||
late OverlayEntry entry;
|
||||
final boundaryKey = GlobalKey();
|
||||
|
||||
entry = OverlayEntry(
|
||||
builder: (_) => RepaintBoundary(
|
||||
key: boundaryKey,
|
||||
child: builder(context),
|
||||
),
|
||||
);
|
||||
|
||||
// 插入 Overlay(Widget 开始 build)
|
||||
Overlay.of(context).insert(entry);
|
||||
|
||||
// 等一帧让 Widget 完成渲染
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
|
||||
try {
|
||||
final boundary = boundaryKey.currentContext?.findRenderObject()
|
||||
as RenderRepaintBoundary?;
|
||||
if (boundary == null) return null;
|
||||
|
||||
final image = await boundary.toImage(pixelRatio: pixelRatio);
|
||||
final bytes = await image.toByteData(format: ImageByteFormat.png);
|
||||
return bytes?.buffer.asUint8List();
|
||||
} finally {
|
||||
// 截图完成,移除临时 Overlay
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 保存到相册 ====================
|
||||
|
||||
/// 保存图片到系统相册(Android/iOS/macOS/Windows)
|
||||
///
|
||||
/// 底层用 gal 插件:
|
||||
/// Android → MediaStore(API 30+)或 WRITE_EXTERNAL_STORAGE(API ≤ 29)
|
||||
/// iOS → PHPhotoLibrary
|
||||
///
|
||||
/// [filePath] 本地图片文件路径
|
||||
/// [album] 保存到指定相册名(可选,Android 上不一定生效,取决于系统实现)
|
||||
///
|
||||
/// 返回 true 表示保存成功,false 表示失败。
|
||||
/// 权限不足时会抛 GalException,调用方可 catch 处理。
|
||||
static Future<bool> saveToGallery({
|
||||
required String filePath,
|
||||
String? album,
|
||||
}) async {
|
||||
if (kIsWeb) {
|
||||
debugPrint('⚠ Web 端不支持保存到相册');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await Gal.putImage(filePath, album: album);
|
||||
return true;
|
||||
} on GalException catch (e) {
|
||||
debugPrint('⚠ 保存到相册失败:${e.type.message}');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('⚠ 保存到相册失败:$e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存内存字节到系统相册
|
||||
///
|
||||
/// 适用场景:Widget 截图后直接保存,不先写磁盘。
|
||||
/// 内部自动写入临时文件再调 [saveToGallery]。
|
||||
static Future<bool> saveToGalleryFromBytes({
|
||||
required Uint8List bytes,
|
||||
String fileName = 'save_image.jpg',
|
||||
String? album,
|
||||
}) async {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File('${dir.path}/$fileName');
|
||||
await file.writeAsBytes(bytes);
|
||||
return saveToGallery(filePath: file.path, album: album);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/// ============================================================
|
||||
/// 【AppDatabase】本地业务数据库(存储分层第三层)—— 平台分发入口
|
||||
///
|
||||
/// 用法不变:main.dart 里 `await AppDatabase.instance.init();`
|
||||
///
|
||||
/// 一、为什么拆成条件导出(三个文件):
|
||||
/// isar 3.x 生成的 Schema 代码(*.g.dart)里含 64 位哈希常量
|
||||
/// (CollectionSchema.id,如 -6819722535046815095),远超 JS 可精确
|
||||
/// 表示的最大整数 2^53,dart2js 编译 Web 端时会直接报
|
||||
/// "The integer literal ... can't be represented exactly in JavaScript"。
|
||||
/// 即使 kIsWeb 在【运行时】跳过数据库,【编译期】依然失败——
|
||||
/// 因为 app_database.dart 静态 import 了模型 → 静态 import 了 .g.dart。
|
||||
///
|
||||
/// 二、条件导出的分发规则(编译期决定,运行期零开销):
|
||||
/// - native(Android/iOS/桌面,dart.library.io 为真):
|
||||
/// → app_database_io.dart,真 Isar 实现
|
||||
/// - web(dart2js / dart2wasm,dart.library.io 为假):
|
||||
/// → app_database_web.dart,空实现(web 仅调试 UI/网络)
|
||||
///
|
||||
/// 三、高频坑:
|
||||
/// 1. 三件套必须都定义 `class AppDatabase`,且成员签名保持一致,
|
||||
/// 否则共享代码(如 main.dart)在某个平台会编译不过;
|
||||
/// 2. web 端别用 Isar 集合类型(`IsarCollection<T>`),
|
||||
/// 那只存在于 io 实现里——web 端没有这些成员;
|
||||
/// 3. 改模型字段后仍要重跑:
|
||||
/// dart run build_runner build --delete-conflicting-outputs
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
export 'app_database_web.dart'
|
||||
if (dart.library.io) 'app_database_io.dart';
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'package:isar_community/isar.dart';
|
||||
import '../models/play_record.dart';
|
||||
import '../models/question.dart';
|
||||
import '../models/wrong_question.dart';
|
||||
import '../models/answer_record.dart';
|
||||
import 'package:new_learn/core/version/app_version_entity.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AppDatabase】Isar 本地业务数据库 —— native(Android/iOS/桌面)真实现
|
||||
///
|
||||
/// 仅当 dart.library.io 为真时编译(见 app_database.dart 条件导出)。
|
||||
/// web 端不会编译本文件,因此可以放心 import isar 与 *.g.dart。
|
||||
///
|
||||
/// 一、为什么需要第三层(对照前两层):
|
||||
/// 1. flutter_secure_storage → Token 等敏感凭据(最高安全)
|
||||
/// 2. shared_preferences → 用户信息/开关等小配置(明文快读)
|
||||
/// 3. AppDatabase(Isar) → 题库/错题/播放记录等大批量结构化数据
|
||||
/// 几十上百条 JSON 塞进 shared_preferences 会越写越卡,
|
||||
/// Isar 是本地 NoSQL 数据库:读写毫秒级、强类型、ACID 事务、支持索引。
|
||||
///
|
||||
/// 二、为什么用 isar_community 而不是 hive / isar:
|
||||
/// - hive:存 Map 无类型保障,官方已停更;
|
||||
/// - isar 3.1.8:只发在已宕机的 pub.isar-community.dev,装不上;
|
||||
/// - isar_community:社区在 pub.dev 的维护版,API 与 isar 完全一致。
|
||||
///
|
||||
/// 三、高频坑(Isar 专属):
|
||||
/// 1. init() 必须在 runApp 前 await(见 main.dart),
|
||||
/// 否则页面访问集合会抛 "Missing XxxSchema in Isar.open";
|
||||
/// 2. 模型改字段后必须重跑代码生成:
|
||||
/// dart run build_runner build --delete-conflicting-outputs
|
||||
/// (生成 *.g.dart,XxxSchema 就在里面);
|
||||
/// 3. 所有写操作包在 isar.writeTxn() 里;查询用 where()/filter();
|
||||
/// 4. 集合访问用 `isar.collection<T>()`(与生成的 isar.xxx 等价);
|
||||
/// 5. Web 端调试用空实现(app_database_web.dart),不要在这加 kIsWeb:
|
||||
/// 本文件只会在 native 编译,kIsWeb 恒为 false。
|
||||
/// ============================================================
|
||||
class AppDatabase {
|
||||
AppDatabase._();
|
||||
|
||||
static final AppDatabase instance = AppDatabase._();
|
||||
|
||||
late Isar isar;
|
||||
|
||||
/// 初始化:打开 Isar 实例并注册全部集合 Schema(main() 里 await)
|
||||
Future<void> init() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
isar = await Isar.open(
|
||||
[
|
||||
QuestionSchema,
|
||||
WrongQuestionSchema,
|
||||
PlayRecordSchema,
|
||||
RemoteVersionEntitySchema,
|
||||
AnswerRecordSchema,
|
||||
],
|
||||
directory: dir.path,
|
||||
name: 'app_db',
|
||||
inspector: true, // ✅加上这一行,debug模式启用Isar Inspector调试页面
|
||||
);
|
||||
debugPrintIsarInit();
|
||||
}
|
||||
|
||||
/// 集合访问守卫:未初始化时给出明确报错(而不是空指针)
|
||||
void _ensureReady() {
|
||||
if (!isar.isOpen) {
|
||||
throw StateError('本地数据库(Isar)未初始化:请在 main() 里 await init()');
|
||||
}
|
||||
}
|
||||
|
||||
/// 题库集合:新增/查询/删除题目都走它
|
||||
IsarCollection<Question> get questions {
|
||||
_ensureReady();
|
||||
return isar.collection<Question>();
|
||||
}
|
||||
|
||||
/// 错题本集合
|
||||
IsarCollection<WrongQuestion> get wrongQuestions {
|
||||
_ensureReady();
|
||||
return isar.collection<WrongQuestion>();
|
||||
}
|
||||
|
||||
/// 答题进度集合(续答功能:进卷读回 / 作答写入 / 交卷清理)
|
||||
IsarCollection<AnswerRecord> get answerRecords {
|
||||
_ensureReady();
|
||||
return isar.collection<AnswerRecord>();
|
||||
}
|
||||
|
||||
/// 播放记录集合
|
||||
IsarCollection<PlayRecord> get playRecords {
|
||||
_ensureReady();
|
||||
return isar.collection<PlayRecord>();
|
||||
}
|
||||
|
||||
/// 远端版本策略缓存集合(版本更新机制用)
|
||||
IsarCollection<RemoteVersionEntity> get remoteVersions {
|
||||
_ensureReady();
|
||||
return isar.collection<RemoteVersionEntity>();
|
||||
}
|
||||
|
||||
/// 清空全部业务数据(换账号/恢复出厂设置时调用)
|
||||
Future<void> clearAll() async {
|
||||
_ensureReady();
|
||||
await isar.writeTxn(() async {
|
||||
await questions.clear();
|
||||
await wrongQuestions.clear();
|
||||
await playRecords.clear();
|
||||
await remoteVersions.clear();
|
||||
await answerRecords.clear();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 打印初始化完成日志(独立函数便于 web 空实现复用相同日志风格)
|
||||
void debugPrintIsarInit() {
|
||||
// ignore: avoid_print
|
||||
print('▶ 【Isar】业务数据库初始化完成(题库/错题/播放记录)');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AppDatabase】本地业务数据库 —— web 空实现(Chrome/Edge 调试用)
|
||||
///
|
||||
/// 仅当 dart.library.io 为假时编译(见 app_database.dart 条件导出)。
|
||||
/// isar 3.x 全系不支持 web(官方版解析不了 Dart 3;社区版 web 实现是
|
||||
/// 占位 stub,调用即抛 "Please use Isar 2.5.0 if you need web support"),
|
||||
/// 且生成代码里的 64 位哈希常量 dart2js 编译不过。
|
||||
/// 所以 web 端用空实现:init() 直接跳过,UI/登录/网络调试完全不受影响。
|
||||
/// 生产走 Android/iOS,app_database_io.dart 真 Isar 照常。
|
||||
///
|
||||
/// ⚠ 注意:本文件不要 import isar 或任何 *.g.dart ——
|
||||
/// web 端没有 IsarCollection 等类型,集合读写成员也不存在(编译报错)。
|
||||
/// 这正是"web 只调试、不上数据库"的强制约束。
|
||||
/// ============================================================
|
||||
class AppDatabase {
|
||||
AppDatabase._();
|
||||
|
||||
static final AppDatabase instance = AppDatabase._();
|
||||
|
||||
/// web 调试模式:跳过本地数据库(UI/网络正常调试)
|
||||
Future<void> init() async {
|
||||
debugPrint('▶ 【Isar】Web 调试模式:跳过本地数据库(UI/网络正常调试)');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:isar_community/isar.dart';
|
||||
|
||||
part 'answer_record.g.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AnswerRecord】答题进度持久化模型(续答功能)
|
||||
///
|
||||
/// 一、解决什么问题:
|
||||
/// 答题中途退出 → 答案只存在 Riverpod 内存里,进程一杀就丢。
|
||||
/// 本模型按"试卷"维度把每题答案落 Isar,下次进卷按 paperId
|
||||
/// 读回注入 answerProvider,实现续答。
|
||||
///
|
||||
/// 二、设计要点:
|
||||
/// 1. paperId 加 @Index() 索引:进卷恢复、交卷清理都按它查
|
||||
/// (where(paperId).equalTo() 走索引,100 题毫秒级);
|
||||
/// 2. answer 统一 String:选择 "A,B" / 判断 "对" / 解答文本,
|
||||
/// 与 Question 模型的存储约定一致,交卷序列化也简单;
|
||||
/// 3. 交卷成功后按 paperId 批量删除,磁盘上永远只留
|
||||
/// "进行中"的试卷答案,不会膨胀。
|
||||
///
|
||||
/// 三、高频坑:
|
||||
/// 1. 和 Question 一样必须有 Id 主键 + 重跑 build_runner;
|
||||
/// 2. 模型 import 了 isar,web 端不能 import 本文件——
|
||||
/// store 层用条件导出(见 answer_record_store.dart)。
|
||||
/// ============================================================
|
||||
@collection
|
||||
class AnswerRecord {
|
||||
Id id = Isar.autoIncrement; // Isar 内部主键(自动递增)
|
||||
|
||||
@Index()
|
||||
int? paperId; // 试卷 id(续答恢复/交卷清理的维度)
|
||||
int? questionId; // 题目 id(对应 Question.questionId)
|
||||
String? answer; // 用户答案(多选 "A,B" 逗号拼接)
|
||||
DateTime? updatedAt; // 作答时间
|
||||
}
|
||||
@@ -0,0 +1,981 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'answer_record.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// IsarCollectionGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
|
||||
|
||||
extension GetAnswerRecordCollection on Isar {
|
||||
IsarCollection<AnswerRecord> get answerRecords => this.collection();
|
||||
}
|
||||
|
||||
const AnswerRecordSchema = CollectionSchema(
|
||||
name: r'AnswerRecord',
|
||||
id: -783120389370632988,
|
||||
properties: {
|
||||
r'answer': PropertySchema(id: 0, name: r'answer', type: IsarType.string),
|
||||
r'paperId': PropertySchema(id: 1, name: r'paperId', type: IsarType.long),
|
||||
r'questionId': PropertySchema(
|
||||
id: 2,
|
||||
name: r'questionId',
|
||||
type: IsarType.long,
|
||||
),
|
||||
r'updatedAt': PropertySchema(
|
||||
id: 3,
|
||||
name: r'updatedAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
},
|
||||
|
||||
estimateSize: _answerRecordEstimateSize,
|
||||
serialize: _answerRecordSerialize,
|
||||
deserialize: _answerRecordDeserialize,
|
||||
deserializeProp: _answerRecordDeserializeProp,
|
||||
idName: r'id',
|
||||
indexes: {
|
||||
r'paperId': IndexSchema(
|
||||
id: -7152058561277781812,
|
||||
name: r'paperId',
|
||||
unique: false,
|
||||
replace: false,
|
||||
properties: [
|
||||
IndexPropertySchema(
|
||||
name: r'paperId',
|
||||
type: IndexType.value,
|
||||
caseSensitive: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
links: {},
|
||||
embeddedSchemas: {},
|
||||
|
||||
getId: _answerRecordGetId,
|
||||
getLinks: _answerRecordGetLinks,
|
||||
attach: _answerRecordAttach,
|
||||
version: '3.3.2',
|
||||
);
|
||||
|
||||
int _answerRecordEstimateSize(
|
||||
AnswerRecord object,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
var bytesCount = offsets.last;
|
||||
{
|
||||
final value = object.answer;
|
||||
if (value != null) {
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
return bytesCount;
|
||||
}
|
||||
|
||||
void _answerRecordSerialize(
|
||||
AnswerRecord object,
|
||||
IsarWriter writer,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeString(offsets[0], object.answer);
|
||||
writer.writeLong(offsets[1], object.paperId);
|
||||
writer.writeLong(offsets[2], object.questionId);
|
||||
writer.writeDateTime(offsets[3], object.updatedAt);
|
||||
}
|
||||
|
||||
AnswerRecord _answerRecordDeserialize(
|
||||
Id id,
|
||||
IsarReader reader,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
final object = AnswerRecord();
|
||||
object.answer = reader.readStringOrNull(offsets[0]);
|
||||
object.id = id;
|
||||
object.paperId = reader.readLongOrNull(offsets[1]);
|
||||
object.questionId = reader.readLongOrNull(offsets[2]);
|
||||
object.updatedAt = reader.readDateTimeOrNull(offsets[3]);
|
||||
return object;
|
||||
}
|
||||
|
||||
P _answerRecordDeserializeProp<P>(
|
||||
IsarReader reader,
|
||||
int propertyId,
|
||||
int offset,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
switch (propertyId) {
|
||||
case 0:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 1:
|
||||
return (reader.readLongOrNull(offset)) as P;
|
||||
case 2:
|
||||
return (reader.readLongOrNull(offset)) as P;
|
||||
case 3:
|
||||
return (reader.readDateTimeOrNull(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
}
|
||||
}
|
||||
|
||||
Id _answerRecordGetId(AnswerRecord object) {
|
||||
return object.id;
|
||||
}
|
||||
|
||||
List<IsarLinkBase<dynamic>> _answerRecordGetLinks(AnswerRecord object) {
|
||||
return [];
|
||||
}
|
||||
|
||||
void _answerRecordAttach(
|
||||
IsarCollection<dynamic> col,
|
||||
Id id,
|
||||
AnswerRecord object,
|
||||
) {
|
||||
object.id = id;
|
||||
}
|
||||
|
||||
extension AnswerRecordQueryWhereSort
|
||||
on QueryBuilder<AnswerRecord, AnswerRecord, QWhere> {
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhere> anyId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(const IdWhereClause.any());
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhere> anyPaperId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
const IndexWhereClause.any(indexName: r'paperId'),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension AnswerRecordQueryWhere
|
||||
on QueryBuilder<AnswerRecord, AnswerRecord, QWhereClause> {
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause> idEqualTo(Id id) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IdWhereClause.between(lower: id, upper: id));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause> idNotEqualTo(
|
||||
Id id,
|
||||
) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: id, includeUpper: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: id, includeLower: false),
|
||||
);
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: id, includeLower: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: id, includeUpper: false),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause> idGreaterThan(
|
||||
Id id, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: id, includeLower: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause> idLessThan(
|
||||
Id id, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: id, includeUpper: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause> idBetween(
|
||||
Id lowerId,
|
||||
Id upperId, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.between(
|
||||
lower: lowerId,
|
||||
includeLower: includeLower,
|
||||
upper: upperId,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause> paperIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IndexWhereClause.equalTo(indexName: r'paperId', value: [null]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause>
|
||||
paperIdIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IndexWhereClause.between(
|
||||
indexName: r'paperId',
|
||||
lower: [null],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause> paperIdEqualTo(
|
||||
int? paperId,
|
||||
) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IndexWhereClause.equalTo(indexName: r'paperId', value: [paperId]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause> paperIdNotEqualTo(
|
||||
int? paperId,
|
||||
) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IndexWhereClause.between(
|
||||
indexName: r'paperId',
|
||||
lower: [],
|
||||
upper: [paperId],
|
||||
includeUpper: false,
|
||||
),
|
||||
)
|
||||
.addWhereClause(
|
||||
IndexWhereClause.between(
|
||||
indexName: r'paperId',
|
||||
lower: [paperId],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IndexWhereClause.between(
|
||||
indexName: r'paperId',
|
||||
lower: [paperId],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
),
|
||||
)
|
||||
.addWhereClause(
|
||||
IndexWhereClause.between(
|
||||
indexName: r'paperId',
|
||||
lower: [],
|
||||
upper: [paperId],
|
||||
includeUpper: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause>
|
||||
paperIdGreaterThan(int? paperId, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IndexWhereClause.between(
|
||||
indexName: r'paperId',
|
||||
lower: [paperId],
|
||||
includeLower: include,
|
||||
upper: [],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause> paperIdLessThan(
|
||||
int? paperId, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IndexWhereClause.between(
|
||||
indexName: r'paperId',
|
||||
lower: [],
|
||||
upper: [paperId],
|
||||
includeUpper: include,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterWhereClause> paperIdBetween(
|
||||
int? lowerPaperId,
|
||||
int? upperPaperId, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IndexWhereClause.between(
|
||||
indexName: r'paperId',
|
||||
lower: [lowerPaperId],
|
||||
includeLower: includeLower,
|
||||
upper: [upperPaperId],
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension AnswerRecordQueryFilter
|
||||
on QueryBuilder<AnswerRecord, AnswerRecord, QFilterCondition> {
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
answerIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'answer'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
answerIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'answer'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition> answerEqualTo(
|
||||
String? value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(
|
||||
property: r'answer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
answerGreaterThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'answer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
answerLessThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'answer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition> answerBetween(
|
||||
String? lower,
|
||||
String? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'answer',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
answerStartsWith(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.startsWith(
|
||||
property: r'answer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
answerEndsWith(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.endsWith(
|
||||
property: r'answer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
answerContains(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.contains(
|
||||
property: r'answer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition> answerMatches(
|
||||
String pattern, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.matches(
|
||||
property: r'answer',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
answerIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'answer', value: ''),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
answerIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(property: r'answer', value: ''),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition> idEqualTo(
|
||||
Id value,
|
||||
) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'id', value: value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition> idGreaterThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition> idLessThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition> idBetween(
|
||||
Id lower,
|
||||
Id upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'id',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
paperIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'paperId'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
paperIdIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'paperId'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
paperIdEqualTo(int? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'paperId', value: value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
paperIdGreaterThan(int? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'paperId',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
paperIdLessThan(int? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'paperId',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
paperIdBetween(
|
||||
int? lower,
|
||||
int? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'paperId',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
questionIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'questionId'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
questionIdIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'questionId'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
questionIdEqualTo(int? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'questionId', value: value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
questionIdGreaterThan(int? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'questionId',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
questionIdLessThan(int? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'questionId',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
questionIdBetween(
|
||||
int? lower,
|
||||
int? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'questionId',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
updatedAtIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'updatedAt'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
updatedAtIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'updatedAt'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
updatedAtEqualTo(DateTime? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'updatedAt', value: value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
updatedAtGreaterThan(DateTime? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'updatedAt',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
updatedAtLessThan(DateTime? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'updatedAt',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterFilterCondition>
|
||||
updatedAtBetween(
|
||||
DateTime? lower,
|
||||
DateTime? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'updatedAt',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension AnswerRecordQueryObject
|
||||
on QueryBuilder<AnswerRecord, AnswerRecord, QFilterCondition> {}
|
||||
|
||||
extension AnswerRecordQueryLinks
|
||||
on QueryBuilder<AnswerRecord, AnswerRecord, QFilterCondition> {}
|
||||
|
||||
extension AnswerRecordQuerySortBy
|
||||
on QueryBuilder<AnswerRecord, AnswerRecord, QSortBy> {
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> sortByAnswer() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'answer', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> sortByAnswerDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'answer', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> sortByPaperId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'paperId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> sortByPaperIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'paperId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> sortByQuestionId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'questionId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy>
|
||||
sortByQuestionIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'questionId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> sortByUpdatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAt', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> sortByUpdatedAtDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAt', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension AnswerRecordQuerySortThenBy
|
||||
on QueryBuilder<AnswerRecord, AnswerRecord, QSortThenBy> {
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> thenByAnswer() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'answer', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> thenByAnswerDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'answer', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> thenById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> thenByIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> thenByPaperId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'paperId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> thenByPaperIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'paperId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> thenByQuestionId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'questionId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy>
|
||||
thenByQuestionIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'questionId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> thenByUpdatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAt', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QAfterSortBy> thenByUpdatedAtDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAt', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension AnswerRecordQueryWhereDistinct
|
||||
on QueryBuilder<AnswerRecord, AnswerRecord, QDistinct> {
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QDistinct> distinctByAnswer({
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'answer', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QDistinct> distinctByPaperId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'paperId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QDistinct> distinctByQuestionId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'questionId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, AnswerRecord, QDistinct> distinctByUpdatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'updatedAt');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension AnswerRecordQueryProperty
|
||||
on QueryBuilder<AnswerRecord, AnswerRecord, QQueryProperty> {
|
||||
QueryBuilder<AnswerRecord, int, QQueryOperations> idProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'id');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, String?, QQueryOperations> answerProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'answer');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, int?, QQueryOperations> paperIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'paperId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, int?, QQueryOperations> questionIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'questionId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<AnswerRecord, DateTime?, QQueryOperations> updatedAtProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'updatedAt');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:isar_community/isar.dart';
|
||||
|
||||
part 'play_record.g.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【PlayRecord】播放记录模型(对应 AppDatabase 的播放记录集合)
|
||||
///
|
||||
/// 一、字段设计:课程 id + 进度(0~1)+ 最近播放时间,
|
||||
/// 下次进课程直接按 progress 续播。
|
||||
///
|
||||
/// 二、高频坑:
|
||||
/// 1. progress 用 double 存小数进度,展示成百分比自己换算;
|
||||
/// 2. updatedAt 每次播放暂停都更新,用于"最近播放"排序;
|
||||
/// 3. 改字段后记得重跑 build_runner。
|
||||
/// ============================================================
|
||||
@collection
|
||||
class PlayRecord {
|
||||
Id id = Isar.autoIncrement; // Isar 内部主键(自动递增)
|
||||
|
||||
int? courseId; // 课程/课时 id
|
||||
String? courseName; // 课程名
|
||||
double? progress; // 播放进度 0~1
|
||||
DateTime? updatedAt; // 最近播放时间
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'play_record.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// IsarCollectionGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
|
||||
|
||||
extension GetPlayRecordCollection on Isar {
|
||||
IsarCollection<PlayRecord> get playRecords => this.collection();
|
||||
}
|
||||
|
||||
const PlayRecordSchema = CollectionSchema(
|
||||
name: r'PlayRecord',
|
||||
id: -7338646677296495696,
|
||||
properties: {
|
||||
r'courseId': PropertySchema(id: 0, name: r'courseId', type: IsarType.long),
|
||||
r'courseName': PropertySchema(
|
||||
id: 1,
|
||||
name: r'courseName',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'progress': PropertySchema(
|
||||
id: 2,
|
||||
name: r'progress',
|
||||
type: IsarType.double,
|
||||
),
|
||||
r'updatedAt': PropertySchema(
|
||||
id: 3,
|
||||
name: r'updatedAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
},
|
||||
|
||||
estimateSize: _playRecordEstimateSize,
|
||||
serialize: _playRecordSerialize,
|
||||
deserialize: _playRecordDeserialize,
|
||||
deserializeProp: _playRecordDeserializeProp,
|
||||
idName: r'id',
|
||||
indexes: {},
|
||||
links: {},
|
||||
embeddedSchemas: {},
|
||||
|
||||
getId: _playRecordGetId,
|
||||
getLinks: _playRecordGetLinks,
|
||||
attach: _playRecordAttach,
|
||||
version: '3.3.2',
|
||||
);
|
||||
|
||||
int _playRecordEstimateSize(
|
||||
PlayRecord object,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
var bytesCount = offsets.last;
|
||||
{
|
||||
final value = object.courseName;
|
||||
if (value != null) {
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
return bytesCount;
|
||||
}
|
||||
|
||||
void _playRecordSerialize(
|
||||
PlayRecord object,
|
||||
IsarWriter writer,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeLong(offsets[0], object.courseId);
|
||||
writer.writeString(offsets[1], object.courseName);
|
||||
writer.writeDouble(offsets[2], object.progress);
|
||||
writer.writeDateTime(offsets[3], object.updatedAt);
|
||||
}
|
||||
|
||||
PlayRecord _playRecordDeserialize(
|
||||
Id id,
|
||||
IsarReader reader,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
final object = PlayRecord();
|
||||
object.courseId = reader.readLongOrNull(offsets[0]);
|
||||
object.courseName = reader.readStringOrNull(offsets[1]);
|
||||
object.id = id;
|
||||
object.progress = reader.readDoubleOrNull(offsets[2]);
|
||||
object.updatedAt = reader.readDateTimeOrNull(offsets[3]);
|
||||
return object;
|
||||
}
|
||||
|
||||
P _playRecordDeserializeProp<P>(
|
||||
IsarReader reader,
|
||||
int propertyId,
|
||||
int offset,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
switch (propertyId) {
|
||||
case 0:
|
||||
return (reader.readLongOrNull(offset)) as P;
|
||||
case 1:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 2:
|
||||
return (reader.readDoubleOrNull(offset)) as P;
|
||||
case 3:
|
||||
return (reader.readDateTimeOrNull(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
}
|
||||
}
|
||||
|
||||
Id _playRecordGetId(PlayRecord object) {
|
||||
return object.id;
|
||||
}
|
||||
|
||||
List<IsarLinkBase<dynamic>> _playRecordGetLinks(PlayRecord object) {
|
||||
return [];
|
||||
}
|
||||
|
||||
void _playRecordAttach(IsarCollection<dynamic> col, Id id, PlayRecord object) {
|
||||
object.id = id;
|
||||
}
|
||||
|
||||
extension PlayRecordQueryWhereSort
|
||||
on QueryBuilder<PlayRecord, PlayRecord, QWhere> {
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterWhere> anyId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(const IdWhereClause.any());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayRecordQueryWhere
|
||||
on QueryBuilder<PlayRecord, PlayRecord, QWhereClause> {
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterWhereClause> idEqualTo(Id id) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IdWhereClause.between(lower: id, upper: id));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterWhereClause> idNotEqualTo(Id id) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: id, includeUpper: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: id, includeLower: false),
|
||||
);
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: id, includeLower: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: id, includeUpper: false),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterWhereClause> idGreaterThan(
|
||||
Id id, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: id, includeLower: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterWhereClause> idLessThan(
|
||||
Id id, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: id, includeUpper: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterWhereClause> idBetween(
|
||||
Id lowerId,
|
||||
Id upperId, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.between(
|
||||
lower: lowerId,
|
||||
includeLower: includeLower,
|
||||
upper: upperId,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayRecordQueryFilter
|
||||
on QueryBuilder<PlayRecord, PlayRecord, QFilterCondition> {
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> courseIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'courseId'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseIdIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'courseId'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> courseIdEqualTo(
|
||||
int? value,
|
||||
) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'courseId', value: value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseIdGreaterThan(int? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'courseId',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> courseIdLessThan(
|
||||
int? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'courseId',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> courseIdBetween(
|
||||
int? lower,
|
||||
int? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'courseId',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseNameIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'courseName'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseNameIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'courseName'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> courseNameEqualTo(
|
||||
String? value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(
|
||||
property: r'courseName',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseNameGreaterThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'courseName',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseNameLessThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'courseName',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> courseNameBetween(
|
||||
String? lower,
|
||||
String? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'courseName',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseNameStartsWith(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.startsWith(
|
||||
property: r'courseName',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseNameEndsWith(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.endsWith(
|
||||
property: r'courseName',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseNameContains(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.contains(
|
||||
property: r'courseName',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> courseNameMatches(
|
||||
String pattern, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.matches(
|
||||
property: r'courseName',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseNameIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'courseName', value: ''),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
courseNameIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(property: r'courseName', value: ''),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> idEqualTo(
|
||||
Id value,
|
||||
) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'id', value: value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> idGreaterThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> idLessThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> idBetween(
|
||||
Id lower,
|
||||
Id upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'id',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> progressIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'progress'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
progressIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'progress'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> progressEqualTo(
|
||||
double? value, {
|
||||
double epsilon = Query.epsilon,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(
|
||||
property: r'progress',
|
||||
value: value,
|
||||
|
||||
epsilon: epsilon,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
progressGreaterThan(
|
||||
double? value, {
|
||||
bool include = false,
|
||||
double epsilon = Query.epsilon,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'progress',
|
||||
value: value,
|
||||
|
||||
epsilon: epsilon,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> progressLessThan(
|
||||
double? value, {
|
||||
bool include = false,
|
||||
double epsilon = Query.epsilon,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'progress',
|
||||
value: value,
|
||||
|
||||
epsilon: epsilon,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> progressBetween(
|
||||
double? lower,
|
||||
double? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
double epsilon = Query.epsilon,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'progress',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
|
||||
epsilon: epsilon,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
updatedAtIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'updatedAt'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
updatedAtIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'updatedAt'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> updatedAtEqualTo(
|
||||
DateTime? value,
|
||||
) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'updatedAt', value: value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition>
|
||||
updatedAtGreaterThan(DateTime? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'updatedAt',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> updatedAtLessThan(
|
||||
DateTime? value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'updatedAt',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterFilterCondition> updatedAtBetween(
|
||||
DateTime? lower,
|
||||
DateTime? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'updatedAt',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayRecordQueryObject
|
||||
on QueryBuilder<PlayRecord, PlayRecord, QFilterCondition> {}
|
||||
|
||||
extension PlayRecordQueryLinks
|
||||
on QueryBuilder<PlayRecord, PlayRecord, QFilterCondition> {}
|
||||
|
||||
extension PlayRecordQuerySortBy
|
||||
on QueryBuilder<PlayRecord, PlayRecord, QSortBy> {
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> sortByCourseId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'courseId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> sortByCourseIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'courseId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> sortByCourseName() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'courseName', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> sortByCourseNameDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'courseName', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> sortByProgress() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'progress', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> sortByProgressDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'progress', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> sortByUpdatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAt', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> sortByUpdatedAtDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAt', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayRecordQuerySortThenBy
|
||||
on QueryBuilder<PlayRecord, PlayRecord, QSortThenBy> {
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> thenByCourseId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'courseId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> thenByCourseIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'courseId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> thenByCourseName() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'courseName', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> thenByCourseNameDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'courseName', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> thenById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> thenByIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> thenByProgress() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'progress', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> thenByProgressDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'progress', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> thenByUpdatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAt', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QAfterSortBy> thenByUpdatedAtDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'updatedAt', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayRecordQueryWhereDistinct
|
||||
on QueryBuilder<PlayRecord, PlayRecord, QDistinct> {
|
||||
QueryBuilder<PlayRecord, PlayRecord, QDistinct> distinctByCourseId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'courseId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QDistinct> distinctByCourseName({
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'courseName', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QDistinct> distinctByProgress() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'progress');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, PlayRecord, QDistinct> distinctByUpdatedAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'updatedAt');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension PlayRecordQueryProperty
|
||||
on QueryBuilder<PlayRecord, PlayRecord, QQueryProperty> {
|
||||
QueryBuilder<PlayRecord, int, QQueryOperations> idProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'id');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, int?, QQueryOperations> courseIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'courseId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, String?, QQueryOperations> courseNameProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'courseName');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, double?, QQueryOperations> progressProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'progress');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<PlayRecord, DateTime?, QQueryOperations> updatedAtProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'updatedAt');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:isar_community/isar.dart';
|
||||
|
||||
part 'question.g.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【Question】题库题目模型(Isar 业务集合,对应 AppDatabase 的题库)
|
||||
///
|
||||
/// 一、为什么用强类型类而不是 Hive 的 Map:
|
||||
/// Isar 是强类型 NoSQL,每张"表"(集合)必须对应一个 Dart 类,
|
||||
/// 类字段即数据库列。字段名/类型写错会在编译期直接报错,
|
||||
/// 比 Hive 存 Map(运行时才崩)安全得多。
|
||||
///
|
||||
/// 二、题型字段 type(与试卷接口模型 ExamQuestion 共用约定):
|
||||
/// 1 单选 / 2 多选 / 3 判断 / 4 解答;
|
||||
/// ⚠ 本 Isar 模型是"题库下载/收藏"等本地资产用途,
|
||||
/// 试卷做题页必须用纯 Dart 的 ExamQuestion(features/exam/),
|
||||
/// 业务代码 import 本模型会把 schema 哈希拉进 web 编译图导致
|
||||
/// dart2js 编译失败(详见 ExamQuestion 头注释)。
|
||||
/// options 统一存 JSON 字符串(选择题存选项数组,判断/解答可空),
|
||||
/// answer 统一存字符串(多选 "A,B" 逗号拼接),渲染层按题型解析。
|
||||
///
|
||||
/// 三、高频坑:
|
||||
/// 1. 每个集合必须有 Id 主键,Id id = Isar.autoIncrement 表示自增,
|
||||
/// 写入时 Isar 会自动分配 id 回填到对象上;
|
||||
/// 2. 字段尽量用可空类型(String?),未赋值存 null;
|
||||
/// 3. 改字段结构后必须重跑 build_runner,否则 Schema 还是旧的。
|
||||
/// ============================================================
|
||||
@collection
|
||||
class Question {
|
||||
Id id = Isar.autoIncrement; // Isar 内部主键(自动递增,别手动赋值)
|
||||
|
||||
int? questionId; // 服务器题号(业务去重用)
|
||||
String? title; // 题干(HTML 富文本,RichTextContent 渲染)
|
||||
String? options; // 选项(JSON 字符串,如 ["A项","B项","C项","D项"])
|
||||
String? answer; // 正确答案(多选 "A,B" 逗号拼接)
|
||||
int? type; // 题型:1 单选 / 2 多选 / 3 判断 / 4 解答
|
||||
String? analysis; // 解析(交卷后查看答案用)
|
||||
int? categoryId; // 分类 id
|
||||
DateTime? createdAt; // 收藏/下载时间
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
import 'package:isar_community/isar.dart';
|
||||
|
||||
part 'wrong_question.g.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【WrongQuestion】错题本模型(对应 AppDatabase 的错题集合)
|
||||
///
|
||||
/// 一、字段设计:存题目快照 + 我的错误答案 + 时间,
|
||||
/// 离线也能看错题,不用每次进错题本都拉接口。
|
||||
///
|
||||
/// 二、高频坑:
|
||||
/// 1. 和 Question 一样必须有 Id 主键;
|
||||
/// 2. 服务器题号存 questionId 用于去重(同一题错多次只留一条);
|
||||
/// 3. 改字段后记得重跑 build_runner。
|
||||
/// ============================================================
|
||||
@collection
|
||||
class WrongQuestion {
|
||||
Id id = Isar.autoIncrement; // Isar 内部主键(自动递增)
|
||||
|
||||
int? questionId; // 题目 id(去重)
|
||||
String? title; // 题干快照
|
||||
String? wrongAnswer; // 我提交的错误答案
|
||||
DateTime? wrongAt; // 加入错题本时间
|
||||
}
|
||||
@@ -0,0 +1,931 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'wrong_question.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// IsarCollectionGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
|
||||
|
||||
extension GetWrongQuestionCollection on Isar {
|
||||
IsarCollection<WrongQuestion> get wrongQuestions => this.collection();
|
||||
}
|
||||
|
||||
const WrongQuestionSchema = CollectionSchema(
|
||||
name: r'WrongQuestion',
|
||||
id: -8005968927277545936,
|
||||
properties: {
|
||||
r'questionId': PropertySchema(
|
||||
id: 0,
|
||||
name: r'questionId',
|
||||
type: IsarType.long,
|
||||
),
|
||||
r'title': PropertySchema(id: 1, name: r'title', type: IsarType.string),
|
||||
r'wrongAnswer': PropertySchema(
|
||||
id: 2,
|
||||
name: r'wrongAnswer',
|
||||
type: IsarType.string,
|
||||
),
|
||||
r'wrongAt': PropertySchema(
|
||||
id: 3,
|
||||
name: r'wrongAt',
|
||||
type: IsarType.dateTime,
|
||||
),
|
||||
},
|
||||
|
||||
estimateSize: _wrongQuestionEstimateSize,
|
||||
serialize: _wrongQuestionSerialize,
|
||||
deserialize: _wrongQuestionDeserialize,
|
||||
deserializeProp: _wrongQuestionDeserializeProp,
|
||||
idName: r'id',
|
||||
indexes: {},
|
||||
links: {},
|
||||
embeddedSchemas: {},
|
||||
|
||||
getId: _wrongQuestionGetId,
|
||||
getLinks: _wrongQuestionGetLinks,
|
||||
attach: _wrongQuestionAttach,
|
||||
version: '3.3.2',
|
||||
);
|
||||
|
||||
int _wrongQuestionEstimateSize(
|
||||
WrongQuestion object,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
var bytesCount = offsets.last;
|
||||
{
|
||||
final value = object.title;
|
||||
if (value != null) {
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
{
|
||||
final value = object.wrongAnswer;
|
||||
if (value != null) {
|
||||
bytesCount += 3 + value.length * 3;
|
||||
}
|
||||
}
|
||||
return bytesCount;
|
||||
}
|
||||
|
||||
void _wrongQuestionSerialize(
|
||||
WrongQuestion object,
|
||||
IsarWriter writer,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
writer.writeLong(offsets[0], object.questionId);
|
||||
writer.writeString(offsets[1], object.title);
|
||||
writer.writeString(offsets[2], object.wrongAnswer);
|
||||
writer.writeDateTime(offsets[3], object.wrongAt);
|
||||
}
|
||||
|
||||
WrongQuestion _wrongQuestionDeserialize(
|
||||
Id id,
|
||||
IsarReader reader,
|
||||
List<int> offsets,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
final object = WrongQuestion();
|
||||
object.id = id;
|
||||
object.questionId = reader.readLongOrNull(offsets[0]);
|
||||
object.title = reader.readStringOrNull(offsets[1]);
|
||||
object.wrongAnswer = reader.readStringOrNull(offsets[2]);
|
||||
object.wrongAt = reader.readDateTimeOrNull(offsets[3]);
|
||||
return object;
|
||||
}
|
||||
|
||||
P _wrongQuestionDeserializeProp<P>(
|
||||
IsarReader reader,
|
||||
int propertyId,
|
||||
int offset,
|
||||
Map<Type, List<int>> allOffsets,
|
||||
) {
|
||||
switch (propertyId) {
|
||||
case 0:
|
||||
return (reader.readLongOrNull(offset)) as P;
|
||||
case 1:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 2:
|
||||
return (reader.readStringOrNull(offset)) as P;
|
||||
case 3:
|
||||
return (reader.readDateTimeOrNull(offset)) as P;
|
||||
default:
|
||||
throw IsarError('Unknown property with id $propertyId');
|
||||
}
|
||||
}
|
||||
|
||||
Id _wrongQuestionGetId(WrongQuestion object) {
|
||||
return object.id;
|
||||
}
|
||||
|
||||
List<IsarLinkBase<dynamic>> _wrongQuestionGetLinks(WrongQuestion object) {
|
||||
return [];
|
||||
}
|
||||
|
||||
void _wrongQuestionAttach(
|
||||
IsarCollection<dynamic> col,
|
||||
Id id,
|
||||
WrongQuestion object,
|
||||
) {
|
||||
object.id = id;
|
||||
}
|
||||
|
||||
extension WrongQuestionQueryWhereSort
|
||||
on QueryBuilder<WrongQuestion, WrongQuestion, QWhere> {
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterWhere> anyId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(const IdWhereClause.any());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension WrongQuestionQueryWhere
|
||||
on QueryBuilder<WrongQuestion, WrongQuestion, QWhereClause> {
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterWhereClause> idEqualTo(
|
||||
Id id,
|
||||
) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IdWhereClause.between(lower: id, upper: id));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterWhereClause> idNotEqualTo(
|
||||
Id id,
|
||||
) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: id, includeUpper: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: id, includeLower: false),
|
||||
);
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: id, includeLower: false),
|
||||
)
|
||||
.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: id, includeUpper: false),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterWhereClause> idGreaterThan(
|
||||
Id id, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.greaterThan(lower: id, includeLower: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterWhereClause> idLessThan(
|
||||
Id id, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.lessThan(upper: id, includeUpper: include),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterWhereClause> idBetween(
|
||||
Id lowerId,
|
||||
Id upperId, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
IdWhereClause.between(
|
||||
lower: lowerId,
|
||||
includeLower: includeLower,
|
||||
upper: upperId,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension WrongQuestionQueryFilter
|
||||
on QueryBuilder<WrongQuestion, WrongQuestion, QFilterCondition> {
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition> idEqualTo(
|
||||
Id value,
|
||||
) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'id', value: value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
idGreaterThan(Id value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition> idLessThan(
|
||||
Id value, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'id',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition> idBetween(
|
||||
Id lower,
|
||||
Id upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'id',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
questionIdIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'questionId'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
questionIdIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'questionId'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
questionIdEqualTo(int? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'questionId', value: value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
questionIdGreaterThan(int? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'questionId',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
questionIdLessThan(int? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'questionId',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
questionIdBetween(
|
||||
int? lower,
|
||||
int? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'questionId',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'title'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'title'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleEqualTo(String? value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(
|
||||
property: r'title',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleGreaterThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'title',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleLessThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'title',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleBetween(
|
||||
String? lower,
|
||||
String? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'title',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleStartsWith(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.startsWith(
|
||||
property: r'title',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleEndsWith(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.endsWith(
|
||||
property: r'title',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleContains(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.contains(
|
||||
property: r'title',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleMatches(String pattern, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.matches(
|
||||
property: r'title',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'title', value: ''),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
titleIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(property: r'title', value: ''),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'wrongAnswer'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'wrongAnswer'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerEqualTo(String? value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(
|
||||
property: r'wrongAnswer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerGreaterThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'wrongAnswer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerLessThan(
|
||||
String? value, {
|
||||
bool include = false,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'wrongAnswer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerBetween(
|
||||
String? lower,
|
||||
String? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'wrongAnswer',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerStartsWith(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.startsWith(
|
||||
property: r'wrongAnswer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerEndsWith(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.endsWith(
|
||||
property: r'wrongAnswer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerContains(String value, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.contains(
|
||||
property: r'wrongAnswer',
|
||||
value: value,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerMatches(String pattern, {bool caseSensitive = true}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.matches(
|
||||
property: r'wrongAnswer',
|
||||
wildcard: pattern,
|
||||
caseSensitive: caseSensitive,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerIsEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'wrongAnswer', value: ''),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAnswerIsNotEmpty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(property: r'wrongAnswer', value: ''),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAtIsNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNull(property: r'wrongAt'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAtIsNotNull() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
const FilterCondition.isNotNull(property: r'wrongAt'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAtEqualTo(DateTime? value) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.equalTo(property: r'wrongAt', value: value),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAtGreaterThan(DateTime? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.greaterThan(
|
||||
include: include,
|
||||
property: r'wrongAt',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAtLessThan(DateTime? value, {bool include = false}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.lessThan(
|
||||
include: include,
|
||||
property: r'wrongAt',
|
||||
value: value,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterFilterCondition>
|
||||
wrongAtBetween(
|
||||
DateTime? lower,
|
||||
DateTime? upper, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addFilterCondition(
|
||||
FilterCondition.between(
|
||||
property: r'wrongAt',
|
||||
lower: lower,
|
||||
includeLower: includeLower,
|
||||
upper: upper,
|
||||
includeUpper: includeUpper,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension WrongQuestionQueryObject
|
||||
on QueryBuilder<WrongQuestion, WrongQuestion, QFilterCondition> {}
|
||||
|
||||
extension WrongQuestionQueryLinks
|
||||
on QueryBuilder<WrongQuestion, WrongQuestion, QFilterCondition> {}
|
||||
|
||||
extension WrongQuestionQuerySortBy
|
||||
on QueryBuilder<WrongQuestion, WrongQuestion, QSortBy> {
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> sortByQuestionId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'questionId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy>
|
||||
sortByQuestionIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'questionId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> sortByTitle() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'title', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> sortByTitleDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'title', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> sortByWrongAnswer() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'wrongAnswer', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy>
|
||||
sortByWrongAnswerDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'wrongAnswer', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> sortByWrongAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'wrongAt', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> sortByWrongAtDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'wrongAt', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension WrongQuestionQuerySortThenBy
|
||||
on QueryBuilder<WrongQuestion, WrongQuestion, QSortThenBy> {
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> thenById() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> thenByIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'id', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> thenByQuestionId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'questionId', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy>
|
||||
thenByQuestionIdDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'questionId', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> thenByTitle() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'title', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> thenByTitleDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'title', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> thenByWrongAnswer() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'wrongAnswer', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy>
|
||||
thenByWrongAnswerDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'wrongAnswer', Sort.desc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> thenByWrongAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'wrongAt', Sort.asc);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QAfterSortBy> thenByWrongAtDesc() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addSortBy(r'wrongAt', Sort.desc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension WrongQuestionQueryWhereDistinct
|
||||
on QueryBuilder<WrongQuestion, WrongQuestion, QDistinct> {
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QDistinct> distinctByQuestionId() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'questionId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QDistinct> distinctByTitle({
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'title', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QDistinct> distinctByWrongAnswer({
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'wrongAnswer', caseSensitive: caseSensitive);
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, WrongQuestion, QDistinct> distinctByWrongAt() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addDistinctBy(r'wrongAt');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension WrongQuestionQueryProperty
|
||||
on QueryBuilder<WrongQuestion, WrongQuestion, QQueryProperty> {
|
||||
QueryBuilder<WrongQuestion, int, QQueryOperations> idProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'id');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, int?, QQueryOperations> questionIdProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'questionId');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, String?, QQueryOperations> titleProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'title');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, String?, QQueryOperations> wrongAnswerProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'wrongAnswer');
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<WrongQuestion, DateTime?, QQueryOperations> wrongAtProperty() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addPropertyName(r'wrongAt');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【PrivacyStorage】隐私协议同意状态本地存储
|
||||
///
|
||||
/// 作用:
|
||||
/// 1. 记录用户是否已经同意启动隐私协议;
|
||||
/// 2. App 冷启动时先读这个值,决定是否要先拦住进入主流程;
|
||||
/// 3. 一旦同意过,就不再重复弹首次启动隐私弹层。
|
||||
///
|
||||
/// 为什么单独拆一个存储类:
|
||||
/// 这类"启动门禁"和用户资料不是一回事,放独立文件更清晰,
|
||||
/// 后续如果要补"撤回授权 / 重新查看协议"也更好扩展。
|
||||
/// ============================================================
|
||||
class PrivacyStorage {
|
||||
PrivacyStorage._();
|
||||
|
||||
static final PrivacyStorage instance = PrivacyStorage._();
|
||||
|
||||
static const String _privacyAcceptedKey = 'privacy_accepted';
|
||||
|
||||
/// 读取是否已同意隐私协议。
|
||||
/// 首次安装/首次打开没有记录时,默认返回 false。
|
||||
Future<bool> readAccepted() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_privacyAcceptedKey) ?? false;
|
||||
}
|
||||
|
||||
/// 保存"已同意隐私协议"状态。
|
||||
Future<void> saveAccepted(bool accepted) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_privacyAcceptedKey, accepted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// ============================================================
|
||||
/// 【AnswerRecordStore】答题进度数据源 —— 平台分发入口(条件导出)
|
||||
///
|
||||
/// 一、为什么也要条件导出(和 WrongQuestionStore 同理):
|
||||
/// AnswerRecord 模型 import 了 isar,web 端 import 直接编译失败。
|
||||
/// 所以"Isar 访问"全部收口到本 store,页面永远只面向本文件:
|
||||
/// - native → answer_record_store_io.dart(真 Isar,续答落盘)
|
||||
/// - web → answer_record_store_web.dart(空实现,跳过持久化)
|
||||
///
|
||||
/// 二、续答三动作(对应三个方法):
|
||||
/// findByPaper() 进卷时读回 → 注入 answerProvider
|
||||
/// put() 每次作答增量写单条(毫秒级)
|
||||
/// deleteByPaper() 交卷成功后按 paperId 批量清理
|
||||
///
|
||||
/// 三、接口统一用 dynamic:
|
||||
/// 模型 import 了 isar,web 端无法 import 模型,接口层返回
|
||||
/// `List<dynamic>`,页面用 dynamic 访问字段(见 wrong_question_store)。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 如果是原生平台就用 Isar 真实现,否则用空实现兜底
|
||||
export 'answer_record_store_web.dart'
|
||||
if (dart.library.io) 'answer_record_store_io.dart';
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:isar_community/isar.dart';
|
||||
import 'package:new_learn/core/storage/database/app_database_io.dart';
|
||||
import '../models/answer_record.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AnswerRecordStore】答题进度数据源 —— native 真实现(Isar)
|
||||
///
|
||||
/// 一、续答数据流(进卷 → 答题 → 交卷):
|
||||
/// findByPaper() 进卷时按 paperId 查全部答案 → 注入 answerProvider
|
||||
/// put() 每题作答写一条(增量 put,幂等:同题覆盖旧答案)
|
||||
/// deleteByPaper() 交卷成功后批量删该卷记录(防止下次恢复旧答案)
|
||||
///
|
||||
/// 二、为什么用 @Index() 索引:
|
||||
/// where(paperId).equalTo() 走索引查询,100 题毫秒级;
|
||||
/// 交卷 deleteByPaper 同样按 paperId 过滤,一次事务删干净。
|
||||
///
|
||||
/// 三、坑:
|
||||
/// 1. 写操作必须包在 isar.writeTxn()(Isar 事务约束);
|
||||
/// 2. 改模型字段后重跑 build_runner;
|
||||
/// 3. ⚠ 这里必须 import app_database_io.dart(具体实现),
|
||||
/// 不能 import app_database.dart(条件导出入口)!
|
||||
/// 分析器会检查所有条件分支,web 分支下入口解析成空实现,
|
||||
/// db.isar / db.answerRecords 会报 undefined getter。
|
||||
/// ============================================================
|
||||
class AnswerRecordStore {
|
||||
AnswerRecordStore._();
|
||||
|
||||
static final AnswerRecordStore instance = AnswerRecordStore._();
|
||||
|
||||
/// 【进卷恢复】按试卷查全部已答记录
|
||||
Future<List<dynamic>> findByPaper(int paperId) async {
|
||||
final db = AppDatabase.instance;
|
||||
final list = await db.answerRecords
|
||||
.where()
|
||||
.paperIdEqualTo(paperId)
|
||||
.findAll();
|
||||
return list.cast<dynamic>();
|
||||
}
|
||||
|
||||
/// 【作答写入】每题答案增量落盘(同题重复作答自动覆盖旧值)
|
||||
Future<void> put({
|
||||
required int paperId,
|
||||
required int questionId,
|
||||
required String answer,
|
||||
}) async {
|
||||
final db = AppDatabase.instance;
|
||||
await db.isar.writeTxn(() async {
|
||||
await db.answerRecords.put(
|
||||
AnswerRecord()
|
||||
..paperId = paperId
|
||||
..questionId = questionId
|
||||
..answer = answer
|
||||
..updatedAt = DateTime.now(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 【交卷清理】按试卷批量删除进度(交卷成功后调用)
|
||||
Future<void> deleteByPaper(int paperId) async {
|
||||
final db = AppDatabase.instance;
|
||||
await db.isar.writeTxn(() async {
|
||||
await db.answerRecords
|
||||
.where()
|
||||
.paperIdEqualTo(paperId)
|
||||
.deleteAll();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AnswerRecordStore】答题进度数据源 —— web 空实现
|
||||
///
|
||||
/// web 端不 import isar / 模型(编译约束),所有操作变为空实现:
|
||||
/// 写入 → 打印提示;读取 → 空列表。
|
||||
/// 效果:web 调试时"续答持久化"自动降级为纯内存(不落盘),
|
||||
/// UI/滑动/答题逻辑全部正常,只有刷新页面后进度丢失。
|
||||
/// ============================================================
|
||||
class AnswerRecordStore {
|
||||
AnswerRecordStore._();
|
||||
|
||||
static final AnswerRecordStore instance = AnswerRecordStore._();
|
||||
|
||||
Future<List<dynamic>> findByPaper(int paperId) async => const [];
|
||||
|
||||
Future<void> put({
|
||||
required int paperId,
|
||||
required int questionId,
|
||||
required String answer,
|
||||
}) async {
|
||||
debugPrint('▶ 【Isar】Web 调试模式:答题进度写入已跳过(仅真机可持久化续答)');
|
||||
}
|
||||
|
||||
Future<void> deleteByPaper(int paperId) async {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/// ============================================================
|
||||
/// 【WrongQuestionStore】错题本数据源 —— 平台分发入口(条件导出)
|
||||
///
|
||||
/// 一、为什么也要条件导出(和 AppDatabase 同理):
|
||||
/// Isar 的集合类型(IsarCollection<T>)只存在于 native 实现,
|
||||
/// web 端 import isar / *.g.dart 直接编译失败。页面如果直接
|
||||
/// 访问 AppDatabase.instance.wrongQuestions,web 编译就报错。
|
||||
/// 所以把"Isar 访问"全部收口到本 store,页面永远只面向本文件:
|
||||
/// - native → wrong_question_store_io.dart(真 Isar)
|
||||
/// - web → wrong_question_store_web.dart(空实现,跳过写库)
|
||||
///
|
||||
/// 二、为什么接口用 dynamic 而非 WrongQuestion:
|
||||
/// WrongQuestion 模型 import 了 isar,web 端无法 import 模型。
|
||||
/// 接口统一返回 List<dynamic>,页面用 dynamic 访问字段,
|
||||
/// web 编译通过、运行时只是拿不到数据(演示降级提示)。
|
||||
///
|
||||
/// 三、这是【Isar 深水区:watch 响应式】的入口:
|
||||
/// watchAll() 返回 Stream:数据库一变(任何地方 put/delete),
|
||||
/// 流自动发事件 → 页面 StreamBuilder 自动重建,不用手动 reload。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
/// 如果是原生平台就用 Isar 真实现,否则用空实现兜底
|
||||
export 'wrong_question_store_web.dart'
|
||||
if (dart.library.io) 'wrong_question_store_io.dart';
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:isar_community/isar.dart';
|
||||
import 'package:new_learn/core/storage/database/app_database_io.dart';
|
||||
import '../models/wrong_question.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【WrongQuestionStore】错题本数据源 —— native 真实现(Isar)
|
||||
///
|
||||
/// 一、watch 响应式(本文件核心):
|
||||
/// watchAll() → AppDatabase.wrongQuestions.where().watch(fireImmediately: true)
|
||||
/// - fireImmediately: true = 订阅后立刻吐一次当前全量数据,
|
||||
/// 页面首帧就能显示,不用再单独查一次;
|
||||
/// - 之后任何地方 put/delete 错题(即使不是本页面),
|
||||
/// 流自动发新事件 → 订阅它的 StreamBuilder 自动重建。
|
||||
/// 这就是"数据库推数据"而非"页面拉数据"。
|
||||
///
|
||||
/// 二、对比非响应式:
|
||||
/// findAll() → where().findAll() 是主动查询,要自己触发/刷新。
|
||||
///
|
||||
/// 三、坑:
|
||||
/// 1. 写操作必须包在 isar.writeTxn()(Isar 事务约束);
|
||||
/// 2. 改模型字段后重跑 build_runner;
|
||||
/// 3. watch 返回 `Stream<List<Object>>`,接口层 cast 成
|
||||
/// `List<dynamic>` 供页面消费(web 端也要能编译);
|
||||
/// 4. ⚠ 这里必须 import app_database_io.dart(具体实现),
|
||||
/// 不能 import app_database.dart(条件导出入口)!
|
||||
/// 分析器会检查所有条件分支,web 分支下入口解析成空实现,
|
||||
/// db.isar / db.wrongQuestions 会报 undefined getter。
|
||||
/// 本文件只在 native 编译,直接 import io 实现最稳。
|
||||
/// 5. ⚠ isar_community 3.3.2 的 API 和 isar 3.1.x 不一样(大坑):
|
||||
/// - IsarCollection 上【没有】watch(),全量监听要写成
|
||||
/// where().watch()(返回 `Stream<List<T>>`);
|
||||
/// - QueryBuilder 上【没有】findAll(),终端方法(findAll/
|
||||
/// watch/count/findFirst...)都在 extension 里,而 Dart 的
|
||||
/// extension 成员要求定义它的库被 import 才可见(import 不传递),
|
||||
/// 所以本文件必须加 import 'package:isar_community/isar.dart',
|
||||
/// 否则 where().findAll() / where().watch() 报 undefined method。
|
||||
/// ============================================================
|
||||
class WrongQuestionStore {
|
||||
WrongQuestionStore._();
|
||||
|
||||
static final WrongQuestionStore instance = WrongQuestionStore._();
|
||||
|
||||
/// 添加一条错题(演示:任何写入都会触发 watch 事件)
|
||||
Future<void> add({
|
||||
int? questionId,
|
||||
String? title,
|
||||
String? wrongAnswer,
|
||||
}) async {
|
||||
final db = AppDatabase.instance;
|
||||
await db.isar.writeTxn(() async {
|
||||
await db.wrongQuestions.put(
|
||||
WrongQuestion()
|
||||
..questionId = questionId
|
||||
..title = title
|
||||
..wrongAnswer = wrongAnswer
|
||||
..wrongAt = DateTime.now(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// 删除一条(按 Isar 主键 id)
|
||||
Future<void> remove(int id) async {
|
||||
final db = AppDatabase.instance;
|
||||
await db.isar.writeTxn(() => db.wrongQuestions.delete(id));
|
||||
}
|
||||
|
||||
/// 【响应式】错题集合 watch 流:数据一变自动发事件
|
||||
Stream<List<dynamic>> watchAll() {
|
||||
return AppDatabase.instance.wrongQuestions
|
||||
.where()
|
||||
.watch(fireImmediately: true)
|
||||
.cast<List<dynamic>>();
|
||||
}
|
||||
|
||||
/// 【非响应式】手动查询全部(对比 watch 用,需自己触发刷新)
|
||||
Future<List<dynamic>> findAll() async {
|
||||
return AppDatabase.instance.wrongQuestions.where().findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【WrongQuestionStore】错题本数据源 —— web 空实现
|
||||
///
|
||||
/// web 端不 import isar / 模型(编译约束),所有操作变为空实现:
|
||||
/// 写入 → 打印提示;读取 → 空流/空列表。
|
||||
/// demo 页面在 web 上用 kIsWeb 显示"仅真机可用"提示即可。
|
||||
/// ============================================================
|
||||
class WrongQuestionStore {
|
||||
WrongQuestionStore._();
|
||||
|
||||
static final WrongQuestionStore instance = WrongQuestionStore._();
|
||||
|
||||
Future<void> add({
|
||||
int? questionId,
|
||||
String? title,
|
||||
String? wrongAnswer,
|
||||
}) async {
|
||||
debugPrint('▶ 【Isar】Web 调试模式:错题写入已跳过(仅真机可用)');
|
||||
}
|
||||
|
||||
Future<void> remove(int id) async {}
|
||||
|
||||
Stream<List<dynamic>> watchAll() => const Stream.empty();
|
||||
|
||||
Future<List<dynamic>> findAll() async => const [];
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:new_learn/core/models/user_model.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【UserStorage】用户本地存储(token 安全存储 + 用户信息缓存)
|
||||
///
|
||||
/// 一、为什么用两个插件(对应 Vue 的 uni.setStorageSync):
|
||||
/// 1. flutter_secure_storage → 存 Token(敏感凭据)
|
||||
/// Android 走 Keystore 加密(EncryptedSharedPreferences),
|
||||
/// iOS 走 Keychain,越狱/root 也难读出,适合放登录凭据;
|
||||
/// 2. shared_preferences → 存用户信息缓存(非敏感)
|
||||
/// 明文 XML/plist,读写快,适合放 userName/avatar 这类
|
||||
/// 不敏感数据 + 各种开关。
|
||||
/// 混合方案 = 业界标准做法:凭据安全、配置轻量。
|
||||
///
|
||||
/// 二、高频坑:
|
||||
/// 1. 两个插件都要 await 初始化,别在 build 里同步调用;
|
||||
/// 2. shared_preferences 存对象必须先 jsonEncode 成字符串;
|
||||
/// 3. 密码永远不要明文存 shared_preferences!(记住密码≠存密码)
|
||||
/// ============================================================
|
||||
class UserStorage {
|
||||
UserStorage._();
|
||||
|
||||
static final UserStorage instance = UserStorage._();
|
||||
|
||||
static const String _tokenKey = 'auth_token';
|
||||
static const String _userKey = 'current_user';
|
||||
|
||||
final FlutterSecureStorage _secure = const FlutterSecureStorage();
|
||||
|
||||
// ---------- Token(安全存储) ----------
|
||||
|
||||
/// 保存登录 Token(登录成功后调用)
|
||||
Future<void> saveToken(String token) =>
|
||||
_secure.write(key: _tokenKey, value: token);
|
||||
|
||||
/// 读取 Token(冷启动 restoreToken 用)
|
||||
Future<String?> readToken() => _secure.read(key: _tokenKey);
|
||||
|
||||
/// 清除 Token(退出登录时调用)
|
||||
Future<void> clearToken() => _secure.delete(key: _tokenKey);
|
||||
|
||||
// ---------- 用户信息(普通缓存) ----------
|
||||
|
||||
/// 保存当前用户信息(登录后调 GetCurrentUserExtension 拿到再存)
|
||||
///
|
||||
/// 作用:
|
||||
/// 1. 把"内存里的用户对象"落到本地;
|
||||
/// 2. App 下次冷启动时,currentUserProvider.build() 可以直接读缓存回填;
|
||||
/// 3. 这样头像/用户名/班级等信息不用等接口返回,页面也能先展示。
|
||||
///
|
||||
/// 为什么要 jsonEncode:
|
||||
/// SharedPreferences 不能直接存 Dart 对象,只能存 String/int/bool 等基础类型,
|
||||
/// 所以要先把 CurrentUserExtension → Map → JSON 字符串。
|
||||
Future<void> saveUserInfo(CurrentUserExtension user) async {
|
||||
// 拿到 SharedPreferences 单例,后续通过 key-value 方式写本地缓存。
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// user.toJson():对象转 Map
|
||||
// jsonEncode(...):Map 转字符串
|
||||
// _userKey:当前用户缓存的固定 key
|
||||
await prefs.setString(_userKey, jsonEncode(user.toJson()));
|
||||
}
|
||||
|
||||
/// 读取当前用户信息(没有登录过则返回 null)
|
||||
Future<CurrentUserExtension?> readUserInfo() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_userKey);
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
return CurrentUserExtension.fromJson(
|
||||
jsonDecode(raw) as Map<String, dynamic>,
|
||||
);
|
||||
} catch (_) {
|
||||
return null; // 缓存损坏时当没登录过处理
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除用户信息(退出登录时调用)
|
||||
Future<void> clearUserInfo() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_userKey);
|
||||
}
|
||||
|
||||
// ---------- 记住密码(账号普通缓存 + 密码安全存储) ----------
|
||||
|
||||
static const String _rememberedAccountKey = 'remembered_account';
|
||||
static const String _rememberedPasswordKey = 'remembered_password';
|
||||
|
||||
/// 保存记住的账号(非敏感 → shared_preferences 明文即可)
|
||||
Future<void> saveRememberedAccount(String account) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_rememberedAccountKey, account);
|
||||
}
|
||||
|
||||
/// 读取记住的账号(没存过返回 null)
|
||||
Future<String?> readRememberedAccount() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_rememberedAccountKey);
|
||||
}
|
||||
|
||||
Future<void> clearRememberedAccount() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_rememberedAccountKey);
|
||||
}
|
||||
|
||||
/// 保存记住的密码(敏感 → flutter_secure_storage 加密存储)
|
||||
/// 注意:只有用户勾选"记住密码"才调用,不勾选绝不落盘
|
||||
Future<void> saveRememberedPassword(String password) =>
|
||||
_secure.write(key: _rememberedPasswordKey, value: password);
|
||||
|
||||
Future<String?> readRememberedPassword() =>
|
||||
_secure.read(key: _rememberedPasswordKey);
|
||||
|
||||
Future<void> clearRememberedPassword() =>
|
||||
_secure.delete(key: _rememberedPasswordKey);
|
||||
|
||||
/// 一键退出登录:清 Token + 用户信息
|
||||
/// (记住的账号密码保留,方便用户下次登录)
|
||||
Future<void> clearAll() async {
|
||||
await clearToken();
|
||||
await clearUserInfo();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'brands/red_theme.dart';
|
||||
import 'brands/sky_blue_theme.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【BrandTheme】品牌换肤 —— 抽象模型 + 注册表(与具体配置解耦)
|
||||
///
|
||||
/// 一、目录分工(解耦规则):
|
||||
/// core/theme/brand_theme.dart ← 本文件:只定义"模型"(类/枚举)
|
||||
/// 和"注册表"(有哪些品牌)
|
||||
/// core/theme/brands/xxx_theme.dart← 每个品牌一个文件:只有"配置"
|
||||
/// (BrandTheme 的具体参数)
|
||||
/// 模型文件不写任何具体颜色;品牌文件不写任何逻辑。
|
||||
///
|
||||
/// 二、新增一套主题三步走:
|
||||
/// 1. brands/ 下新建文件,定义 `const xxxTheme = BrandTheme(...)`;
|
||||
/// 2. 本文件顶部 import + 注册表 brandThemes 里加一行;
|
||||
/// 3. 需要的资源(banner/图标)放对应目录。
|
||||
/// 页面/切换逻辑零改动——它们只消费"模型 + 注册表"。
|
||||
///
|
||||
/// 三、两级模型回顾:
|
||||
/// 一级 = 品牌:seedColor 生成整套 Material 色板(全 App 共享);
|
||||
/// 二级 = 页面细节(PageTheme):按页面层级区分背景/头部/强调色/
|
||||
/// 标题/banner。页面只声明"我是哪个层级",从品牌对象查细节。
|
||||
/// ============================================================
|
||||
|
||||
/// 页面层级:一级分类(新增层级 = 这里加枚举值 + 各品牌补配置)
|
||||
enum PageLevel { home, detail, result }
|
||||
|
||||
/// 主题 ID 常量:注册表 key(BrandTheme.name)+ 品牌表 themeId 都引这里。
|
||||
/// 好处:改名字一处生效;IDE 补全 + 拼写错直接编译报错(而不是静默兜底)。
|
||||
abstract final class BrandNames {
|
||||
static const skyBlue = 'SkyBlue';
|
||||
static const red = 'Red';
|
||||
}
|
||||
|
||||
/// 一级:品牌 = 全局色板 seed + 二级细节表
|
||||
class BrandTheme {
|
||||
const BrandTheme({
|
||||
required this.name,
|
||||
required this.seedColor,
|
||||
required this.pages,
|
||||
});
|
||||
|
||||
/// 品牌名(显示 + 持久化标识)
|
||||
final String name;
|
||||
|
||||
/// 生成整套 Material 色板(primary/secondary/surface...)
|
||||
final Color seedColor;
|
||||
|
||||
/// 二级:页面层级 → 该层级的页面细节
|
||||
final Map<PageLevel, PageTheme> pages;
|
||||
}
|
||||
|
||||
/// 二级:某个层级页面的细节(颜色 / 文案 / 专属资源)
|
||||
class PageTheme {
|
||||
const PageTheme({
|
||||
required this.bg,
|
||||
required this.headerBg,
|
||||
required this.accent,
|
||||
required this.title,
|
||||
this.banner,
|
||||
});
|
||||
|
||||
/// 页面背景色
|
||||
final Color bg;
|
||||
|
||||
/// 头部/AppBar 色
|
||||
final Color headerBg;
|
||||
|
||||
/// 本页强调色(按钮/选中态)
|
||||
final Color accent;
|
||||
|
||||
/// 本页标题文案
|
||||
final String title;
|
||||
|
||||
/// 本页专属 banner(资源路径,如 assets/img/banner_sky.png)
|
||||
final String? banner;
|
||||
}
|
||||
|
||||
/// 品牌注册表:切换页/持久化只遍历这里。
|
||||
/// 新增品牌 = brands/ 加文件 + 这里加一行。
|
||||
final List<BrandTheme> brandThemes = [skyBlueTheme, redTheme];
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../brand_theme.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【Red 主题】具体配置(只改这里,不动模型/页面)
|
||||
///
|
||||
/// 一套品牌的全部参数:seed 色 + 三个页面层级各自的细节。
|
||||
/// 页面从 brand_theme.dart 的注册表拿它,切换 = 换 BrandTheme。
|
||||
/// ============================================================
|
||||
const redTheme = BrandTheme(
|
||||
name: BrandNames.red,
|
||||
seedColor: Color(0xFFEF4444),
|
||||
pages: {
|
||||
PageLevel.home: PageTheme(
|
||||
bg: Color(0xFFFFF5F5),
|
||||
headerBg: Color(0xFFEF4444),
|
||||
accent: Color(0xFFDC2626),
|
||||
title: '任务',
|
||||
),
|
||||
PageLevel.detail: PageTheme(
|
||||
bg: Colors.white,
|
||||
headerBg: Color(0xFFB91C1C),
|
||||
accent: Color(0xFFB91C1C),
|
||||
title: '课程详情',
|
||||
banner: 'assets/img/banner_red.png',
|
||||
),
|
||||
PageLevel.result: PageTheme(
|
||||
bg: Color(0xFFFEF2F2),
|
||||
headerBg: Color(0xFF991B1B),
|
||||
accent: Color(0xFF991B1B),
|
||||
title: '答题结果',
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../brand_theme.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【SkyBlue 主题】具体配置(只改这里,不动模型/页面)
|
||||
///
|
||||
/// 一套品牌的全部参数:seed 色 + 三个页面层级各自的细节。
|
||||
/// 页面从 brand_theme.dart 的注册表拿它,切换 = 换 BrandTheme。
|
||||
/// ============================================================
|
||||
const skyBlueTheme = BrandTheme(
|
||||
name: BrandNames.skyBlue,
|
||||
seedColor: Color(0xFF4FC3F7),
|
||||
pages: {
|
||||
PageLevel.home: PageTheme(
|
||||
bg: Color(0xFFF0F9FF),
|
||||
headerBg: Color(0xFF4FC3F7),
|
||||
accent: Color(0xFF29B6F6),
|
||||
title: '任务',
|
||||
),
|
||||
PageLevel.detail: PageTheme(
|
||||
bg: Colors.white,
|
||||
headerBg: Color(0xFF0288D1),
|
||||
accent: Color(0xFF0288D1),
|
||||
title: '课程详情',
|
||||
banner: 'assets/img/banner_sky.png',
|
||||
),
|
||||
PageLevel.result: PageTheme(
|
||||
bg: Color(0xFFE1F5FE),
|
||||
headerBg: Color(0xFF0277BD),
|
||||
accent: Color(0xFF0277BD),
|
||||
title: '答题结果',
|
||||
),
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
/// ============================================================
|
||||
/// 【ImgUrl】图片地址工具 —— 对标 UniApp utils/image.js 的 $img
|
||||
///
|
||||
/// 规则(与原 $img 完全一致):
|
||||
/// 1. 空值 / 纯空白 → 返回空串
|
||||
/// 2. 以 http 开头 → 原样返回(已是完整 URL)
|
||||
/// 3. 以 /static/ 开头 → 原样返回(小程序本地资源;Flutter 端此类
|
||||
/// 资源应改用 Image.asset / Material Icon,不能直接加载网络)
|
||||
/// 4. 其余 → 拼 CDN 域名返回完整 URL
|
||||
/// ============================================================
|
||||
class ImgUrl {
|
||||
ImgUrl._();
|
||||
|
||||
/// 图片 CDN 域名(对标 image.js 的 IMG_URL;旧域名 hx.23544.com 已弃用)。
|
||||
/// TODO(业务逻辑): 如需按环境切换,可迁移进 AppEnv 的 _envTable。
|
||||
static const String _cdn = 'https://hx.cnzhyx.cn';
|
||||
|
||||
/// 等价 Vue 的 $img(path)
|
||||
static String build(String? path) {
|
||||
if (path == null) return '';
|
||||
final s = path.trim();
|
||||
if (s.isEmpty) return '';
|
||||
if (s.startsWith('http')) return s;
|
||||
if (s.startsWith('/static/')) return s;
|
||||
return '$_cdn$s';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'dart:math' show min;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【新项目屏幕适配模板】= 方案 B(平板动态 designSize) + 方案 A(.spMin) + 兜底
|
||||
///
|
||||
/// 使用方法(新项目):
|
||||
/// 1. pubspec.yaml 添加 flutter_screenutil: ^5.9.0;
|
||||
/// 2. 复制本文件到新项目 lib/utils/,入口改成 runApp(const ScreenAdaptApp());
|
||||
/// 3. 页面里统一按下面规则写尺寸:
|
||||
/// 字号:fontSize: 16.spMin ← 大屏只缩不放(关键!)
|
||||
/// 宽度/横向间距:16.w 高度/纵向间距:16.h 圆角:12.r
|
||||
///
|
||||
/// 一、这套方案解决什么问题?
|
||||
/// 默认 .sp = 字号 × 屏宽/375。平板宽 800dp → 字体放大 2.1 倍,爆炸。
|
||||
/// 方案 B:平板(≥600dp)自动切换大 designSize,缩放比例回到 ~1;
|
||||
/// 方案 A:.spMin = min(设计值, .sp),字体只允许缩小,永不超设计稿;
|
||||
/// 兜底:fontSizeResolver 限制字体最大放大 1.2 倍(防横屏/极端宽屏)。
|
||||
///
|
||||
/// 二、注意事项:
|
||||
/// 1. 平板上布局尺寸接近设计稿原值,若想"大屏显示更多内容",
|
||||
/// 需配合 LayoutBuilder / 断点做响应式布局;
|
||||
/// 2. 所有阈值集中在 AppAdapt,一处改、全局生效;
|
||||
/// 3. designSize 用 MediaQueryData.fromView 取物理屏尺寸,
|
||||
/// 不依赖 MaterialApp,启动时即可正确计算。
|
||||
/// ============================================================
|
||||
|
||||
/// ---------- 工具:全局适配配置(一处修改,全局生效) ----------
|
||||
class AppAdapt {
|
||||
AppAdapt._();
|
||||
|
||||
/// 平板判定断点(Material 规范:宽度 ≥ 600dp 视为平板/大屏)
|
||||
static const double tabletBreakpoint = 600;
|
||||
|
||||
/// 手机设计稿(按你的 UI 设计稿尺寸改)
|
||||
static const Size phoneDesign = Size(375, 812);
|
||||
|
||||
/// 字体最大放大倍数兜底(防横屏/极端宽屏字体失控)
|
||||
static const double maxFontScale = 1.2;
|
||||
|
||||
/// 方案 B 核心:根据屏幕尺寸选择设计稿
|
||||
///
|
||||
/// 手机:用 375x812,按宽度比例缩放;
|
||||
/// 平板:设计稿 = 设备自身逻辑尺寸 → scaleWidth 恒为 1,
|
||||
/// 横竖屏 UI 尺寸完全一致,不会旋转后变小/变大。
|
||||
static Size designSizeOf(BuildContext context) {
|
||||
final mq = MediaQuery.maybeOf(context) ??
|
||||
MediaQueryData.fromView(View.of(context));
|
||||
final size = mq.size;
|
||||
// 平板判定用"宽高都 ≥600":避免手机横屏(如 932x430)被误判成平板
|
||||
final isTablet =
|
||||
size.width >= tabletBreakpoint && size.height >= tabletBreakpoint;
|
||||
return isTablet ? size : phoneDesign;
|
||||
}
|
||||
|
||||
/// 方案 A 兜底:自定义字体缩放,放大倍数封顶 maxFontScale
|
||||
static double fontSizeResolver(num fontSize, ScreenUtil instance) {
|
||||
final scaled = fontSize * instance.scaleText;
|
||||
return min(scaled, fontSize * maxFontScale);
|
||||
}
|
||||
}
|
||||
|
||||
/// ---------- 入口模板(新项目直接 runApp 这个) ----------
|
||||
class ScreenAdaptApp extends StatelessWidget {
|
||||
const ScreenAdaptApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScreenUtilInit(
|
||||
designSize: AppAdapt.designSizeOf(context), // 方案 B:平板/手机自动切换
|
||||
minTextAdapt: true,
|
||||
fontSizeResolver: AppAdapt.fontSizeResolver, // 方案 A 兜底
|
||||
builder: (context, child) {
|
||||
return MaterialApp(
|
||||
title: '新项目',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: ThemeData(
|
||||
useMaterial3: true,
|
||||
colorSchemeSeed: const Color(0xFF3B82F6),
|
||||
scaffoldBackgroundColor: const Color(0xFFF5F7FA),
|
||||
),
|
||||
home: const _TemplateHome(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 模板首页:演示 .spMin / .w / .h / .r 的写法
|
||||
class _TemplateHome extends StatelessWidget {
|
||||
const _TemplateHome();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('适配模板')),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 关键:字号用 .spMin,平板上不会爆炸
|
||||
Text(
|
||||
'标题 24.spMin',
|
||||
style: TextStyle(fontSize: 24.spMin, fontWeight: FontWeight.bold),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
'正文 16.spMin',
|
||||
style: TextStyle(fontSize: 16.spMin, color: Colors.grey),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
Container(
|
||||
width: 200.w,
|
||||
height: 60.h,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF3B82F6),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'按钮 200.w x 60.h',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:isar_community/isar.dart';
|
||||
|
||||
part 'app_version_entity.g.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【RemoteVersionEntity】远端版本策略实体(Isar 集合 + API 响应模型)
|
||||
///
|
||||
/// 一、双重身份:
|
||||
/// 1. Isar @collection → 可持久化到本地数据库,离线时读取缓存策略;
|
||||
/// 2. fromJson 工厂 → 直接解析后端 GET /api/app/version 返回的 data。
|
||||
///
|
||||
/// 二、字段对照(后端 JSON → 本实体):
|
||||
/// latestVersion → 最新对外展示版本号(如 "1.1.0")
|
||||
/// latestBuild → 最新构建号(整型,用于版本比对)
|
||||
/// minSupportBuild → 最低兼容构建号(本地 build < 此值 → 强制更新)
|
||||
/// force → 是否强制更新(非最低兼容场景的开关)
|
||||
/// updateDesc → 更新日志列表
|
||||
/// androidUrl → 安卓下载地址
|
||||
/// iosUrl → iOS App Store 链接
|
||||
/// enableHotfix → 热更新开关(仅安卓生效,iOS 禁止下发 dart 代码)
|
||||
///
|
||||
/// 三、Isar 注意:
|
||||
/// - 改字段后必须重跑 build_runner 重新生成 Schema;
|
||||
/// - Isar 只在本文件被 native 端 import(条件导出),web 不会编译到。
|
||||
/// ============================================================
|
||||
@collection
|
||||
class RemoteVersionEntity {
|
||||
RemoteVersionEntity();
|
||||
|
||||
Id id = Isar.autoIncrement;
|
||||
|
||||
String? latestVersion;
|
||||
int? latestBuild;
|
||||
int? minSupportBuild;
|
||||
bool? force;
|
||||
|
||||
/// 更新日志(`List<String>` Isar 原生支持)
|
||||
List<String>? updateDesc;
|
||||
|
||||
String? androidUrl;
|
||||
String? iosUrl;
|
||||
bool? enableHotfix;
|
||||
|
||||
/// 从后端 JSON 解析(data 层,不含外层 code)
|
||||
factory RemoteVersionEntity.fromJson(Map<String, dynamic> json) {
|
||||
return RemoteVersionEntity()
|
||||
..latestVersion = json['latestVersion'] as String?
|
||||
..latestBuild = json['latestBuild'] as int?
|
||||
..minSupportBuild = json['minSupportBuild'] as int?
|
||||
..force = json['force'] as bool?
|
||||
..updateDesc = (json['updateDesc'] as List<dynamic>?)
|
||||
?.map((e) => e.toString())
|
||||
.toList()
|
||||
..androidUrl = json['androidUrl'] as String?
|
||||
..iosUrl = json['iosUrl'] as String?
|
||||
..enableHotfix = json['enableHotfix'] as bool?;
|
||||
}
|
||||
|
||||
/// 判断是否需要强制更新
|
||||
/// [localBuild] 本机构建号(package_info_plus 读取)
|
||||
bool needsForceUpdate(int localBuild) {
|
||||
final minBuild = minSupportBuild ?? 0;
|
||||
if (localBuild < minBuild) return true;
|
||||
if (localBuild < (latestBuild ?? 0) && (force ?? false)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 判断是否需要更新(含可选)
|
||||
bool needsUpdate(int localBuild) {
|
||||
return localBuild < (latestBuild ?? 0);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'RemoteVersion(latest=$latestVersion, build=$latestBuild, '
|
||||
'minBuild=$minSupportBuild, force=$force, hotfix=$enableHotfix)';
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
/// ============================================================
|
||||
/// 【AppVersionService + RemoteVersionEntity】版本服务 —— 平台分发入口
|
||||
///
|
||||
/// 用法:
|
||||
/// import 'package:new_learn/core/version/app_version_service.dart';
|
||||
/// final service = AppVersionService.instance;
|
||||
/// await service.init();
|
||||
/// final update = await service.checkForUpdate();
|
||||
///
|
||||
/// 一、为什么拆成条件导出(和 app_database.dart 同模式):
|
||||
/// native 实现 import 了 isar_community + package_info_plus,
|
||||
/// web 端没有 Isar 支持(编译不过),所以 web 用空实现。
|
||||
///
|
||||
/// 二、条件导出规则:
|
||||
/// native(dart.library.io 为真)→ app_version_service_io.dart
|
||||
/// web(dart.library.io 为假) → app_version_service_web.dart
|
||||
///
|
||||
/// 三、两个文件必须保持 API 一致(类名、方法签名完全相同),
|
||||
/// 否则共享代码在某个平台编译不过。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
export 'app_version_service_web.dart'
|
||||
if (dart.library.io) 'app_version_service_io.dart';
|
||||
@@ -0,0 +1,169 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:isar_community/isar.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
import 'package:new_learn/core/storage/database/app_database_io.dart';
|
||||
import 'app_version_entity.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AppVersionService】版本核心服务 —— native 真实现(Android/iOS/桌面)
|
||||
///
|
||||
/// 一、职责:
|
||||
/// 1. init():通过 package_info_plus 读取本机 versionName、buildNumber;
|
||||
/// 2. fetchRemoteVersion():请求后端 GET /api/app/version,解析并存入 Isar;
|
||||
/// 3. checkForUpdate():版本比对,返回 RemoteVersionEntity 或 null;
|
||||
/// 4. showUpdateDialogIfNeeded():在页面 mounted 后唤起更新弹窗。
|
||||
///
|
||||
/// 二、隐私约束:
|
||||
/// ⚠️ 严禁在隐私协议同意前调用 init() 或任何网络请求。
|
||||
/// 只有 privacyAcceptedNotifier.value == true 后才能使用本服务。
|
||||
///
|
||||
/// 三、缓存策略:
|
||||
/// 接口成功 → 解析 RemoteVersionEntity → Isar 写入缓存(覆盖旧数据);
|
||||
/// 网络异常 → 从 Isar 读取上次缓存策略做降级判断。
|
||||
///
|
||||
/// 四、版本比对规则(全程用 buildNumber 整型比较):
|
||||
/// localBuild < minSupportBuild → 强制更新
|
||||
/// localBuild < latestBuild && force == true → 强制更新
|
||||
/// localBuild < latestBuild && force == false → 可选更新
|
||||
/// localBuild >= latestBuild → 无需更新
|
||||
/// ============================================================
|
||||
class AppVersionService {
|
||||
AppVersionService._();
|
||||
static final AppVersionService instance = AppVersionService._();
|
||||
|
||||
/// 本机版本名(如 "1.0.0")
|
||||
String _versionName = '';
|
||||
String get versionName => _versionName;
|
||||
|
||||
/// 本机构建号(如 1)
|
||||
int _buildNumber = 0;
|
||||
int get buildNumber => _buildNumber;
|
||||
|
||||
/// 最近一次获取到的远端版本信息(检查后可读取)
|
||||
RemoteVersionEntity? _remoteVersion;
|
||||
RemoteVersionEntity? get remoteVersion => _remoteVersion;
|
||||
|
||||
/// ① 初始化:读取本机版本信息
|
||||
/// ⚠️ 必须在隐私协议同意后调用
|
||||
Future<void> init() async {
|
||||
try {
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
_versionName = info.version;
|
||||
_buildNumber = int.tryParse(info.buildNumber) ?? 0;
|
||||
debugPrint(
|
||||
'▶ 【版本】本机版本:$_versionName+$_buildNumber');
|
||||
} catch (e) {
|
||||
debugPrint('▶ 【版本】读取本机版本失败:$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// ② 请求后端版本接口
|
||||
/// 成功:解析并存入 Isar 缓存
|
||||
/// 失败:从 Isar 缓存读取上次策略
|
||||
Future<RemoteVersionEntity?> fetchRemoteVersion() async {
|
||||
try {
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// 🎯 Mock 开关:改成 true 用模拟数据,false 请求真实接口
|
||||
// 修改 _mockData 模拟不同场景,热重启即可
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// const bool mock = false;
|
||||
const bool mock = true; // ← 取消注释这行,注释掉上面那行,开启 Mock
|
||||
|
||||
final dynamic data;
|
||||
if (mock) {
|
||||
// ====== 🎯 修改这里模拟不同场景 ======
|
||||
// 当前 App 版本:pubspec.yaml → version: 1.0.0+1(buildNumber=1)
|
||||
//
|
||||
// 场景 A - 无需更新:latestBuild = 1
|
||||
// 场景 B - 可选更新:latestBuild = 3, force = false
|
||||
// 场景 C - 强制更新:latestBuild = 3, force = true
|
||||
// 场景 D - 最低兼容强制更新:minSupportBuild = 5
|
||||
data = {
|
||||
"latestVersion": "1.1.1",
|
||||
"latestBuild": 3,
|
||||
"minSupportBuild": 1,
|
||||
"force": false,
|
||||
"updateDesc": [
|
||||
"新增学习报告功能",
|
||||
"优化视频播放体验",
|
||||
"修复已知问题",
|
||||
],
|
||||
"androidUrl": "https://www.example.com/app-release.apk",
|
||||
"iosUrl": "https://apps.apple.com/cn/example",
|
||||
"enableHotfix": false,
|
||||
};
|
||||
// 模拟 500ms 网络延迟(和 Vue3 里 setTimeout 一个效果)
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
} else {
|
||||
data = await DioClient.instance.get('/api/app/version');
|
||||
}
|
||||
// 兼容 {code, data: {...}} 和直接返回 data 两种格式
|
||||
final payload =
|
||||
(data is Map<String, dynamic> && data.containsKey('data'))
|
||||
? data['data'] as Map<String, dynamic>
|
||||
: data as Map<String, dynamic>;
|
||||
final entity = RemoteVersionEntity.fromJson(payload);
|
||||
// 存入 Isar 缓存(先清旧数据再写新数据)
|
||||
await _saveToCache(entity);
|
||||
debugPrint('▶ 【版本】远端版本:$entity');
|
||||
return entity;
|
||||
} catch (e) {
|
||||
debugPrint('▶ 【版本】请求远端版本失败:$e,尝试读取本地缓存');
|
||||
return _loadFromCache();
|
||||
}
|
||||
}
|
||||
|
||||
/// ③ 版本比对,返回需要更新时的远端版本信息,无需更新返回 null
|
||||
Future<RemoteVersionEntity?> checkForUpdate() async {
|
||||
if (_buildNumber == 0) {
|
||||
// 本机版本未初始化,先 init
|
||||
await init();
|
||||
}
|
||||
final remote = await fetchRemoteVersion();
|
||||
_remoteVersion = remote;
|
||||
if (remote == null) return null;
|
||||
if (!remote.needsUpdate(_buildNumber)) {
|
||||
debugPrint('▶ 【版本】当前已是最新版本');
|
||||
return null;
|
||||
}
|
||||
return remote;
|
||||
}
|
||||
|
||||
/// 判断当前远端版本是否要求强制更新
|
||||
bool isForceUpdate() {
|
||||
final remote = _remoteVersion;
|
||||
if (remote == null) return false;
|
||||
return remote.needsForceUpdate(_buildNumber);
|
||||
}
|
||||
|
||||
/// 存入 Isar 缓存(覆盖旧数据)
|
||||
Future<void> _saveToCache(RemoteVersionEntity entity) async {
|
||||
try {
|
||||
final db = AppDatabase.instance;
|
||||
await db.isar.writeTxn(() async {
|
||||
// 清除旧缓存(只保留一条最新策略)
|
||||
await db.remoteVersions.clear();
|
||||
await db.remoteVersions.put(entity);
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('▶ 【版本】缓存远端版本失败:$e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Isar 缓存读取
|
||||
Future<RemoteVersionEntity?> _loadFromCache() async {
|
||||
try {
|
||||
final db = AppDatabase.instance;
|
||||
final all = await db.remoteVersions.where().findAll();
|
||||
final cached = all.isNotEmpty ? all.first : null;
|
||||
if (cached != null) {
|
||||
debugPrint('▶ 【版本】使用缓存策略:$cached');
|
||||
}
|
||||
return cached;
|
||||
} catch (e) {
|
||||
debugPrint('▶ 【版本】读取缓存失败:$e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:new_learn/core/network/dio_client.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AppVersionService】版本核心服务 —— web 空实现(Chrome/Edge 调试用)
|
||||
///
|
||||
/// web 端没有 package_info_plus(返回固定值)和 Isar(不支持缓存),
|
||||
/// 只做基础网络请求,无缓存降级。
|
||||
/// 生产走 Android/iOS,本文件仅供 Web 调试 UI 流程。
|
||||
///
|
||||
/// ⚠ 注意:本文件不 import isar 或 package_info_plus 的原生能力,
|
||||
/// web 端编译不会报错。
|
||||
/// ============================================================
|
||||
|
||||
/// 远端版本实体(web 简化版,非 Isar 集合)
|
||||
class RemoteVersionEntity {
|
||||
RemoteVersionEntity();
|
||||
|
||||
String? latestVersion;
|
||||
int? latestBuild;
|
||||
int? minSupportBuild;
|
||||
bool? force;
|
||||
List<String>? updateDesc;
|
||||
String? androidUrl;
|
||||
String? iosUrl;
|
||||
bool? enableHotfix;
|
||||
|
||||
factory RemoteVersionEntity.fromJson(Map<String, dynamic> json) {
|
||||
return RemoteVersionEntity()
|
||||
..latestVersion = json['latestVersion'] as String?
|
||||
..latestBuild = json['latestBuild'] as int?
|
||||
..minSupportBuild = json['minSupportBuild'] as int?
|
||||
..force = json['force'] as bool?
|
||||
..updateDesc = (json['updateDesc'] as List<dynamic>?)
|
||||
?.map((e) => e.toString())
|
||||
.toList()
|
||||
..androidUrl = json['androidUrl'] as String?
|
||||
..iosUrl = json['iosUrl'] as String?
|
||||
..enableHotfix = json['enableHotfix'] as bool?;
|
||||
}
|
||||
|
||||
bool needsForceUpdate(int localBuild) {
|
||||
final minBuild = minSupportBuild ?? 0;
|
||||
if (localBuild < minBuild) return true;
|
||||
if (localBuild < (latestBuild ?? 0) && (force ?? false)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool needsUpdate(int localBuild) {
|
||||
return localBuild < (latestBuild ?? 0);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'RemoteVersion(latest=$latestVersion, build=$latestBuild, '
|
||||
'minBuild=$minSupportBuild, force=$force)';
|
||||
}
|
||||
|
||||
class AppVersionService {
|
||||
AppVersionService._();
|
||||
static final AppVersionService instance = AppVersionService._();
|
||||
|
||||
String _versionName = '0.0.0';
|
||||
String get versionName => _versionName;
|
||||
|
||||
int _buildNumber = 0;
|
||||
int get buildNumber => _buildNumber;
|
||||
|
||||
RemoteVersionEntity? _remoteVersion;
|
||||
RemoteVersionEntity? get remoteVersion => _remoteVersion;
|
||||
|
||||
/// web 端:package_info_plus 不可用,使用固定值
|
||||
Future<void> init() async {
|
||||
_versionName = '0.0.0';
|
||||
_buildNumber = 0;
|
||||
debugPrint('▶ 【版本】Web 调试模式:版本信息使用固定值');
|
||||
}
|
||||
|
||||
/// web 端:无 Isar 缓存,网络失败直接返回 null
|
||||
Future<RemoteVersionEntity?> fetchRemoteVersion() async {
|
||||
try {
|
||||
final data = await DioClient.instance.get('/api/app/version');
|
||||
final payload =
|
||||
(data is Map<String, dynamic> && data.containsKey('data'))
|
||||
? data['data'] as Map<String, dynamic>
|
||||
: data as Map<String, dynamic>;
|
||||
final entity = RemoteVersionEntity.fromJson(payload);
|
||||
debugPrint('▶ 【版本】远端版本:$entity');
|
||||
return entity;
|
||||
} catch (e) {
|
||||
debugPrint('▶ 【版本】Web 请求远端版本失败:$e(无缓存降级)');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<RemoteVersionEntity?> checkForUpdate() async {
|
||||
if (_buildNumber == 0) await init();
|
||||
final remote = await fetchRemoteVersion();
|
||||
_remoteVersion = remote;
|
||||
if (remote == null) return null;
|
||||
if (!remote.needsUpdate(_buildNumber)) return null;
|
||||
return remote;
|
||||
}
|
||||
|
||||
bool isForceUpdate() {
|
||||
final remote = _remoteVersion;
|
||||
if (remote == null) return false;
|
||||
return remote.needsForceUpdate(_buildNumber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/// ============================================================
|
||||
/// 【RemoteVersionEntity】远端版本实体 —— 平台分发入口
|
||||
///
|
||||
/// native → app_version_entity.dart(Isar @collection)
|
||||
/// web → app_version_service_web.dart(普通类,内联在 service web 文件中)
|
||||
///
|
||||
/// 和 app_version_service.dart 同模式:条件导出,调用方只 import 这一个文件。
|
||||
/// ============================================================
|
||||
library;
|
||||
|
||||
export 'app_version_service_web.dart'
|
||||
// web 文件里定义了 RemoteVersionEntity 类,web 端直接用它
|
||||
if (dart.library.io) 'app_version_entity.dart';
|
||||
Reference in New Issue
Block a user