flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+96
View File
@@ -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();
}
}
+85
View File
@@ -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;
}
+117
View File
@@ -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>);
}
}
+307
View File
@@ -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;
}
+30
View File
@@ -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>);
}
}
+62
View File
@@ -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,
);
}
}
+189
View File
@@ -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;
}
+157
View File
@@ -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 ? '立即更新' : '立即更新'),
),
],
);
}
}
+479
View File
@@ -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)),
],
),
),
);
}
}
+330
View File
@@ -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(),
),
),
],
);
},
);
}
}
+132
View File
@@ -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: (_, _) {},
);
}
}
+134
View File
@@ -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,
),
),
),
);
}
}
+256
View File
@@ -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!);
}
}
+90
View File
@@ -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 的注释示例)
};
+49
View File
@@ -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;
}
+63
View File
@@ -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';
}
+72
View File
@@ -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),
),
),
),
],
);
},
);
}
}
+44
View File
@@ -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};
}
}
+168
View File
@@ -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;
}
+19
View File
@@ -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;
}
+313
View File
@@ -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;
}
+109
View File
@@ -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>);
}
}
+121
View File
@@ -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);
}
+76
View File
@@ -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;
}
}
+45
View File
@@ -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>);
}
}
+80
View File
@@ -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,
);
+15
View File
@@ -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);
+105
View File
@@ -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,
);
+135
View File
@@ -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;
}
+162
View File
@@ -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 => '班级',
_ => '我的',
},
),
],
),
);
}
}
+6
View File
@@ -0,0 +1,6 @@
import 'package:flutter/widgets.dart';
/// 全局根导航 key:
/// 1. 给 GoRouter 作为根 Navigator 使用;
/// 2. 供启动隐私 Gate 这类"路由外层"页面直接打开子页面。
final GlobalKey<NavigatorState> rootNavigatorKey = GlobalKey<NavigatorState>();
+340
View File
@@ -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);
});
}
}
+370
View File
@@ -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;
}
}
+225
View File
@@ -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');
});
}
}
+24
View File
@@ -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; // 最近播放时间
}
+852
View File
@@ -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');
});
}
}
+40
View File
@@ -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');
});
}
}
+34
View File
@@ -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 [];
}
+127
View File
@@ -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();
}
}
+83
View File
@@ -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];
+34
View File
@@ -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: '答题结果',
),
},
);
+34
View File
@@ -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: '答题结果',
),
},
);
+27
View File
@@ -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';
}
}
+132
View File
@@ -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),
),
),
],
),
),
);
}
}
+77
View File
@@ -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
+24
View File
@@ -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';