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';
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
/// ============================================================
/// 【LegalDocumentPage】通用协议正文页
///
/// 作用:
/// 统一承接《用户协议》《隐私政策》这类长文本页面。
/// 两份协议只换 title/content,不再重复写两套滚动布局。
/// ============================================================
class LegalDocumentPage extends StatelessWidget {
const LegalDocumentPage({
super.key,
required this.title,
required this.content,
});
final String title;
final String content;
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F7FA),
appBar: AppBar(
title: Text(title),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: SelectableText(
content,
style: const TextStyle(
fontSize: 14,
height: 1.8,
color: Color(0xFF323233),
),
),
),
),
),
);
}
}
+189
View File
@@ -0,0 +1,189 @@
// 旧 UniApp 项目的《用户协议》《隐私政策》内容迁移到 Flutter。
// 先落成常量,后续如果要改成远端配置或 assets/json,再平滑替换即可。
const String kUserAgreementTitle = '爱学蝶变·用户协议';
const String kUserAgreementContent = '''
生效日期:2026年06月01日
欢迎使用爱学蝶变平台及对应服务!本用户协议(以下简称「本协议」)是您与我方之间,关于您使用本软件、平台及相关在线教育服务的有效法律约定。您注册账号、开启并持续使用本服务,即代表您已认真阅读、充分理解并自愿接受本协议全部条款,愿意受本协议约束。
一、服务说明
爱学蝶变旨在为用户提供在线学习、课程学习、作业练习、学习记录管理等教育服务。我方有权基于业务发展、服务优化、系统维护、合规要求,在提前公示的前提下,调整、优化、更新服务内容与服务形式,保障服务稳定、优质运行。
二、账号注册与使用规范
1. 您可通过合规方式注册平台账号,完整使用平台学习服务。
2. 您承诺在注册及使用过程中,提供真实、准确、完整的账号信息,不得伪造、冒用他人信息注册账号。
3. 您需自行妥善保管账号、密码及登录凭证,对账号下所有操作、行为及产生的结果自行负责。因个人保管不善、转借、共用账号导致的损失,由用户自行承担。
4. 严禁恶意注册、批量注册、盗用他人账号等违规行为,我方有权对违规账号进行限制、冻结或注销处理。
三、用户行为规范
您在使用本平台服务时,承诺遵守国家法律法规、社会公序良俗及本平台规则,不得实施以下行为:
1. 利用本平台发布、传播违法、违规、低俗、侵权、虚假及损害公共利益的内容;
2. 破解、篡改、逆向工程、非法侵入平台系统,干扰、破坏平台正常运行;
3. 使用外挂、脚本、作弊工具干扰学习秩序、恶意刷取数据;
4. 以任何方式侵害平台及其他用户合法权益的行为。
若您存在上述违规行为,我方有权暂停、终止您的服务权限,并保留依法追责的权利。
四、知识产权保护
平台内所有课程内容、文字、图片、音视频、界面设计、程序代码、功能模块等全部知识产权,均归我方或相关合法权利人所有。未经我方书面授权,任何个人或机构不得擅自复制、转载、传播、篡改、商用、二次分发,违者我方将依法追究其法律责任。
用户在平台上传、发布的内容,用户享有合法权益,同时授予平台合理使用、展示、存储该内容的权限,用于服务正常运行。
五、免责声明
1. 我方将秉持审慎原则维护服务稳定,但因网络故障、系统维护、不可抗力、第三方服务异常等非我方可控因素导致的服务中断、卡顿、数据临时异常,我方不承担赔偿责任,但将尽力修复并降低用户损失。
2. 平台学习内容仅供用户学习参考,不构成任何专业指导、升学指导、权威定论建议,用户需自主判断、审慎使用。
3. 因用户自身违规操作、设备故障、网络问题、账号保管不当引发的一切损失,由用户自行承担。
六、协议修改与生效
我方有权根据业务发展、法律法规更新,适时修订本服务协议。修订后的协议将在平台内公示,公示后用户继续使用服务的,视为自愿接受更新后的协议条款。
七、其他条款
1. 本协议未尽事宜,我方有权制定补充规则,补充规则与本协议具有同等法律效力。
2. 若本协议部分条款因法定原因被认定为无效,不影响其余条款的法律效力。
3. 本协议的解释、执行与争议解决均适用中华人民共和国法律。
联系我们
运营主体:北京爱学蝶变人工智能科技有限公司
客服邮箱:aixuediebian@163.com
''';
const String kPrivacyPolicyTitle = '爱学蝶变・隐私政策';
const String kPrivacyPolicyContent = '''
生效日期:2026 年 06 月 01 日
欢迎使用爱学蝶变!北京爱学蝶变人工智能科技有限公司高度重视您的个人信息和隐私安全。本隐私政策旨在向您说明,在您使用爱学蝶变应用及相关服务时,我们如何收集、使用、存储、共享和保护您的个人信息,以及您依法享有的相关权利。
在使用爱学蝶变服务前,请您仔细阅读并理解本隐私政策。您可以选择同意或不同意本政策。您不同意本政策时,可能无法使用需要处理相关个人信息的功能,但不影响您使用与该信息处理无关的其他基础功能。
一、我们收集和处理的个人信息
为向您提供拍照上传、相册图片上传、视频播放和攻克海报分享等功能,我们仅在实现具体功能所必需的范围内处理您的个人信息,主要包括以下信息:
1. 图片信息
当您主动使用拍照上传功能时,我们会调用设备相机,处理您主动拍摄的图片,用于作业提交、学习资料上传及其他相关学习服务。
当您主动使用相册选择功能时,我们会申请相册或照片访问权限,处理您主动选择的图片,用于作业图片、学习资料及其他相关内容的上传。未经您的主动操作,我们不会调用相机拍照,也不会读取或上传您未选择的图片。
2. 设备与系统信息
为保障应用正常运行、识别异常设备、保障应用和服务安全,以及实现攻克海报分享功能,我们可能通过应用自身代码、uni-app 相关功能模块或其底层系统接口处理必要的设备与系统信息,包括设备型号、操作系统版本、应用版本、网络状态、OAID 等信息。
仅在您主动发起攻克海报分享操作场景下,会处理 MAC 地址等设备信息,以及用于识别分享目标应用是否安装的已安装应用信息(应用名称、应用包名),用于本地判断微信等目标应用是否安装、完成攻克海报分享唤起和分享链路校验;非分享场景不会读取应用安装列表。
上述信息主要用于设备识别、异常设备风险识别、应用运行安全、识别微信等目标应用是否安装、完成分享唤起和分享链路校验。我们不会将上述信息用于与应用运行安全和攻克海报分享无关的广告推荐、营销或用户画像。我们仅在实现相关功能所必需的范围内处理上述信息,具体信息类型以当前版本实际集成的组件及其实际运行情况为准。
3. 视频播放及学习使用信息
当您播放视频课程时,本应用会使用系统或应用内置的视频播放能力加载和播放视频,并处理视频播放记录、播放进度、播放日志、课程学习进度、设备型号、操作系统版本、应用版本及网络状态等必要信息,用于播放视频、同步学习进度、排查播放故障及优化视频播放体验。
4. 网络及应用运行信息
在您使用图片上传、攻克海报分享、视频播放及其他服务时,我们可能处理网络连接状态、应用版本、设备运行环境、操作日志和故障日志等信息,用于连接服务器、上传图片、加载视频资源、同步服务数据、保障应用稳定运行及排查技术故障。
二、系统权限调用说明
我们仅在您主动使用对应功能或确有必要时申请相关系统权限。您可以在手机系统设置中关闭相关权限。关闭权限后,相关功能可能无法使用,但不影响与该权限无关的其他基础功能。
1. 相机权限
当您主动使用拍照上传功能时申请,用于拍摄作业图片或其他学习资料。未经您的主动操作,我们不会调用相机进行拍照或录像。
2. 相册及照片访问权限
当您主动使用相册选择功能时申请,用于读取您选择的图片并完成上传。我们不会无差别读取或上传您未选择的图片。
3. 网络权限
用于连接服务器、上传图片、加载视频课程、同步学习进度、完成攻克海报分享及保障服务正常运行。
三、第三方组件及相关功能说明
为实现攻克海报分享等功能,本应用可能使用第三方组件或相关能力。我们会根据您使用的具体功能调用相关组件。第三方组件可能按照其自身隐私政策处理必要的信息。我们会要求相关第三方在实现服务所必需的范围内处理个人信息,并督促其遵守适用的法律法规。
1. uni-app 微信分享功能
功能名称:uni-app 微信分享功能
功能提供方式:通过 uni-app 分享 API 及其底层微信分享组件实现。
使用目的:支持您将攻克海报分享至微信,识别微信是否安装并完成分享唤起和分享链路校验。
使用场景:仅在您主动使用攻克海报的微信分享功能时调用。除攻克海报分享外,本应用不会在其他业务场景调用该分享功能。
处理的信息:OAID、设备型号、操作系统版本、应用版本、MAC 地址,以及用于识别微信是否安装的已安装应用信息(应用名称、应用包名)。
处理方式:通过 uni-app 分享模块、底层微信分享组件及 Android 系统接口获取相关信息。
处理范围:仅限于识别微信目标应用、完成攻克海报分享唤起、分享链路校验及保障分享功能稳定运行所必需的范围。
第三方开发者:以当前版本实际集成的底层微信分享组件信息为准。
第三方隐私政策链接:以当前版本实际集成的底层微信分享组件对应隐私政策链接为准。
2. 移动智能终端补充设备标识体系统一调用 SDK
SDK 开发者:中国信息通信研究院
使用目的:获取 OAID,用于设备标识识别、应用安全及异常风险识别。
使用场景:应用运行过程中,在实现设备识别和服务安全所必需的范围内调用。
处理的信息:OAID。
处理方式:通过 SDK 接口调用获取相关信息。
SDK 隐私政策链接:https://www.msa-alliance.cn/col.jsp?id=122
四、个人信息的使用
我们收集和处理的个人信息仅用于以下目的:
1. 提供拍照上传、相册图片上传、视频课程播放和攻克海报分享等核心功能;
2. 上传和保存您主动选择的图片,完成作业提交及学习资料管理;
3. 加载视频课程、同步视频播放进度和学习记录;
4. 识别异常设备、保障应用和服务安全、防范恶意程序及违规使用行为;
5. 分析应用运行情况、排查故障、优化视频播放和服务体验;
6. 遵守法律法规、履行法定义务及处理监管要求。
除非取得您的单独同意或法律法规另有规定,我们不会将您的个人信息用于与上述目的无关的用途。
五、个人信息的共享与披露
我们坚持非必要不共享原则,不出售、出租或交易您的个人信息。
1. 为实现攻克海报微信分享功能,相关第三方组件可能在实现对应功能所必需的范围内处理必要的设备信息、已安装应用信息及运行信息,具体处理方式以对应第三方的隐私政策为准。
2. 除上述情况外,未经您明确同意,我们不会向其他第三方共享或披露您的个人信息,但法律法规另有规定、司法机关或行政机关依法要求提供,或为保护公共安全、用户及平台合法权益所必需的情形除外。
3. 如我们委托第三方服务商协助提供技术服务,我们会通过合同、数据处理要求或其他管理措施,要求其按照本政策及适用法律法规处理您的个人信息,不得将信息用于其他目的。
六、个人信息的存储与保护
1. 我们会采取 HTTPS 加密传输、访问权限控制、加密存储、身份认证、安全审计和数据备份等合理的安全措施,保护您的个人信息免遭未经授权的访问、使用、泄露、修改或损毁。
2. 我们仅在实现本政策所述目的所必需的期限内保存您的个人信息。超过保存期限后,我们会按照法律法规要求删除或匿名化处理相关信息。
3. 在发生个人信息安全事件时,我们将按照法律法规要求采取补救措施,并在必要时向您告知事件的基本情况及可能采取的措施。
七、您的个人信息权利
在法律法规规定的范围内,您依法享有以下权利:
1. 查阅、复制您的个人信息;
2. 更正或补充不准确、不完整的个人信息;
3. 删除您的个人信息;
4. 撤回已授予的权限或同意;
5. 注销您的账号;
6. 对个人信息处理提出疑问、投诉或意见。
您可以通过应用内相关功能管理或修改个人信息,也可以通过本政策 “联系我们” 中的方式向我们提出申请。您可以在手机系统设置中关闭相机、相册等相关权限。关闭权限后,相关功能可能无法使用,但不会影响与该权限无关的其他服务。
如您申请注销账号,我们将在完成身份核验后按照法律法规及平台规则处理您的账号和相关个人信息。法律法规要求继续保存的信息,我们将在保存期限届满后删除或匿名化处理。
八、未成年人个人信息保护
我们高度重视未成年人个人信息保护。未成年人应在监护人的陪同、指导和同意下阅读本政策并使用本服务。监护人如对未成年人的个人信息处理存在疑问,或发现未成年人未经监护人同意使用本服务并提交个人信息,可以通过本政策中的联系方式与我们联系。经核实后,我们将按照法律法规要求处理相关信息。
九、隐私政策的更新
我们可能根据业务变化、功能升级或法律法规更新对本隐私政策进行修订。隐私政策更新后,我们会在应用内或其他适当位置进行展示,并在必要时以弹窗等方式向您提示。对于重大变更,我们会按照法律法规要求征得您的同意。
十、联系我们
如您对本隐私政策、个人信息处理或隐私保护事项存在疑问、建议或申诉,可以通过以下方式联系我们:
运营主体:北京爱学蝶变人工智能科技有限公司
客服邮箱:aixuediebian@163.com
我们将在收到您的请求后,在法律法规规定的期限内进行处理和答复。
''';
+529
View File
@@ -0,0 +1,529 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/storage/user_storage.dart';
import 'package:new_learn/core/api/auth/api.dart';
/// ============================================================
/// 【LoginPage】登录页 —— 对照 Vue 版 pages/index.vue 移植
///
/// 一、布局结构(自上而下):
/// 1. 渐变头部 + 欢迎语(原 Vue 是背景图 + welcome 图)
/// 2. 账号输入框(圆角灰底 + 左侧图标 + 右侧历史下拉箭头)
/// 3. 密码输入框(左侧图标 + 右侧"眼睛"显隐切换)
/// 4. 记住密码 / 忘记密码
/// 5. 立即登录按钮(loading 态防重复提交)
/// 6. 协议勾选 + 《用户协议》《隐私政策》入口
///
/// 二、登录流程(对齐 Vue logInNow):
/// 校验账号/密码 → 校验协议 → 调 UserApi.login(密码 MD5 加密)
/// → 成功:
/// ① token 写进 UserStorage(安全存储)+ DioClient.accessToken(内存,拦截器自动带)
/// ② UserApi.getCurrentUserExtension 拉全量用户信息 → 缓存到 UserStorage
/// → 失败:ApiException.message 直接 Toast,永不接触 Dio 细节
///
/// 三、与 Vue 的对应关系:
/// uni.showToast → ScaffoldMessenger SnackBar
/// formLogin (ref 对象) → _accountController/_passwordController
/// stringToMd5(password) → UserApi.login 内部已处理(md5)
/// app.setToken / storage → UserStorage.saveToken(flutter_secure_storage)
/// app.fetchCurrentUserExtension → UserApi.getCurrentUserExtension
/// 用户协议/隐私政策 → 空方法(占位,本页先不做跳转)
///
/// 四、高频坑:
/// 1. TextEditingController 必须在 dispose 里释放(防内存泄漏);
/// 2. 密码框 obscureText 切换要 setState 重建,否则不生效;
/// 3. 登录按钮 loading 时禁用,防止连点发多个请求;
/// 4. 页面里用了 .w/.h/.sp/.r 就不能加 const(运行期才知道尺寸)。
/// ============================================================
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
// ---------- 表单控制器(dispose 里必须释放) ----------
final TextEditingController _accountController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
bool _obscurePassword = true; // 密码是否打码
bool _rememberPassword = true; // 记住密码
bool _hasAgreed = false; // 已同意协议
bool _logging = false; // 登录请求中(按钮 loading)
/// 登录历史(对齐 Vue loginHistory,本版只存内存,不落盘)
final List<String> _loginHistory = [];
@override
void initState() {
super.initState();
// 回填"记住密码"的账号密码(异步读取,不在 initState 里 await/用 context)
_loadRemembered();
}
/// 启动时回填记住的账号密码(密码存在系统安全存储,勾选状态一并恢复)
Future<void> _loadRemembered() async {
final account = await UserStorage.instance.readRememberedAccount();
final pwd = await UserStorage.instance.readRememberedPassword();
if (!mounted) return; // 页面已销毁就停止 setState(防泄漏)
setState(() {
if (account != null) _accountController.text = account;
if (pwd != null) {
_passwordController.text = pwd;
_rememberPassword = true;
}
});
}
@override
void dispose() {
// 内存泄漏红线:控制器必须释放
_accountController.dispose();
_passwordController.dispose();
super.dispose();
}
// ==================== 空方法占位(后续再实现) ====================
/// 《用户协议》跳转(Vue: uni.navigateTo /pages/login/userAgreement)
void _goToAgreement() {
context.push('/user-agreement');
}
/// 《隐私政策》跳转(Vue: uni.navigateTo /pages/login/privacyPolicy)
void _goToPrivacy() {
context.push('/privacy-policy');
}
/// 忘记密码(Vue: 弹窗提示"请联系学习官找回密码")
void _forgetPassword() {
_toast('请联系学习官找回密码');
}
// ==================== 登录逻辑 ====================
/// 点击「立即登录」→ 校验 → 调接口
Future<void> _handleLogin() async {
if (_logging) return; // 防连点
final account = _accountController.text.trim();
final password = _passwordController.text;
// ① 基础校验(对齐 Vue logInNow 的顺序)
if (account.isEmpty) {
_toast('请填写账号');
return;
}
if (password.isEmpty) {
_toast('请填写密码');
return;
}
if (!_hasAgreed) {
_toast('请先阅读并同意《用户协议》和《隐私政策》');
return;
}
// ② 调接口(loading 态)
setState(() => _logging = true);
try {
final res = await UserApi.login(account: account, password: password);
// ③ 成功:Token 进安全存储 + 内存(拦截器自动带 Authorization)
// 防御:token 为空 = 登录响应字段解析失败(比如后端把 token 包在
// data 里,LoginResponse.fromJson 读的是顶层 json['token'])。
// 此时不落盘不赋值,直接提示,避免发出不带 token 的请求。
if (res.token.isEmpty) {
if (mounted) {
_toast('登录响应缺少 token 字段,请检查后端返回结构');
}
return;
}
await UserStorage.instance.saveToken(res.token);
DioClient.instance.accessToken = res.token;
// ④ 登录后拉取完整用户信息并缓存(对齐 Vue: app.fetchCurrentUserExtension)。
//
// 为什么登录接口成功后,还要再调一次"获取当前用户信息"接口:
// - /api/v1/User/Login 主要负责鉴权,核心产物是 token;
// - 真正给"我的"页、任务页头部使用的用户资料(头像、班级、积分等),
// 应该以 /api/v1/User/GetCurrentUserExtension 为准;
// - 这样后续页面只读 currentUserProvider,不需要每个页面自己再请求一次。
//
// forceRefresh: true 的作用:
// - 登录刚成功时,必须拿一份服务器最新用户资料;
// - 不能继续沿用上次缓存,否则可能出现头像、积分、班级等信息还是旧的。
//
// getCurrentUser(...) 内部职责:
// - 请求最新用户资料;
// - 成功后写入本地缓存(SharedPreferences);
// - 如果网络失败,会优先回退到旧缓存,尽量不打断登录后的进入流程。
try {
final user = await UserApi.getCurrentUser(forceRefresh: true);
// ⑤ Riverpod 全局状态:把"完整用户信息"放进 currentUserProvider。
//
// 这一步完成后:
// - 全 App 里所有 ref.watch(currentUserProvider) 的页面,
// 都能直接拿到当前用户;
// - "我的"页不需要再请求一次用户信息接口;
// - 后续如果头像局部更新,也是在这份全局状态上做 copyWith 即可。
//
// 这里的 provider 负责"运行中的内存状态";
// UserStorage.saveUserInfo(...) 负责"重启后还能恢复的本地缓存";
// 两者配合起来,才是完整的用户信息链路。
ref.read(currentUserProvider.notifier).set(user);
} catch (_) {
debugPrint('⚠ 【用户信息】拉取失败,已跳过缓存');
}
// ⑤ 记住密码持久化(勾选才存:账号走 prefs,密码走安全存储)
if (_rememberPassword) {
await UserStorage.instance.saveRememberedAccount(account);
await UserStorage.instance.saveRememberedPassword(password);
} else {
await UserStorage.instance.clearRememberedAccount();
await UserStorage.instance.clearRememberedPassword();
}
// 记录登录历史(内存)
_loginHistory
..remove(account)
..insert(0, account);
if (mounted) {
// 登录成功 → context.go('/') 替换为 4 tab 主框架(默认第 1 个"任务"页)。
// go 是"替换不留历史":按返回键不会退回登录页。
// 响应式守卫双保险:上面 set(user) 时 authTick++ 会触发守卫重算,
// 已登录访问 /login → 自动踢回 '/'。这里 go('/') 显式跳转,
// 两者目标一致、不冲突;就算哪天去掉守卫,登录后也能正常进主页。
// 先取根 messenger,跳转后 SnackBar 挂在新页面 Scaffold 上正常显示。
final messenger = ScaffoldMessenger.of(context);
context.go('/');
messenger.showSnackBar(
SnackBar(
content: Text('登录成功,欢迎 ${res.info.userName.isEmpty ? account : res.info.userName}'),
duration: const Duration(seconds: 2),
),
);
}
} on ApiException catch (e) {
// ④ 失败:只 catch 业务异常,直接展示中文提示
if (mounted) _toast(e.message);
} catch (e) {
// 兜底:解析失败等意外
if (mounted) _toast('登录失败,请稍后重试');
} finally {
if (mounted) setState(() => _logging = false);
}
}
/// 统一 Toast(对应 Vue uni.showToast,本版用 SnackBar)
void _toast(String msg) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(msg), duration: const Duration(seconds: 2)));
}
// ==================== UI ====================
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.symmetric(horizontal: 25.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ---------- 顶部欢迎区 ----------
SizedBox(height: 30.h),
_buildWelcome(),
SizedBox(height: 40.h),
// ---------- 账号输入框 ----------
_buildAccountField(),
SizedBox(height: 15.h),
// ---------- 密码输入框 ----------
_buildPasswordField(),
SizedBox(height: 15.h),
// ---------- 记住密码 / 忘记密码 ----------
_buildTipsRow(),
SizedBox(height: 30.h),
// ---------- 立即登录按钮 ----------
_buildLoginButton(),
SizedBox(height: 25.h),
// ---------- 协议区 ----------
_buildAgreementRow(),
SizedBox(height: 20.h),
],
),
),
),
);
}
/// 欢迎区:品牌名 + 欢迎语(Vue 里是欢迎图)
Widget _buildWelcome() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.school_rounded, size: 32.w, color: const Color(0xFF3B82F6)),
SizedBox(height: 12.h),
Text(
'爱学蝶变',
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w700,
color: const Color(0xFF323233),
),
),
SizedBox(height: 6.h),
Text(
'11欢迎回来,请登录后继续学习',
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)),
),
],
);
}
/// 账号输入框(灰底圆角 + 左侧图标 + 右侧历史箭头)
Widget _buildAccountField() {
return Container(
height: 48.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: const Color(0xFFF9F9F9),
borderRadius: BorderRadius.circular(24.r),
),
child: Row(
children: [
const Icon(Icons.person_outline_rounded, color: Color(0xFF999999)),
SizedBox(width: 10.w),
Expanded(
child: TextField(
controller: _accountController,
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)),
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: '请输入账号',
hintStyle: TextStyle(fontSize: 13.sp, color: const Color(0xFFC0C4CC)),
),
),
),
// 历史下拉箭头(有历史才显示,点击下拉选择)
if (_loginHistory.isNotEmpty)
GestureDetector(
onTap: () => _showHistorySheet(),
child: const Icon(Icons.keyboard_arrow_down, color: Color(0xFFC0C4CC)),
),
],
),
);
}
/// 密码输入框(灰底圆角 + 左侧图标 + 右侧眼睛)
Widget _buildPasswordField() {
return Container(
height: 48.h,
padding: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: const Color(0xFFF9F9F9),
borderRadius: BorderRadius.circular(24.r),
),
child: Row(
children: [
const Icon(Icons.lock_outline_rounded, color: Color(0xFF999999)),
SizedBox(width: 10.w),
Expanded(
child: TextField(
controller: _passwordController,
obscureText: _obscurePassword,
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)),
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: '请输入密码',
hintStyle: TextStyle(fontSize: 13.sp, color: const Color(0xFFC0C4CC)),
),
),
),
// 密码显隐切换(setState 重建才生效)
GestureDetector(
onTap: () => setState(() => _obscurePassword = !_obscurePassword),
child: Icon(
_obscurePassword ? Icons.visibility_off_outlined : Icons.visibility_outlined,
color: const Color(0xFFC0C4CC),
),
),
],
),
);
}
/// 记住密码(勾选) + 忘记密码
/// 注意(测试抓到的真实 bug):375 宽下两组文字合计 277px > 可用 275px,
/// 会 Row overflow 3px。修复:左边 Expanded 占满 + 间距收窄到 8.w +
/// Flexible 兜底(更窄屏 ellipsis 截断,绝不溢出)。
Widget _buildTipsRow() {
return Row(
children: [
Expanded(
child: InkWell(
onTap: () => setState(() => _rememberPassword = !_rememberPassword),
child: Row(
children: [
Icon(
_rememberPassword
? Icons.check_circle_rounded
: Icons.radio_button_unchecked,
size: 15.w,
color: _rememberPassword
? const Color(0xFF3B82F6)
: const Color(0xFFC0C4CC),
),
SizedBox(width: 4.w),
Flexible(
child: Text(
'记住密码',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF323233)),
),
),
],
),
),
),
InkWell(
onTap: _forgetPassword,
child: Text(
'忘记密码?',
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF3B82F6)),
),
),
],
);
}
/// 立即登录按钮(loading 时禁用)
Widget _buildLoginButton() {
return SizedBox(
width: double.infinity,
height: 48.h,
child: FilledButton(
onPressed: _logging ? null : _handleLogin,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF3B82F6),
disabledBackgroundColor: const Color(0xFF3B82F6).withValues(alpha: 0.6),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24.r)),
),
child: _logging
? SizedBox(
width: 18.w,
height: 18.w,
child: const CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
),
)
: Text(
'立即登录',
style: TextStyle(fontSize: 16.sp, color: Colors.white),
),
),
);
}
/// 协议区:勾选同意 + 《用户协议》《隐私政策》
Widget _buildAgreementRow() {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
InkWell(
onTap: () => setState(() => _hasAgreed = !_hasAgreed),
child: Icon(
_hasAgreed ? Icons.check_circle_rounded : Icons.radio_button_unchecked,
size: 15.w,
color: _hasAgreed ? const Color(0xFF3B82F6) : const Color(0xFFC0C4CC),
),
),
SizedBox(width: 6.w),
Flexible(
child: RichText(
text: TextSpan(
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF323233)),
children: [
const TextSpan(text: '我已阅读并同意'),
TextSpan(
text: '《用户协议》',
style: const TextStyle(color: Color(0xFF3B82F6)),
recognizer: TapGestureRecognizer()..onTap = _goToAgreement,
),
const TextSpan(text: '和'),
TextSpan(
text: '《隐私政策》',
style: const TextStyle(color: Color(0xFF3B82F6)),
recognizer: TapGestureRecognizer()..onTap = _goToPrivacy,
),
],
),
),
),
],
);
}
/// 登录历史下拉(本版用底部弹层,对应 Vue 的下拉列表)
void _showHistorySheet() {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (sheetContext) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.symmetric(vertical: 12.h),
child: Text(
'选择历史账号',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF323233),
),
),
),
for (final account in _loginHistory)
ListTile(
title: Text(account, style: TextStyle(fontSize: 14.sp)),
trailing: IconButton(
icon: const Icon(Icons.close, size: 10, color: Color(0xFFC0C4CC)),
onPressed: () {
setState(() => _loginHistory.remove(account));
Navigator.of(sheetContext).pop();
},
),
onTap: () {
_accountController.text = account;
Navigator.of(sheetContext).pop();
},
),
],
),
);
},
);
}
}
+267
View File
@@ -0,0 +1,267 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:new_learn/core/providers/privacy_provider.dart';
import 'package:new_learn/core/storage/privacy_storage.dart';
import 'package:new_learn/features/auth/legal_documents.dart';
/// ============================================================
/// 【PrivacyConsentPage】首次启动隐私协议页
///
/// 作用:
/// 1. 用户首次打开 App 时,先展示隐私说明;
/// 2. 用户点击"同意并继续"后,保存本地状态并放行进入主流程;
/// 3. 用户点击"暂不同意"时,维持拦截状态(Android 可直接退出应用)。
///
/// 这里是"应用启动级"协议页,不等同于登录页里的协议勾选:
/// - 启动协议:未登录前就要先拦;
/// - 登录页协议:属于业务表单校验,发生在点击登录时。
/// ============================================================
class PrivacyConsentPage extends StatefulWidget {
const PrivacyConsentPage({super.key});
@override
State<PrivacyConsentPage> createState() => _PrivacyConsentPageState();
}
class _PrivacyConsentPageState extends State<PrivacyConsentPage> {
bool _submitting = false;
_LegalDocumentData? _activeDocument;
Future<void> _openLegalDocument({
required String title,
required String content,
}) async {
if (!mounted) return;
setState(() {
_activeDocument = _LegalDocumentData(
title: title,
content: content,
);
});
}
Future<void> _accept() async {
if (_submitting) return;
setState(() => _submitting = true);
await PrivacyStorage.instance.saveAccepted(true);
// 更新全局门禁状态:启动 Gate 和 go_router 守卫都会同步放行。
privacyAcceptedNotifier.value = true;
if (mounted) {
setState(() => _submitting = false);
}
}
Future<void> _decline() async {
final result = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('提示'),
content: const Text('若您不同意隐私政策,将无法使用我们的服务'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('重新考虑'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('退出'),
),
],
),
);
// 选"重新考虑"或直接关闭弹窗,都继续停留在当前隐私页。
if (result != false) return;
await PrivacyStorage.instance.saveAccepted(false);
privacyAcceptedNotifier.value = false;
// Android 上按常见做法直接退出应用;其他平台调用也安全,无法退出时只是不生效。
await SystemNavigator.pop();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeDocument = _activeDocument;
if (activeDocument != null) {
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
setState(() => _activeDocument = null);
},
child: Scaffold(
backgroundColor: const Color(0xFFF5F7FA),
appBar: AppBar(
title: Text(activeDocument.title),
leading: IconButton(
onPressed: () => setState(() => _activeDocument = null),
icon: const Icon(Icons.arrow_back),
),
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: SelectableText(
activeDocument.content,
style: const TextStyle(
fontSize: 14,
height: 1.8,
color: Color(0xFF323233),
),
),
),
),
),
),
);
}
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'欢迎使用爱学蝶变',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
Text(
'为保障你的个人信息安全,在继续使用前,请先阅读并同意《用户协议》和《隐私政策》。同意后我们才会继续进入 App 的登录和业务流程。',
style: theme.textTheme.bodyLarge?.copyWith(
color: const Color(0xFF4B5563),
height: 1.7,
),
),
const SizedBox(height: 20),
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(16),
),
child: SingleChildScrollView(
child: Text(
'1. 我们会在必要范围内使用账号信息、设备信息,用于登录鉴权、学习记录同步、功能服务提供。\n\n'
'2. 未经你的明确同意,我们不会将你的个人信息用于与学习无关的用途。\n\n'
'3. 你可以通过《用户协议》和《隐私政策》了解信息收集、使用、存储与保护方式。\n\n'
'4. 若你不同意本协议内容,将无法继续进入 App 主流程。',
style: theme.textTheme.bodyMedium?.copyWith(
color: const Color(0xFF475569),
height: 1.8,
),
),
),
),
),
const SizedBox(height: 16),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
Text(
'点击“同意并继续”即表示你已阅读并同意',
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF64748B),
),
),
InkWell(
onTap: () => _openLegalDocument(
title: kUserAgreementTitle,
content: kUserAgreementContent,
),
child: const Text(
'《用户协议》',
style: TextStyle(
color: Color(0xFF2563EB),
fontSize: 12,
),
),
),
Text(
'和',
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF64748B),
),
),
InkWell(
onTap: () => _openLegalDocument(
title: kPrivacyPolicyTitle,
content: kPrivacyPolicyContent,
),
child: const Text(
'《隐私政策》',
style: TextStyle(
color: Color(0xFF2563EB),
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _submitting ? null : _decline,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: const Text('暂不同意'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _submitting ? null : _accept,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
child: _submitting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('同意并继续'),
),
),
],
),
],
),
),
),
);
}
}
class _LegalDocumentData {
const _LegalDocumentData({
required this.title,
required this.content,
});
final String title;
final String content;
}
+166
View File
@@ -0,0 +1,166 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/class/class_update_view.dart';
import 'package:new_learn/features/class/point_rank_view.dart';
/// ============================================================
/// 【ClassPage】「班级」tab 页 —— 对标 Vue pages/tabbar/class/class.vue
///
/// 一、Vue 源结构(壳 + 两个子页):
/// NavBar(透明、无返回)的 title 插槽 = 顶部 tab「班级动态 / 积分排行」
/// content-body = ClassUpdate(tab0) + PointRank(tab1),v-show 切换
///
/// 二、换算规则(本工程统一):Vue 的 `N rpx` → Flutter 的 `(N/2).w/.h/.sp/.r`
/// 例:间距 20rpx → 10.w;卡片圆角 30rpx → 15.r;字号 28rpx → 14.sp
///
/// 三、对应关系:
/// v-show 切换保留状态 → IndexedStack(保持两子页状态)
/// v-if 懒加载 → _loadedTabs 首次切到才 build
/// switchTab 后 handleShow → TODO(业务逻辑): 切回时刷新子页数据
///
/// 四、底部 Tabbar 由 MainTabPage 承载,此处不重复实现。
/// ============================================================
class ClassPage extends StatefulWidget {
const ClassPage({super.key});
@override
State<ClassPage> createState() => _ClassPageState();
}
class _ClassPageState extends State<ClassPage> {
static const Color _titleColor = Color(0xFF1D2B47);
static const Color _subtitleColor = Color(0xFF495A7A);
static const Color _primary = Color(0xFF3D7DF5);
static const List<String> _tabs = ['班级动态', '积分排行'];
int _currentTab = 0;
/// 懒加载标记(对标 Vue 的 loadedTabs):首次切到才创建对应子页
final List<bool> _loadedTabs = [true, false];
void _switchTab(int index) {
if (_currentTab == index) return;
setState(() {
_loadedTabs[index] = true;
_currentTab = index;
});
// TODO(业务逻辑): 对应 Vue switchTab 的 nextTick 后调用子组件 handleShow 刷新。
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// 页面底色(class-container 的 background-color: #F5F8FA)
const Positioned.fill(child: ColoredBox(color: Color(0xFFF5F8FA))),
// 顶部背景图:background-size 100% 512rpx → 高 256.h
Positioned(
top: 0,
left: 0,
right: 0,
height: 256.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-03/class-header-background-small.png',
),
fit: BoxFit.fill,
width: double.infinity,
),
),
// 积分排行左上角装饰图(仅 tab1 显示;280rpx → 140.w)
if (_currentTab == 1)
Positioned(
top: 0,
left: 0,
width: 140.w,
height: 140.h,
child: IgnorePointer(
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-07-31/img_points1785468064641.png',
),
fit: BoxFit.cover,
),
),
),
SafeArea(
child: Column(
children: [
_buildTabBar(),
Expanded(
child: IndexedStack(
index: _currentTab,
children: [
if (_loadedTabs[0])
const ClassUpdateView()
else
const SizedBox.shrink(),
if (_loadedTabs[1])
const PointRankView()
else
const SizedBox.shrink(),
],
),
),
],
),
),
],
),
);
}
/// 顶部 tab 栏(对标 NavBar title 插槽)
Widget _buildTabBar() {
return SizedBox(
height: 44.h,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (int i = 0; i < _tabs.length; i++) ...[
if (i > 0) SizedBox(width: 20.w), // 两个 tab 各 margin 20rpx
_buildTabItem(i),
],
],
),
);
}
Widget _buildTabItem(int index) {
final active = _currentTab == index;
return GestureDetector(
onTap: () => _switchTab(index),
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: 44.h,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_tabs[index],
style: TextStyle(
fontSize: active ? 16.sp : 14.sp,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
color: active ? _titleColor : _subtitleColor,
),
),
SizedBox(height: 6.h),
// 下划线指示(36rpx × 6rpx)
Container(
width: 18.w,
height: 3.h,
decoration: BoxDecoration(
color: active ? _primary : Colors.transparent,
borderRadius: BorderRadius.circular(2.r),
),
),
],
),
),
);
}
}
+403
View File
@@ -0,0 +1,403 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/models/user_model.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/core/api/class/api.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/features/class/widgets/class_update_widgets.dart';
/// ============================================================
/// 【ClassUpdateView】「班级动态」子页 —— 对标 Vue classUpdate.vue
///
/// 一、Vue 源结构:
/// 班名标题 → OnlineStatus 在线同学条 → 动态列表
/// 列表项 SubjectCard 内按 type 分发:1=攻克任务 / 2、3=文字笔记,
/// 再叠九宫格相册 + LikeAndComment 点赞评论栏。
///
/// 二、数据对接(已接通):
/// - 班名 → currentUser(gradeLevel + graduationYear + className)
/// - 在线同学 → getClassUsers
/// - 动态列表 → getUserClassPost(游标分页)
/// - 点赞/评论 → addUserLike / addUserComment(乐观更新,失败回滚)
///
/// 三、为什么列表不用 CommonPagedList:
/// 点赞/评论需要对单条动态做原位更新(不重建整个列表、不丢滚动位置),
/// CommonPagedList 把 PagingController/列表藏死在内部拿不到,无法改单条;
/// 故这里用 ListView + 手动游标分页,list 由本 State 直接持有,改单条即 setState。
///
/// 四、换算规则:rpx → /2 后接 .w/.h/.sp/.r
/// ============================================================
class ClassUpdateView extends ConsumerStatefulWidget {
const ClassUpdateView({super.key});
@override
ConsumerState<ClassUpdateView> createState() => _ClassUpdateViewState();
}
class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
static const Color _titleColor = Color(0xFF1D2B47);
static const Color _primary = Color(0xFF3D7DF5);
static const int _pageSize = 10;
final ScrollController _scrollController = ScrollController();
final List<UserPostDto> _posts = [];
int _pageIndex = 1;
int _prevPageId = 0;
bool _loading = true; // 首屏加载中
bool _loadingMore = false;
bool _hasMore = true;
bool _failed = false;
int _onlineCount = 0;
List<String> _avatarUrls = [];
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadFirstPage();
_fetchClassUsers();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 80) {
_loadMore();
}
}
/// 班名:gradeLevel + graduationYear + "届" + 班级(1班 → (1)班)
String _buildClassName(CurrentUserExtension? user) {
if (user == null) return '';
final className = user.className.trim().replaceAllMapped(
RegExp(r'\d+'),
(m) => '(${m.group(0)})',
);
return '${user.gradeLevel}${user.graduationYear}届$className';
}
Future<void> _fetchClassUsers() async {
try {
final users = await ClassApi.instance.getClassUsers();
final online = users.where((u) => u.isOnline).toList();
final offline = users.where((u) => !u.isOnline).toList();
final ordered = [...online, ...offline];
if (!mounted) return;
setState(() {
_onlineCount = online.length;
_avatarUrls = ordered.map((u) => _avatarUrl(u.avatar)).toList();
});
} catch (_) {
// 在线状态拉取失败静默处理,不影响动态列表
}
}
Future<void> _loadFirstPage() async {
setState(() {
_loading = true;
_failed = false;
});
try {
final res = await ClassApi.instance.getUserClassPost(
pageIndex: 1,
pageSize: _pageSize,
prevPageId: 0,
);
if (!mounted) return;
setState(() {
_posts
..clear()
..addAll(res.data);
_pageIndex = 1;
_prevPageId = res.data.isNotEmpty ? res.data.last.id : 0;
_hasMore = res.data.length >= _pageSize;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
Future<void> _loadMore() async {
if (_loadingMore || !_hasMore || _loading || _failed) return;
setState(() => _loadingMore = true);
try {
final res = await ClassApi.instance.getUserClassPost(
pageIndex: _pageIndex + 1,
pageSize: _pageSize,
prevPageId: _prevPageId,
);
if (!mounted) return;
setState(() {
_posts.addAll(res.data);
_pageIndex += 1;
if (res.data.isNotEmpty) _prevPageId = res.data.last.id;
_hasMore = res.data.length >= _pageSize;
_loadingMore = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _loadingMore = false);
}
}
Future<void> _toggleLike(int index) async {
final post = _posts[index];
final liked = !post.userLikeStatus;
final newCount = post.likeCount + (liked ? 1 : -1);
_replaceAt(
index,
post.copyWith(
userLikeStatus: liked,
likeCount: newCount < 0 ? 0 : newCount,
),
);
try {
await ClassApi.instance.addUserLike(
userPostId: post.id,
likeStatus: liked,
);
} catch (_) {
if (!mounted) return;
_replaceAt(index, post);
}
}
Future<void> _submitComment(int index, String content) async {
final text = content.trim();
if (text.isEmpty) return;
final post = _posts[index];
final me = ref.read(currentUserProvider).value;
final common = UserPostCommonDto(
userId: me?.userId ?? 0,
userName: me?.userName ?? '我',
content: text,
);
_replaceAt(
index,
post.copyWith(
replyCount: post.replyCount + 1,
commons: [common, ...post.commons],
),
);
try {
await ClassApi.instance.addUserComment(
userPostId: post.id,
content: text,
);
} catch (_) {
if (!mounted) return;
_replaceAt(index, post);
}
}
void _replaceAt(int index, UserPostDto post) {
setState(() => _posts[index] = post);
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider).value;
final className = _buildClassName(user);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(className),
_buildOnlineStatus(),
Expanded(child: _buildBody()),
],
);
}
/// 列表区:首屏加载 / 失败 / 空态 / 内容(含下拉刷新 + 上拉加载)
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_failed) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'加载失败,请检查网络',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
),
SizedBox(height: 12.h),
FilledButton(onPressed: _loadFirstPage, child: const Text('重新加载')),
],
),
);
}
if (_posts.isEmpty) {
return Center(
child: Text(
'暂无班级动态',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
),
);
}
return RefreshIndicator(
onRefresh: _loadFirstPage,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 14.w),
itemCount: _posts.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _posts.length) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
),
);
}
return PostCard(
post: _posts[index],
onLike: () => _toggleLike(index),
onComment: (content) => _submitComment(index, content),
);
},
),
);
}
/// 班名标题(class-title-section)
Widget _buildTitle(String className) {
return Padding(
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 0),
child: Text(
className,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: _titleColor,
height: 50 / 36,
),
),
);
}
/// 在线同学条(OnlineStatus)
Widget _buildOnlineStatus() {
return Container(
height: 58.h,
margin: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 15.h),
padding: EdgeInsets.fromLTRB(2.w, 2.h, 5.w, 2.h),
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build('/StaticResource/2026-04-06/onlineStatus-bg.png'),
),
fit: BoxFit.fill,
),
),
child: Row(
children: [
_buildOnlineLeft(),
const Spacer(),
_buildAvatarStack(),
_buildAllButton(),
],
),
);
}
Widget _buildOnlineLeft() {
return Row(
children: [
SizedBox(width: 15.w),
const Text('在线同学', style: TextStyle(fontSize: 14, color: _titleColor)),
SizedBox(width: 5.w),
Text(
'$_onlineCount',
style: const TextStyle(
fontSize: 23,
fontWeight: FontWeight.w500,
color: _primary,
),
),
SizedBox(width: 4.w),
const Text('人', style: TextStyle(fontSize: 14, color: _titleColor)),
],
);
}
/// 头像组:在线在前、离线在后,最多展示 6 个
Widget _buildAvatarStack() {
final urls = _avatarUrls.take(6).toList();
if (urls.isEmpty) return const SizedBox.shrink();
final size = 25.w;
final overlap = size * 0.94;
return SizedBox(
width: size + (urls.length - 1) * overlap,
height: size,
child: Stack(
children: [
for (int i = 0; i < urls.length; i++)
Positioned(
left: i * overlap,
child: UserCircleAvatar(url: urls[i], radius: size / 2),
),
],
),
);
}
/// 右侧「全部」按钮
Widget _buildAllButton() {
return Container(
width: 62.w,
height: 34.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF3D7DF5), Color(0xFF3C84FF), Color(0xFF8AD1FD)],
),
borderRadius: BorderRadius.only(
topRight: Radius.circular(15.r),
bottomLeft: Radius.circular(15.r),
bottomRight: Radius.circular(20.r),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('全部', style: TextStyle(fontSize: 14, color: Colors.white)),
SizedBox(width: 2.w),
Icon(Icons.chevron_right, size: 14, color: Colors.white),
],
),
);
}
/// 头像兜底:空 avatar 用伪造头像
static String _avatarUrl(String? raw) {
final url = ImgUrl.build(raw);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png')
: url;
}
}
+432
View File
@@ -0,0 +1,432 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/api.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/class/widgets/point_rank_widgets.dart';
/// ============================================================
/// 【PointRankView】「积分排行」子页 —— 对标 Vue pointRank.vue
///
/// 一、Vue 源结构:
/// 海报图 → 分类(本班级/同年级/同云校) → 子 tab(总榜/本周/上周/今日/昨天)
/// → 规则排序栏 → 排行列表(RankItem) → 底部「我的排名」卡 → 积分规则弹窗。
///
/// 二、数据对接(已接通):
/// - 人数统计 → getUserCount
/// - 榜单列表 → getUserRankPagedList(游标分页,CommonPagedList)
/// - 我的排名 → 同一接口返回的 myRank
/// - 分类/子tab/排序切换 → 通过 ValueKey 重建列表,自动重新拉第一页
///
/// 三、组件已抽离到 widgets/point_rank_widgets.dart(RankRow/EnergyRuleModal/
/// RuleSection/TriangleClipper),本文件只保留页面状态与数据逻辑。
///
/// 四、换算规则:rpx → /2 后接 .w/.h/.sp/.r
/// ============================================================
class PointRankView extends ConsumerStatefulWidget {
const PointRankView({super.key});
@override
ConsumerState<PointRankView> createState() => _PointRankViewState();
}
class _PointRankViewState extends ConsumerState<PointRankView> {
static const Color _primary = Color(0xFF3D7DF5);
static const int _pageSize = 20;
int _activeCategory = 0; // 0 本班级 / 1 同年级 / 2 同云校
int _activeSubTab = 0; // 总榜/本周/上周/今日/昨天
bool _sortDesc = true; // true 积分倒序 / false 正序
bool _showRule = false;
int? _classCount;
int? _gradeCount;
int? _cloudCount;
UserRank? _myRank;
int _prevRankPageId = 0;
static const List<String> _categories = ['本班级', '同年级', '同云校'];
static const List<String> _subTabs = ['总榜', '本周', '上周', '今日', '昨天'];
@override
void initState() {
super.initState();
_fetchUserCount();
}
Future<void> _fetchUserCount() async {
try {
final res = await UserRankApi.instance.getUserCount();
if (!mounted) return;
setState(() {
_classCount = res.classUserCount;
_gradeCount = res.gradeUserCount;
_cloudCount = res.cloudSchoolUserCount;
});
} catch (_) {
// 人数统计失败静默处理
}
}
Future<PagingResult<UserRank>> _loadRank(int page) async {
final prev = page == 1 ? 0 : _prevRankPageId;
final res = await UserRankApi.instance.getRankPage(
pageIndex: page,
pageSize: _pageSize,
prevPageId: prev,
userRankType: _activeSubTab,
userClass: _activeCategory,
desc: _sortDesc,
);
if (res.userRanks.isNotEmpty) {
_prevRankPageId = res.userRanks.last.userId;
}
if (page == 1 && mounted) {
setState(() => _myRank = res.myRank);
}
return PagingResult<UserRank>(
data: res.userRanks,
hasNext: res.userRanks.length >= _pageSize,
);
}
String _countText(int? count) => count == null ? '--' : '$count';
String _formatPoints(double p) {
if (p == p.roundToDouble()) return p.toInt().toString();
return p.toString();
}
@override
Widget build(BuildContext context) {
final currentUserId = ref.watch(currentUserProvider).value?.userId ?? 0;
return Stack(
children: [
Column(
children: [
_buildPoster(),
_buildCategoryBar(),
Expanded(child: _buildRankListContainer(currentUserId)),
],
),
if (_myRank != null)
Positioned(left: 0, right: 0, bottom: 0, child: _buildMyRankCard()),
if (_showRule)
EnergyRuleModal(onClose: () => setState(() => _showRule = false)),
],
);
}
Widget _buildPoster() {
return Container(
margin: EdgeInsets.only(top: 22.h),
alignment: Alignment.center,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-07-29/pointRankPoster1785291500821.png',
),
width: 170.w,
height: 30.h,
fit: BoxFit.fill,
errorWidget: (_, _, _) => const SizedBox(width: 170, height: 30),
),
);
}
Widget _buildCategoryBar() {
return Container(
margin: EdgeInsets.only(top: 20.h),
padding: EdgeInsets.symmetric(horizontal: 14.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (int i = 0; i < _categories.length; i++) _buildCategoryItem(i),
],
),
);
}
Widget _buildCategoryItem(int index) {
final active = _activeCategory == index;
final countText = switch (index) {
0 => _countText(_classCount),
1 => _countText(_gradeCount),
_ => _countText(_cloudCount),
};
return GestureDetector(
onTap: () {
if (_activeCategory == index) return;
setState(() => _activeCategory = index);
},
child: Column(
children: [
Container(
width: 105.w,
height: 45.h,
decoration: BoxDecoration(
color: active
? Colors.white
: Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8.r),
border: active ? null : Border.all(color: Colors.white, width: 1),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_categories[index],
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: active ? _primary : const Color(0xFF687FAD),
),
),
SizedBox(height: 4.h),
Text(
'$countText人',
style: TextStyle(
fontSize: 11,
color: active ? _primary : const Color(0xFF687FAD),
),
),
],
),
),
if (active) _buildTriangle(),
],
),
);
}
Widget _buildTriangle() {
return ClipPath(
clipper: TriangleClipper(),
child: Container(width: 14.w, height: 8.h, color: Colors.white),
);
}
Widget _buildRankListContainer(int currentUserId) {
return Container(
margin: EdgeInsets.only(top: 8.h),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
_buildSubTabList(),
_buildRuleSortBar(),
Expanded(
child: CommonPagedList<UserRank>(
// 分类/子tab/排序变化 → 重建列表,自动重拉第一页
key: ValueKey('rank-$_activeCategory-$_activeSubTab-$_sortDesc'),
load: _loadRank,
padding: EdgeInsets.fromLTRB(14.w, 13.h, 14.w, 121.h),
emptyWidget: Center(
child: Text(
'暂无积分排行',
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF999999),
),
),
),
buildItem: (context, item, index) {
final isSelf = item.userId != 0 && item.userId == currentUserId;
return RankRow(
item: item,
category: _activeCategory,
isSelf: isSelf,
);
},
),
),
],
),
);
}
Widget _buildSubTabList() {
return Container(
margin: EdgeInsets.only(top: 12.h),
padding: EdgeInsets.symmetric(horizontal: 25.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (int i = 0; i < _subTabs.length; i++) _buildSubTabItem(i),
],
),
);
}
Widget _buildSubTabItem(int index) {
final active = _activeSubTab == index;
return GestureDetector(
onTap: () {
if (_activeSubTab == index) return;
setState(() => _activeSubTab = index);
},
child: Container(
width: 52.w,
height: 32.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? _primary : const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
_subTabs[index],
style: TextStyle(
fontSize: 14,
fontWeight: active ? FontWeight.w500 : FontWeight.w400,
color: active ? Colors.white : const Color(0xFF687FAD),
),
),
),
);
}
Widget _buildRuleSortBar() {
return Container(
margin: EdgeInsets.only(top: 21.h),
padding: EdgeInsets.symmetric(horizontal: 14.w),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => _showRule = true),
child: Row(
children: [
const Flexible(
child: Text(
'积分数值越高排名越靠前,同分并列同名次',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Color(0xFF687FAD)),
),
),
SizedBox(width: 4.w),
const Icon(
Icons.info_outline,
size: 13,
color: Color(0xFF687FAD),
),
],
),
),
),
GestureDetector(
onTap: () => setState(() => _sortDesc = !_sortDesc),
child: Row(
children: [
Text(
_sortDesc ? '积分倒序' : '积分正序',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _primary,
),
),
SizedBox(width: 5.w),
Icon(
_sortDesc ? Icons.arrow_downward : Icons.arrow_upward,
size: 14,
color: _primary,
),
],
),
),
],
),
);
}
Widget _buildMyRankCard() {
final rank = _myRank!;
return Container(
height: 70.h,
padding: EdgeInsets.fromLTRB(10.w, 0, 14.w, 0),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFD6EEFF), Color(0xFFFFFFFF)],
),
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
SizedBox(
width: 50.w,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${rank.rank}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2B47),
),
),
SizedBox(height: 8.h),
const Text(
'我的排名',
style: TextStyle(fontSize: 9, color: Color(0xFF687FAD)),
),
],
),
),
UserCircleAvatar(url: _avatarUrl(rank.userAvatar), radius: 22.w),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
rank.userName.isEmpty ? '我' : rank.userName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2B47),
),
),
if (_activeCategory != 0 && rank.userClassName.isNotEmpty) ...[
SizedBox(height: 3.h),
Text(
rank.userClassName,
style: TextStyle(fontSize: 12, color: Color(0xFF687FAD)),
),
],
],
),
),
Text(
_formatPoints(rank.points),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: _primary,
),
),
SizedBox(width: 25.w),
],
),
);
}
static String _avatarUrl(String? raw) {
final url = ImgUrl.build(raw);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-11/default_avatar.png')
: url;
}
}
@@ -0,0 +1,578 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【班级动态组件】由 class_update_view.dart 抽离的展示组件
///
/// PostCard(动态卡片,按 type 分发内容 + 相册 + 点赞评论栏)
/// CardHeader / SubjectTag / ResolveProblemBox / AlbumDesc / AlbumGrid
/// LikeCommentBar / MetaButton / CommentPreview / CommentInput
///
/// 原为 class_update_view.dart 内的私有组件,重命名去下划线后放到本文件。
/// 头像复用 core/components/user_circle_avatar.dart。
/// ============================================================
/// 动态卡片(对标 SubjectCard + ResolveProblem/文字 + Album + LikeAndComment)
class PostCard extends StatelessWidget {
const PostCard({
super.key,
required this.post,
required this.onLike,
required this.onComment,
});
final UserPostDto post;
final VoidCallback onLike;
final ValueChanged<String> onComment;
String get _avatar {
final url = ImgUrl.build(post.avatar);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png')
: url;
}
String get _name => post.userName.isEmpty ? '默认姓名' : post.userName;
String get _subject => post.subjectName.isEmpty ? '数学' : post.subjectName;
String get _time {
final t = post.createTime;
if (t.isEmpty) return '';
final s = t.replaceFirst('T', ' ').replaceFirst('Z', '');
return s.length > 16 ? s.substring(0, 16) : s;
}
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(bottom: 10.h),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15.r),
),
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: 85.h,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [
const Color(0xFFCEE9FF).withValues(alpha: 0.9),
const Color(0xFFF6FBFF).withValues(alpha: 0.0),
],
),
),
),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 21.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CardHeader(
avatar: _avatar,
name: _name,
subject: _subject,
time: _time,
),
if (post.type == 1) ...[
SizedBox(height: 5.h),
ResolveProblemBox(desc: post.content),
],
if (post.type == 2 || post.type == 3) ...[
SizedBox(height: 15.h),
AlbumDesc(text: post.content),
],
if (post.urls.isNotEmpty) ...[
SizedBox(height: 15.h),
AlbumGrid(urls: post.urls.map(ImgUrl.build).toList()),
],
SizedBox(height: 10.h),
LikeCommentBar(
post: post,
onLike: onLike,
onComment: onComment,
),
],
),
),
],
),
);
}
}
/// 卡片标题:头像 + 姓名 + 学科标签 + 时间
class CardHeader extends StatelessWidget {
const CardHeader({
super.key,
required this.avatar,
required this.name,
required this.subject,
required this.time,
});
final String avatar;
final String name;
final String subject;
final String time;
@override
Widget build(BuildContext context) {
return Row(
children: [
UserCircleAvatar(url: avatar, radius: 20.w),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2B47),
),
),
),
SizedBox(width: 8.w),
SubjectTag(subject: subject),
],
),
SizedBox(height: 2.h),
Text(
time,
style: const TextStyle(fontSize: 12, color: Color(0xFF495A7A)),
),
],
),
),
],
);
}
}
/// 学科标签(对标 SubjectTag)
class SubjectTag extends StatelessWidget {
const SubjectTag({super.key, required this.subject});
final String subject;
static const Map<String, _SubjectColor> _map = {
'数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF),
'物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF),
'英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3),
'化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3),
};
@override
Widget build(BuildContext context) {
final t = subject.trim();
if (t.isEmpty) return const SizedBox.shrink();
final c = _map[t] ?? const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF);
return Container(
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h),
decoration: BoxDecoration(
color: Color(c.bg),
borderRadius: BorderRadius.circular(6.r),
),
child: Text(t, style: TextStyle(fontSize: 11, color: Color(c.fg))),
);
}
}
/// 攻克任务卡(对标 ResolveProblem)
class ResolveProblemBox extends StatelessWidget {
const ResolveProblemBox({super.key, required this.desc});
final String desc;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(19.w, 25.h, 19.w, 11.h),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFEAF4FF), Color(0xFFFFFFFF)],
),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'刚刚攻克了',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
color: Color(0xFF1D2B47),
),
),
SizedBox(height: 3.h),
Text.rich(
TextSpan(
children: [
TextSpan(
text: '「$desc」',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1D2B47),
),
),
const TextSpan(
text: ' 的所有高难考点!',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF45597E),
),
),
],
),
),
],
),
);
}
}
/// 纯文字笔记(album-desc,最多 2 行)
class AlbumDesc extends StatelessWidget {
const AlbumDesc({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w400,
color: Color(0xFF495A7A),
height: 42 / 30,
),
);
}
}
/// 九宫格相册(最多展示 9 张,第 9 张叠加 +N)
class AlbumGrid extends StatelessWidget {
const AlbumGrid({super.key, required this.urls});
final List<String> urls;
@override
Widget build(BuildContext context) {
final displayed = urls.take(9).toList();
return Wrap(
spacing: 9.w,
runSpacing: 9.h,
children: [
for (int i = 0; i < displayed.length; i++)
_gridItem(displayed[i], i, urls.length),
],
);
}
Widget _gridItem(String url, int index, int total) {
final more = total > 9 && index == 8 ? total - 9 : 0;
return ClipRRect(
borderRadius: BorderRadius.circular(8.r),
child: SizedBox(
width: 99.w,
height: 99.h,
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
errorWidget: (_, _, _) =>
const ColoredBox(color: Color(0xFFF0F2F5)),
),
if (more > 0)
ColoredBox(
color: const Color(0x66000000),
child: Center(
child: Text(
'+$more',
style: const TextStyle(
fontSize: 25,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
),
);
}
}
/// 点赞 / 评论栏(对标 LikeAndComment)
class LikeCommentBar extends StatelessWidget {
const LikeCommentBar({
super.key,
required this.post,
required this.onLike,
required this.onComment,
});
final UserPostDto post;
final VoidCallback onLike;
final ValueChanged<String> onComment;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
MetaButton(
icon: post.userLikeStatus
? Icons.favorite
: Icons.favorite_border,
count: post.likeCount,
active: post.userLikeStatus,
onTap: onLike,
),
SizedBox(width: 8.w),
MetaButton(icon: Icons.chat_bubble_outline, count: post.replyCount),
],
),
if (post.commons.isNotEmpty) ...[
SizedBox(height: 12.h),
CommentPreview(post: post),
],
SizedBox(height: 12.h),
CommentInput(onSubmit: onComment),
],
);
}
}
class MetaButton extends StatelessWidget {
const MetaButton({
super.key,
required this.icon,
required this.count,
this.active = false,
this.onTap,
});
final IconData icon;
final int count;
final bool active;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final color = active ? const Color(0xFF3D7DF5) : const Color(0xFFB4C2E0);
return GestureDetector(
onTap: onTap,
child: Container(
width: 51.w,
height: 22.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
border: Border.all(
color: const Color(0xFFB3C2E0).withValues(alpha: 0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 13.w, color: color),
SizedBox(width: 4.w),
Text('$count', style: TextStyle(fontSize: 13, color: color)),
],
),
),
);
}
}
/// 评论预览框(前 3 条 + 共 N 条)
class CommentPreview extends StatelessWidget {
const CommentPreview({super.key, required this.post});
final UserPostDto post;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 19.w, vertical: 12.h),
decoration: BoxDecoration(
color: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final c in post.commons.take(3))
Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '${c.userName}:',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: Color(0xFF1D2B47),
),
),
TextSpan(
text: c.content,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF495A7A),
),
),
],
),
),
),
if (post.replyCount > 3)
Row(
children: [
Text(
'共${post.replyCount}条评论',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF687FAD),
),
),
const Icon(
Icons.chevron_right,
size: 14,
color: Color(0xFF687FAD),
),
],
),
],
),
);
}
}
/// 评论输入行
class CommentInput extends StatefulWidget {
const CommentInput({super.key, required this.onSubmit});
final ValueChanged<String> onSubmit;
@override
State<CommentInput> createState() => _CommentInputState();
}
class _CommentInputState extends State<CommentInput> {
final TextEditingController _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _send() {
final text = _controller.text.trim();
if (text.isEmpty) return;
widget.onSubmit(text);
_controller.clear();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Container(
height: 30.h,
padding: EdgeInsets.symmetric(horizontal: 12.w),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE3ECFF)),
borderRadius: BorderRadius.circular(10.r),
),
child: TextField(
controller: _controller,
style: const TextStyle(fontSize: 12, color: Color(0xFF1D2B47)),
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: '写下你的评论',
hintStyle: TextStyle(fontSize: 12, color: Color(0xFFB4C2E0)),
),
onSubmitted: (_) => _send(),
),
),
),
SizedBox(width: 10.w),
GestureDetector(
onTap: _send,
child: Container(
width: 51.w,
height: 30.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF8AD1FD),
Color(0xFF3C84FF),
Color(0xFF3D7DF5),
],
),
borderRadius: BorderRadius.circular(6.r),
),
child: const Text(
'发送',
style: TextStyle(fontSize: 13, color: Colors.white),
),
),
),
],
);
}
}
class _SubjectColor {
const _SubjectColor(this.fg, this.bg);
final int fg;
final int bg;
}
@@ -0,0 +1,327 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【积分排行组件】由 point_rank_view.dart 抽离的展示组件
///
/// RankRow(排行榜单行)/ EnergyRuleModal(积分规则弹窗)/
/// RuleSection(规则段落)/ TriangleClipper(白三角裁剪)
/// 头像复用 core/components/user_circle_avatar.dart。
/// ============================================================
/// 排行榜单行(对标 rankItem.vue)
class RankRow extends StatelessWidget {
const RankRow({
super.key,
required this.item,
required this.category,
required this.isSelf,
});
final UserRank item;
final int category;
final bool isSelf;
bool get _isTop => item.rank >= 1 && item.rank <= 3;
String _formatPoints(double p) {
if (p == p.roundToDouble()) return p.toInt().toString();
return p.toString();
}
String get _avatar {
final url = ImgUrl.build(item.userAvatar);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-11/default_avatar.png')
: url;
}
@override
Widget build(BuildContext context) {
final subInfo = (category == 1 || category == 2) ? item.userClassName : '';
return Container(
height: 60.h,
margin: EdgeInsets.only(bottom: _isTop ? 10.h : 0),
padding: EdgeInsets.symmetric(horizontal: 10.w),
decoration: _isTop
? BoxDecoration(
gradient: LinearGradient(colors: _topColors(item.rank)),
borderRadius: BorderRadius.circular(10.r),
)
: null,
child: Row(
children: [
if (_isTop)
CachedNetworkImage(
imageUrl: _medalUrl(item.rank),
width: 39.w,
height: 60.h,
fit: BoxFit.fill,
errorWidget: (_, _, _) => const SizedBox(width: 39, height: 60),
)
else
SizedBox(
width: 36.w,
child: Center(
child: Text(
'${item.rank}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFFB4C2E0),
),
),
),
),
SizedBox(width: 8.w),
UserCircleAvatar(url: _avatar, radius: 22.w),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Flexible(
child: Text(
item.userName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2B47),
),
),
),
if (isSelf) ...[
SizedBox(width: 8.w),
Container(
width: 16.w,
height: 16.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFFB4C2E0),
borderRadius: BorderRadius.circular(6.r),
),
child: const Text(
'我',
style: TextStyle(fontSize: 10, color: Colors.white),
),
),
],
],
),
if (subInfo.isNotEmpty) ...[
SizedBox(height: 4.h),
Text(
subInfo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF687FAD),
),
),
],
],
),
),
Text(
_formatPoints(item.points),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF3D7DF5),
),
),
SizedBox(width: 20.w),
],
),
);
}
List<Color> _topColors(int rank) {
switch (rank) {
case 1:
return [const Color(0xFFFCF4DE), Colors.white];
case 2:
return [const Color(0xFFE8F0F7), Colors.white];
default:
return [const Color(0xFFFAEAE6), Colors.white];
}
}
String _medalUrl(int rank) {
final name = switch (rank) {
1 => 'ranking1Position1785292048368.png',
2 => 'ranking2Position1785292048369.png',
_ => 'ranking3Position1785292048369.png',
};
return ImgUrl.build('/StaticResource/2026-07-29/$name');
}
}
/// 积分规则弹窗(对标 EnergyRuleModal)
class EnergyRuleModal extends StatelessWidget {
const EnergyRuleModal({super.key, required this.onClose});
final VoidCallback onClose;
static const List<String> _rule1 = [
'1.每日登录+1分每日提交作业+3分',
'2.连续打卡奖励连续打卡3天+5分;连续打卡7天+12分;连续打卡14天+25分;连续打卡30天+50分',
'3.答题正确率奖励 正确率≥80%额外+2分每攻克一个知识点+5分',
'4.每日上传学习图片 图片>1张+2分',
'5.笔记被设为精选+10分(每日可叠加)',
'6.每日点赞(非同一个人同一个笔记/攻克知识点)单条+0.5分(单日上限5分)每日评论(同上)单条+1分(单日上限5分)',
];
static const List<String> _rule2 = [
'1.初始等级:0 级,0 积分',
'2.0 升 1 级:累计积攒 100 积分',
'3.1 升 2 级:再积攒 100 积分(累计 200)',
'4.2 升 3 级:再积攒 100 积分(累计 300)',
'5.3 升 4 级:再积攒 100 积分(累计 400)',
'6.4 升 5 级:再积攒 100 积分(累计 500)',
'7.5 升 6 级:再积攒 100 积分(累计 600)',
'...',
];
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(child: ColoredBox(color: const Color(0x99000000))),
Center(
child: SizedBox(
width: 301.5.w,
height: 530.5.h,
child: Stack(
children: [
Positioned.fill(
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-06-26/pointsRule_bg1782467735027.png',
),
fit: BoxFit.fill,
errorWidget: (_, _, _) =>
const ColoredBox(color: Colors.white),
),
),
Positioned(
left: 25.w,
right: 25.w,
top: 198.h,
bottom: 35.h,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RuleSection(title: '一、积分获取规则', items: _rule1),
SizedBox(height: 10.h),
RuleSection(title: '二、等级晋升规则', items: _rule2),
],
),
),
),
],
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 90.h),
child: GestureDetector(
onTap: onClose,
child: Container(
width: 150.w,
height: 49.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF8AD1FD),
Color(0xFF3C84FF),
Color(0xFF3D7DF5),
],
),
borderRadius: BorderRadius.circular(49.h),
),
child: const Text(
'我知道了',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
),
],
);
}
}
class RuleSection extends StatelessWidget {
const RuleSection({super.key, required this.title, required this.items});
final String title;
final List<String> items;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2B47),
),
),
SizedBox(height: 8.h),
for (final item in items)
Padding(
padding: EdgeInsets.only(bottom: 10.h),
child: Text(
item,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2B47),
height: 36 / 24,
),
),
),
],
);
}
}
/// 白色向下小三角
class TriangleClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
return Path()
..moveTo(0, 0)
..lineTo(size.width, 0)
..lineTo(size.width / 2, size.height)
..close();
}
@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;
}
+54
View File
@@ -0,0 +1,54 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:new_learn/core/storage/stores/answer_record_store.dart';
/// ============================================================
/// 【answerProvider】答题状态 —— Riverpod 内存主状态
///
/// 一、架构定位(对应方案分析结论):
/// 答案主状态必须住内存(每题作答高频读写、交卷即弃),
/// Isar 只是"备份恢复层":每次作答同步写一条 AnswerRecord,
/// 进程杀掉后下次进卷按 paperId 读回 restore() 续答。
///
/// 二、数据结构:`Map<questionId, String>`
/// key 用服务器题号(Question.questionId),不依赖本地自增 id,
/// 这样续答恢复时即使题目重新拉取也能对上。
///
/// 三、持久化为什么 fire-and-forget:
/// put 走 Isar 毫秒级完成,没必要阻塞 UI 作答回调;
/// web 端 store 是空实现(打印跳过),行为自动降级为纯内存。
/// ============================================================
/// Demo 试卷 id(真实项目由试卷对象传入,如 paper.paperId)
const int demoPaperId = 1001;
final answerProvider =
NotifierProvider<AnswerNotifier, Map<int, String>>(AnswerNotifier.new);
class AnswerNotifier extends Notifier<Map<int, String>> {
@override
Map<int, String> build() => {};
/// 【作答】更新内存 + 同步落盘(续答持久化)
void answer(int questionId, String value) {
state = {...state, questionId: value};
// fire-and-forget:写库毫秒级,不阻塞作答回调
unawaited(
AnswerRecordStore.instance.put(
paperId: demoPaperId,
questionId: questionId,
answer: value,
),
);
}
/// 【续答恢复】进卷时从 Isar 读回,整体注入(只恢复本卷存在的题)
void restore(Map<int, String> saved) {
if (saved.isEmpty) return;
state = {...state, ...saved};
}
/// 【交卷清理】清空内存状态(Isar 记录由页面调 deleteByPaper)
void clear() => state = {};
}
+170
View File
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【AnswerSheetDialog】答题卡 —— 题号网格 + 跳题
///
/// 一、职责:
/// 1. 展示所有题号(GridView 4 列),颜色区分答题状态:
/// 已答=蓝 / 未答=灰 / 当前题=绿;
/// 2. 点击题号 → onJump(index) 跳转(由 ExamPage 调 jumpToPage)。
///
/// 二、为什么独立成组件:
/// 答题卡是"总览 → 跳转"的通用交互,参数化设计后可复用于
/// 错题重做、试卷回顾等场景;不直接碰 PageController,
/// 跳转动作通过 onJump 回调上抛,保持组件解耦。
///
/// 三、状态判定:answers 是 `Map<questionId, answer>`,
/// 已答 = questionId 存在于 map(解答题空字符串算未答?——
/// 本 Demo 约定:answer 非空字符串才算已答)。
/// ============================================================
class AnswerSheetDialog extends StatelessWidget {
const AnswerSheetDialog({
super.key,
required this.questionIds,
required this.answers,
required this.currentIndex,
required this.onJump,
});
/// 题目列表(顺序即试卷顺序,用于定位题号)
final List<int> questionIds;
/// 已答答案表(questionId → answer)
final Map<int, String> answers;
/// 当前题索引(高亮绿色)
final int currentIndex;
/// 点击题号回调(传入索引,由页面执行跳转)
final ValueChanged<int> onJump;
int get _answeredCount => questionIds
.where((id) => (answers[id] ?? '').isNotEmpty)
.length;
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 420.h),
child: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ---------- 标题 + 已答统计 ----------
Row(
children: [
const Text(
'答题卡',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
),
const Spacer(),
Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.h),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
'已答 $_answeredCount / ${questionIds.length}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF3B82F6),
fontWeight: FontWeight.w600,
),
),
),
],
),
SizedBox(height: 12.h),
// ---------- 图例 ----------
Row(
children: [
_buildLegend(const Color(0xFF3B82F6), '已答'),
SizedBox(width: 16.w),
_buildLegend(const Color(0xFF22C55E), '当前'),
SizedBox(width: 16.w),
_buildLegend(const Color(0xFFE5E7EB), '未答'),
],
),
SizedBox(height: 12.h),
// ---------- 题号网格 ----------
Flexible(
child: GridView.builder(
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.3,
),
itemCount: questionIds.length,
itemBuilder: (context, index) {
final id = questionIds[index];
final answered = (answers[id] ?? '').isNotEmpty;
final isCurrent = index == currentIndex;
return _buildCell(index, answered, isCurrent);
},
),
),
],
),
),
),
);
}
Widget _buildLegend(Color color, String label) {
return Row(
children: [
Container(
width: 12.w,
height: 12.w,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(3.r),
),
),
SizedBox(width: 4.w),
Text(
label,
style: const TextStyle(fontSize: 11, color: Color(0xFF666666)),
),
],
);
}
Widget _buildCell(int index, bool answered, bool isCurrent) {
final color = isCurrent
? const Color(0xFF22C55E)
: answered
? const Color(0xFF3B82F6)
: const Color(0xFFE5E7EB);
return InkWell(
onTap: () => onJump(index),
borderRadius: BorderRadius.circular(6.r),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
'${index + 1}',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: isCurrent || answered ? Colors.white : const Color(0xFF666666),
),
),
),
);
}
}
+338
View File
@@ -0,0 +1,338 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/storage/stores/answer_record_store.dart';
import 'package:new_learn/features/exam/answer_provider.dart';
import 'package:new_learn/features/exam/answer_sheet_dialog.dart';
import 'package:new_learn/features/exam/exam_question.dart';
import 'package:new_learn/features/exam/paper_provider.dart';
import 'package:new_learn/features/exam/question_card.dart';
import 'package:new_learn/features/exam/question_type.dart';
/// ============================================================
/// 【ExamPage】试卷练习页 —— 大数据滑动核心 Demo
///
/// 一、组件协作(本页是唯一桥接 Riverpod 的地方):
/// paperProvider → `AsyncValue<List<Question>>`:loading/error/data 三态
/// answerProvider → `Map<int, String>`:当前全部答案,ref.watch 响应式
/// QuestionCard → 纯展示组件,props 传题 + 答案 + 作答回调
///
/// 二、PageView.builder 大数据性能(对应 Vue3 swiper 方案分析):
/// - builder 惰性构建:只构建视口内 ±1 页(cacheExtent),
/// 100 道题传入也只是个 List,滑出的页面 Element 自动回收;
/// - allowImplicitScrolling: true → 预构建相邻页,滑动不掉帧;
/// - ValueKey(questionId) → 页面复用时按题号隔离,杜绝串题。
///
/// 三、续答链路(进卷 → 作答 → 交卷):
/// 进卷 initState → AnswerRecordStore.findByPaper 读回 →
/// 注入 answerProvider(有记录则 SnackBar 提示已恢复);
/// 作答 QuestionCard 回调 → answer() 更新内存 + 异步写 Isar;
/// 交卷 确认 → 判分统计 → deleteByPaper 清理 → clear 内存。
///
/// 四、坑:
/// 1. restore 是异步的,首次 build 时答案可能还没回来 →
/// 注入后 provider 变化自动重建页面(响应式),无需手动刷新;
/// 2. 交卷接口失败不能删本地记录(防"没交成但答案没了"),
/// 本 Demo 模拟提交成功后统一清理。
/// ============================================================
class ExamPage extends ConsumerStatefulWidget {
const ExamPage({super.key});
@override
ConsumerState<ExamPage> createState() => _ExamPageState();
}
class _ExamPageState extends ConsumerState<ExamPage> {
final PageController _pageController = PageController();
int _currentIndex = 0;
/// 续答恢复后要定位到的题号(null = 不跳转,只定位一次)
int? _jumpToQuestion;
@override
void initState() {
super.initState();
_restoreProgress();
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
/// 【进卷恢复】从 Isar 读回本卷已答记录 → 注入 answerProvider
/// 并定位到最后作答的题(按 updatedAt 最新一条)。
///
/// 为什么还要定位:恢复的只是"答案数据",PageView 初始还在第 1 题,
/// 不定位的话用户每次进卷都要手动滑回上次位置(本 Bug 修复点)。
Future<void> _restoreProgress() async {
final records = await AnswerRecordStore.instance.findByPaper(demoPaperId);
if (!mounted || records.isEmpty) return;
final saved = <int, String>{};
int? lastQuestionId;
DateTime? lastAt;
for (final r in records) {
final qid = r.questionId as int;
saved[qid] = (r.answer as String?) ?? '';
// 记录最后作答的题:updatedAt 最新的一条(重复作答会覆盖更新时间)
final at = r.updatedAt as DateTime?;
if (at != null && (lastAt == null || at.isAfter(lastAt))) {
lastAt = at;
lastQuestionId = qid;
}
}
ref.read(answerProvider.notifier).restore(saved);
_jumpToQuestion = lastQuestionId;
_tryJumpAfterRestore(); // 若题目已加载完成(异步竞争),这里直接跳
if (!mounted) return;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text('已恢复上次答题进度(${saved.length} 题),定位到第 $lastQuestionId 题'),
duration: const Duration(seconds: 2),
),
);
}
/// 定位到最后作答的题:restore 与题目加载谁先完成都能跳转
void _tryJumpAfterRestore() {
final target = _jumpToQuestion;
if (target == null) return;
final questions = ref.read(paperProvider).value;
if (questions == null) return; // 题目还没加载完,等 build data 分支再试
final index = questions.indexWhere((q) => q.questionId == target);
if (index < 0) return;
_jumpToQuestion = null; // 只定位一次
WidgetsBinding.instance.addPostFrameCallback((_) {
// 首帧后跳转:此时 PageView 已 attach,jumpToPage 才安全
if (!mounted || !_pageController.hasClients) return;
_pageController.jumpToPage(index);
});
}
/// 【交卷】确认 → 清理本地进度 → 判分统计
Future<void> _submit(List<ExamQuestion> questions) async {
final answers = ref.read(answerProvider);
final total = questions.length;
final answered =
questions.where((q) => (answers[q.questionId] ?? '').isNotEmpty).length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认交卷?'),
content: Text('已答 $answered / $total 题,未答 ${total - answered} 题。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('再检查一下'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('交卷'),
),
],
),
);
if (confirmed != true || !mounted) return;
// 模拟提交接口成功 → 清理本地进度(防止下次进卷恢复旧答案)
await AnswerRecordStore.instance.deleteByPaper(demoPaperId);
ref.read(answerProvider.notifier).clear();
if (!mounted) return;
// 判分:选择/判断自动比对;解答题不算分(需人工批改)
final rightCount = questions.where((q) {
if (q.type == QuestionType.essay) return false; // 解答题跳过
final user = answers[q.questionId];
return user != null && user == q.answer;
}).length;
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('交卷成功'),
content: Text(
'自动判分:答对 $rightCount / ${total - answered} 题(客观题)\n'
'主观题需人工批改,本地进度已清理,下次进卷重新作答。',
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('知道了'),
),
],
),
);
}
/// 打开答题卡
void _openAnswerSheet(List<ExamQuestion> questions) {
showDialog<void>(
context: context,
builder: (ctx) => AnswerSheetDialog(
questionIds: [for (final q in questions) q.questionId],
answers: ref.read(answerProvider),
currentIndex: _currentIndex,
onJump: (index) {
Navigator.pop(ctx);
_pageController.jumpToPage(index);
},
),
);
}
@override
Widget build(BuildContext context) {
final paperAsync = ref.watch(paperProvider);
final answers = ref.watch(answerProvider);
return Scaffold(
appBar: AppBar(
title: const Text('试卷练习'),
actions: [
TextButton(
onPressed: paperAsync.hasValue
? () => _submit(paperAsync.value!)
: null,
child: const Text(
'交卷',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
body: paperAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Text('试卷加载失败:$e', style: const TextStyle(fontSize: 14)),
),
data: (questions) {
// 续答恢复可能晚于题目加载完成:data 分支兜底再触发一次定位
_tryJumpAfterRestore();
return Column(
children: [
_buildProgressBar(questions, answers),
Expanded(
child: PageView.builder(
controller: _pageController,
itemCount: questions.length,
// 预构建相邻页:滑动时目标页已就绪,不掉帧
allowImplicitScrolling: true,
onPageChanged: (i) => setState(() => _currentIndex = i),
itemBuilder: (context, i) {
final q = questions[i];
return QuestionCard(
// 按题号隔离:PageView 页面复用时杜绝串题
key: ValueKey(q.questionId),
question: q,
answer: answers[q.questionId],
onAnswer: (v) =>
ref.read(answerProvider.notifier).answer(q.questionId, v),
);
},
),
),
_buildBottomBar(questions),
],
);
},
),
);
}
/// 顶部进度条:已答 X / N + 当前题号
Widget _buildProgressBar(List<ExamQuestion> questions, Map<int, String> answers) {
final total = questions.length;
final answered =
questions.where((q) => (answers[q.questionId] ?? '').isNotEmpty).length;
return Container(
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 10.h),
color: Colors.white,
child: Column(
children: [
Row(
children: [
Text(
'第 ${_currentIndex + 1} / $total 题',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
const Spacer(),
Text(
'已答 $answered 题',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
],
),
SizedBox(height: 8.h),
ClipRRect(
borderRadius: BorderRadius.circular(4.r),
child: LinearProgressIndicator(
value: total == 0 ? 0 : answered / total,
minHeight: 6,
backgroundColor: const Color(0xFFF0F2F5),
valueColor: const AlwaysStoppedAnimation(Color(0xFF3B82F6)),
),
),
],
),
);
}
/// 底部导航:上一题 / 答题卡 / 下一题
Widget _buildBottomBar(List<ExamQuestion> questions) {
final isFirst = _currentIndex == 0;
final isLast = _currentIndex == questions.length - 1;
return Container(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0xFFF0F2F5))),
),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: isFirst
? null
: () => _pageController.previousPage(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
),
icon: const Icon(Icons.chevron_left, size: 18),
label: const Text('上一题'),
),
),
SizedBox(width: 12.w),
Expanded(
child: FilledButton.tonalIcon(
onPressed: () => _openAnswerSheet(questions),
icon: const Icon(Icons.grid_view, size: 18),
label: const Text('答题卡'),
),
),
SizedBox(width: 12.w),
Expanded(
child: FilledButton.icon(
onPressed: isLast
? null
: () => _pageController.nextPage(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
),
icon: const Icon(Icons.chevron_right, size: 18),
label: const Text('下一题'),
),
),
],
),
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:new_learn/features/exam/question_type.dart';
/// ============================================================
/// 【ExamQuestion】试卷题目接口模型(纯 Dart,无 Isar 依赖)
///
/// 一、为什么不能直接用 Isar 的 Question 模型(web 编译约束):
/// Isar 模型生成的 *.g.dart 含 64 位 schema 哈希常量,
/// dart2js 无法精确表示 → web 编译直接失败(本项目已踩坑)。
/// 业务代码 import Isar 模型 = 把它拉进 web 编译图。
/// 所以试卷功能用本纯 Dart 模型(接口 JSON 解析),
/// WrongQuestionStore 注释的约定:"页面永远不要 import 模型"。
///
/// 二、与 Isar Question 字段对应:
/// questionId/title/answer/analysis 一一对应;
/// options 直接用 `List<String>`(接口 JSON 是数组),
/// Isar 侧存 JSON 字符串只是本地存储的简化写法,语义相同。
/// ============================================================
class ExamQuestion {
const ExamQuestion({
required this.questionId,
required this.title,
required this.options,
required this.answer,
required this.type,
required this.analysis,
});
/// 服务器题号(答案 Map 的 key,续答恢复的关联字段)
final int questionId;
/// 题干(HTML 富文本,RichTextContent 渲染)
final String title;
/// 选项(选择/判断题有值,解答题空列表)
final List<String> options;
/// 正确答案(多选 "A,C" 逗号拼接,判断 "对"/"错",解答为参考答案)
final String answer;
/// 题型(渲染分发的依据)
final QuestionType type;
/// 解析(交卷后查看答案用)
final String analysis;
/// 接口 JSON → 模型(字段缺失兜底空值,防御脏数据)
factory ExamQuestion.fromJson(Map<String, dynamic> json) {
return ExamQuestion(
questionId: json['questionId'] as int,
title: json['title'] as String? ?? '',
options: (json['options'] as List?)?.cast<String>() ?? const [],
answer: json['answer'] as String? ?? '',
type: QuestionType.fromCode(json['type'] as int?),
analysis: json['analysis'] as String? ?? '',
);
}
}
+124
View File
@@ -0,0 +1,124 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:new_learn/features/exam/exam_question.dart';
import 'package:new_learn/features/exam/question_type.dart';
/// ============================================================
/// 【paperProvider】试卷题目数据源 —— Riverpod 内存主状态
///
/// 一、架构定位(对应方案分析结论):
/// 题目是"会话数据"不是"资产":进卷拉一次、退出即弃,
/// 不落 Isar(Isar 只负责跨会话的答案进度,见 answer_provider)。
/// 100 题内存占用 ≈ 1MB,PageView.builder 懒加载按需构建,
/// 大数组传入无性能压力。
///
/// 二、mock 模式(对标 Vue3 内联 mock 体验):
/// const bool mock = true 时直接本地生成题目(Future.delayed
/// 模拟接口延迟),改 false 后接真实接口(DioClient),
/// 一行开关切换,热重启即生效。
///
/// 三、paperSizeProvider:demo 专用,控制生成题数(50/100/200),
/// paperProvider watch 它 → 改变自动重新加载(Riverpod 响应式)。
/// ============================================================
/// Demo 专用:试卷题目数量(50/100/200,入口页选择)
class PaperSizeNotifier extends Notifier<int> {
@override
int build() => 100;
void setSize(int size) => state = size;
}
final paperSizeProvider =
NotifierProvider<PaperSizeNotifier, int>(PaperSizeNotifier.new);
/// 试卷题目加载器
final paperProvider = AsyncNotifierProvider<PaperNotifier, List<ExamQuestion>>(
PaperNotifier.new,
);
class PaperNotifier extends AsyncNotifier<List<ExamQuestion>> {
/// 接口 mock 开关:true = 本地生成;false = 接真实接口(DioClient)
static const bool mock = true;
@override
Future<List<ExamQuestion>> build() async {
final size = ref.watch(paperSizeProvider);
if (mock) {
// 模拟接口延迟:首屏展示 loading,验证加载态
await Future.delayed(const Duration(milliseconds: 600));
return MockExamData.generate(size);
}
// 真实接口:GET /api/v1/Paper/{paperId}/Questions
// final resp = await DioClient.instance.get('/api/v1/Paper/1/Questions');
// return (resp.data['list'] as List).map(Question.fromJson).toList();
return const [];
}
}
/// ============================================================
/// 【MockExamData】大数据模拟生成器(100 题 × 4 题型)
///
/// 模拟真实试卷的题目构成:单选 / 多选 / 判断 / 解答 循环分布,
/// 题干带少量 HTML 标签(RichTextContent 富文本渲染路径),
/// 用于验证 PageView.builder 大数据滑动性能与题型分发。
/// ============================================================
class MockExamData {
/// 学科题材池(按题号轮换,避免题目千篇一律)
static const List<(String, String)> _stems = [
('数学', '计算 12 × 8 = ?'),
('语文', '下列词语书写<u>完全正确</u>的一项是?'),
('英语', '— What does he do?<br>— He is a(n) <b>____</b>.'),
('科学', '植物进行光合作用的主要器官是?'),
('常识', '地球是太阳系中最大的行星。'),
('语文', '“落霞与孤鹜齐飞”出自哪篇名作?'),
('数学', '一个三角形最多有几个直角?'),
('英语', '下列单词中发音与其他三项不同的一项是?'),
];
static List<ExamQuestion> generate(int count) {
return List.generate(count, (i) {
final (subject, stem) = _stems[i % _stems.length];
return switch (i % 4) {
// 0 → 单选(4 个选项)
0 => ExamQuestion(
questionId: i + 1,
type: QuestionType.single,
title: '<p><b>【$subject】</b> 第 ${i + 1} 题</p><p>$stem</p>',
options: const ['选项 A 的内容', '选项 B 的内容', '选项 C 的内容', '选项 D 的内容'],
answer: 'A',
analysis: '本题考察基础知识点,正确答案为 A。',
),
// 1 → 多选(答案两个字母)
1 => ExamQuestion(
questionId: i + 1,
type: QuestionType.multi,
title:
'<p><b>【$subject】</b> 第 ${i + 1} 题(多选)</p><p>$stem</p>',
options: const ['选项 A 的内容', '选项 B 的内容', '选项 C 的内容', '选项 D 的内容'],
answer: 'A,C',
analysis: '本题有两个正确答案:A 与 C。',
),
// 2 → 判断(对 / 错)
2 => ExamQuestion(
questionId: i + 1,
type: QuestionType.judge,
title:
'<p><b>【$subject】</b> 第 ${i + 1} 题(判断)</p><p>$stem</p>',
options: const ['对', '错'],
answer: i % 6 == 0 ? '错' : '对',
analysis: '依据教材定义判断,答案为 ${i % 6 == 0 ? "错" : "对"}。',
),
// 3 → 解答题(文本输入)
_ => ExamQuestion(
questionId: i + 1,
type: QuestionType.essay,
title:
'<p><b>【$subject】</b> 第 ${i + 1} 题(解答)</p><p>$stem</p>',
options: const [],
answer: '水有三种状态:液态、固态、气态。',
analysis: '解答题需人工批改,参考答案:水有三种状态。',
),
};
});
}
}
+398
View File
@@ -0,0 +1,398 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/rich_text_content.dart';
import 'package:new_learn/features/exam/exam_question.dart';
import 'package:new_learn/features/exam/question_type.dart';
/// ============================================================
/// 【QuestionCard】题目卡片 —— 纯展示组件(业务解耦)
///
/// 一、设计原则:
/// 组件不碰 Riverpod,只接收 question + 当前答案 + 回调,
/// 由 ExamPage 负责桥接 provider。这样组件可复用、可单测,
/// 换状态管理方案不用改组件。
///
/// 二、题型分发(本项目核心模式):
/// 按 question.type switch 到三个子视图:
/// ChoiceQuestionView(单选/多选,共用选项布局,toggle 逻辑区分)
/// JudgeQuestionView(判断,对/错两按钮)
/// EssayQuestionView(解答,文本输入 + 防抖提交)
/// 新增题型只需加一个枚举值 + 一个视图,QuestionCard 加一个 case。
///
/// 三、题干渲染:
/// RichTextContent 纯 widget 渲染 HTML(非 WebView),
/// 富文本解析发生在每次 build —— 真实项目可在此接入预解析缓存
/// (进卷时批量解析一次 HTML,按 questionId 缓存解析结果)。
/// ============================================================
class QuestionCard extends StatelessWidget {
const QuestionCard({
super.key,
required this.question,
required this.answer,
required this.onAnswer,
});
final ExamQuestion question;
final String? answer;
final ValueChanged<String> onAnswer;
@override
Widget build(BuildContext context) {
final type = question.type;
return Container(
margin: EdgeInsets.all(16.w),
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ---------- 题型标签 + 题号 ----------
Row(
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h),
decoration: BoxDecoration(
color: _typeColor(type).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
type.label,
style: TextStyle(
fontSize: 11.sp,
color: _typeColor(type),
fontWeight: FontWeight.w600,
),
),
),
SizedBox(width: 8.w),
Text(
'第 ${question.questionId} 题',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
],
),
SizedBox(height: 12.h),
// ---------- 题干(富文本) ----------
RichTextContent(
html: question.title,
textStyle: TextStyle(
fontSize: 15.sp,
height: 1.6,
color: const Color(0xFF333333),
),
),
SizedBox(height: 16.h),
// ---------- 按题型分发 ----------
switch (type) {
QuestionType.single => ChoiceQuestionView(
options: question.options,
multiSelect: false,
answer: answer,
onAnswer: onAnswer,
),
QuestionType.multi => ChoiceQuestionView(
options: question.options,
multiSelect: true,
answer: answer,
onAnswer: onAnswer,
),
QuestionType.judge => JudgeQuestionView(
answer: answer,
onAnswer: onAnswer,
),
QuestionType.essay => EssayQuestionView(
answer: answer,
onAnswer: onAnswer,
),
},
],
),
);
}
static Color _typeColor(QuestionType type) {
return switch (type) {
QuestionType.single => const Color(0xFF3B82F6),
QuestionType.multi => const Color(0xFF8B5CF6),
QuestionType.judge => const Color(0xFFF59E0B),
QuestionType.essay => const Color(0xFF10B981),
};
}
}
/// ============================================================
/// 【ChoiceQuestionView】选择题(单选/多选共用)
///
/// 受控组件:选中状态完全来自外部 answer,自己不留状态。
/// 答案格式:"A"(单选)/ "A,C"(多选,升序逗号拼接)。
/// ============================================================
class ChoiceQuestionView extends StatelessWidget {
const ChoiceQuestionView({
super.key,
required this.options,
required this.multiSelect,
required this.answer,
required this.onAnswer,
});
final List<String> options;
final bool multiSelect;
final String? answer;
final ValueChanged<String> onAnswer;
void _onTap(int index) {
final letter = String.fromCharCode(65 + index); // 0→A, 1→B...
if (!multiSelect) {
onAnswer(letter);
return;
}
// 多选:在选中集合中 toggle,再升序拼接
final selected = answer == null
? <String>{}
: answer!.split(',').where((s) => s.isNotEmpty).toSet();
if (!selected.add(letter)) selected.remove(letter);
final sorted = selected.toList()..sort();
onAnswer(sorted.join(','));
}
@override
Widget build(BuildContext context) {
final selectedSet = answer == null
? <String>{}
: answer!.split(',').where((s) => s.isNotEmpty).toSet();
return Column(
children: [
for (int i = 0; i < options.length; i++) ...[
_buildOption(i, selectedSet),
if (i < options.length - 1) SizedBox(height: 10.h),
],
],
);
}
Widget _buildOption(int index, Set<String> selectedSet) {
final letter = String.fromCharCode(65 + index);
final isSelected = selectedSet.contains(letter);
return InkWell(
onTap: () => _onTap(index),
borderRadius: BorderRadius.circular(8.r),
child: Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFFEFF6FF) : Colors.white,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: isSelected
? const Color(0xFF3B82F6)
: const Color(0xFFE5E7EB),
width: isSelected ? 1.5 : 1,
),
),
child: Row(
children: [
Container(
width: 26.w,
height: 26.w,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? const Color(0xFF3B82F6) : Colors.white,
border: Border.all(
color: isSelected
? const Color(0xFF3B82F6)
: const Color(0xFF9CA3AF),
),
),
child: Text(
letter,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : const Color(0xFF6B7280),
),
),
),
SizedBox(width: 12.w),
Expanded(
child: Text(
options[index],
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF333333),
),
),
),
if (multiSelect)
Icon(
isSelected ? Icons.check_circle : Icons.circle_outlined,
size: 18,
color: isSelected
? const Color(0xFF3B82F6)
: const Color(0xFFD1D5DB),
),
],
),
),
);
}
}
/// ============================================================
/// 【JudgeQuestionView】判断题(对/错两按钮)
/// 答案格式:"对" / "错"(与 Question.answer 存储约定一致)。
/// ============================================================
class JudgeQuestionView extends StatelessWidget {
const JudgeQuestionView({
super.key,
required this.answer,
required this.onAnswer,
});
final String? answer;
final ValueChanged<String> onAnswer;
@override
Widget build(BuildContext context) {
return Row(
children: [
for (final value in const ['对', '错']) ...[
Expanded(
child: _buildJudgeButton(value),
),
if (value == '对') SizedBox(width: 12.w),
],
],
);
}
Widget _buildJudgeButton(String value) {
final isSelected = answer == value;
return InkWell(
onTap: () => onAnswer(value),
borderRadius: BorderRadius.circular(8.r),
child: Container(
padding: EdgeInsets.symmetric(vertical: 14.h),
decoration: BoxDecoration(
color: isSelected
? (value == '对' ? const Color(0xFFE8F7EE) : const Color(0xFFFDECEA))
: Colors.white,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: isSelected
? (value == '对' ? const Color(0xFF22C55E) : const Color(0xFFEF4444))
: const Color(0xFFE5E7EB),
width: isSelected ? 1.5 : 1,
),
),
child: Column(
children: [
Icon(
value == '对' ? Icons.check_circle : Icons.cancel,
size: 22,
color: isSelected
? (value == '对' ? const Color(0xFF22C55E) : const Color(0xFFEF4444))
: const Color(0xFF9CA3AF),
),
SizedBox(height: 6.h),
Text(
value,
style: TextStyle(
fontSize: 15.sp,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
color: const Color(0xFF333333),
),
),
],
),
),
);
}
}
/// ============================================================
/// 【EssayQuestionView】解答题(文本输入 + 300ms 防抖提交)
///
/// 防抖原因:onChanged 每敲一个字都会触发 provider 更新 + Isar 写库,
/// 300ms 空闲后合并为一次提交,减少高频写盘。
/// 注意:组件是受控回填的 —— 外部 answer 变化(续答恢复)时
/// didUpdateWidget 同步 controller 文本,但不打断用户正在输入。
/// ============================================================
class EssayQuestionView extends StatefulWidget {
const EssayQuestionView({
super.key,
required this.answer,
required this.onAnswer,
});
final String? answer;
final ValueChanged<String> onAnswer;
@override
State<EssayQuestionView> createState() => _EssayQuestionViewState();
}
class _EssayQuestionViewState extends State<EssayQuestionView> {
late final TextEditingController _controller;
Timer? _debounce;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.answer ?? '');
}
@override
void didUpdateWidget(covariant EssayQuestionView oldWidget) {
super.didUpdateWidget(oldWidget);
// 外部答案变化(如续答恢复)且与当前输入不一致时才回填,
// 避免覆盖用户正在输入的内容
if (widget.answer != null && widget.answer != _controller.text) {
_controller.text = widget.answer!;
}
}
void _onChanged(String text) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
widget.onAnswer(text.trim());
});
}
@override
void dispose() {
_debounce?.cancel();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: const Color(0xFFF9FAFB),
borderRadius: BorderRadius.circular(8.r),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: TextField(
controller: _controller,
onChanged: _onChanged,
maxLines: 5,
minLines: 3,
decoration: InputDecoration(
hintText: '请输入你的解答...',
hintStyle: const TextStyle(color: Color(0xFFB0B7C3)),
contentPadding: EdgeInsets.all(12.w),
border: InputBorder.none,
),
style: TextStyle(fontSize: 14.sp, height: 1.5),
),
);
}
}
+41
View File
@@ -0,0 +1,41 @@
/// ============================================================
/// 【QuestionType】题型枚举 —— 试卷渲染分发的唯一依据
///
/// 与 Question 模型的 type 字段(int)一一对应:
/// 1 单选 / 2 多选 / 3 判断 / 4 解答
/// QuestionCard 拿到 question 后 switch type 分发到对应视图组件。
///
/// 为什么用枚举而不是裸 int:
/// 枚举自带 label(UI 标签)、答案格式约定(选择 "A,B" / 判断 "对" /
/// 解答文本),页面不用到处散落魔法数字,加新题型只改这一个文件。
/// ============================================================
enum QuestionType {
single(1, '单选题'),
multi(2, '多选题'),
judge(3, '判断题'),
essay(4, '解答题');
const QuestionType(this.code, this.label);
/// Isar 存储值(Question.type)
final int code;
final String label;
/// 从存储 int 转枚举(非法值兜底为单选,防御脏数据)
static QuestionType fromCode(int? code) {
return QuestionType.values.firstWhere(
(t) => t.code == code,
orElse: () => QuestionType.single,
);
}
}
/// ============================================================
/// 【答案格式约定】统一 String 存储
///
/// 单选 → "A"(一个字母)
/// 多选 → "A,B"(字母按升序逗号拼接,如 "A,C")
/// 判断 → "对" / "错"
/// 解答 → 用户输入的纯文本
/// QuestionCard 各题型视图按约定编码/解码,交卷时按约定序列化上报。
/// ============================================================
+576
View File
@@ -0,0 +1,576 @@
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/image_preview_viewer.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/network/oss_api.dart';
import 'package:new_learn/core/network/oss_uploader.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/services/image_processor.dart';
import 'package:new_learn/core/services/local_notification_service.dart';
import 'package:new_learn/core/services/picker_service.dart';
import 'package:new_learn/core/storage/user_storage.dart';
import 'package:new_learn/core/api/auth/api.dart';
/// ============================================================
/// 【MyPage】「我的」tab 页
///
/// 一、本页结构(Column):
/// 渐变头部(用户信息)→ Expanded(SingleChildScrollView(菜单))
/// 固定头部 + 菜单可滚动,是「我的」页经典布局。
///
/// 二、当前菜单:
/// 暂时只保留【我的积分】入口(点击 context.push('/myPoint') 进积分页),
/// 后续菜单项一个个加回来,点击各自 push 对应页面。
///
/// 三、Riverpod 全局用户:
/// 头部头像/用户名/班级/积分全部来自 currentUserProvider(登录后
/// login_page 已 set),本页 ref.watch 自动响应更新,不再写死。
///
/// 四、屏幕适配(flutter_screenutil):
/// 所有尺寸按设计稿 375x812 适配:.w 宽/.h 高/.sp 字号/.r 圆角。
///
/// 五、高频坑:
/// 1. SingleChildScrollView 里不要嵌套无界高度的 ListView;
/// 2. 菜单项统一 64.h 高,点击区用 InkWell 包满整行;
/// 3. 加了 .w/.h/.sp 的 widget 不能再用 const(值是运行时计算)。
/// ============================================================
class MyPage extends ConsumerWidget {
const MyPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// 全局用户(登录后自动有值)
final user = ref.watch(currentUserProvider).value;
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 顶部渐变背景区域(用户信息头部)
Container(
width: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFE2C2), Color(0xFFFFFFFF)],
),
),
child: Padding(
padding: EdgeInsets.only(bottom: 20.h),
child: _UserHeader(
name: user?.userName ?? '未登录',
className: user?.className ?? '-',
avatar: user?.avatar ?? '',
),
),
),
// 菜单列表区域(可滚动)
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
SizedBox(height: 12.h),
// 功能菜单(先只留"我的积分",其余后补)
_MenuCard(
items: [
_MenuItem(
color: const Color(0xFFFF9800),
title: '我的积分',
iconData: Icons.stars_rounded,
trailingText: '${user?.userPoints ?? 0}积分',
onTap: () => context.push('/myPoint'),
),
if (!kReleaseMode)
_MenuItem(
color: const Color(0xFF2563EB),
title: '测试通知(15秒)',
iconData: Icons.notifications_active_outlined,
onTap: () async {
talker.debug('触发测试通知:15 秒后弹出,点击进入我的积分');
await LocalNotificationService.instance
.showMyPointNow();
await LocalNotificationService.instance
.scheduleMyPointAfter15Seconds();
},
),
],
),
SizedBox(height: 24.h),
// 退出登录(守卫自动踢回登录页,见 app_router.dart)
_MenuCard(
items: [
_MenuItem(
color: const Color(0xFFEF4444),
title: '退出登录',
iconData: Icons.logout_rounded,
onTap: () => _confirmLogout(context, ref),
),
],
),
SizedBox(height: 24.h),
],
),
),
),
],
),
),
);
}
}
/// 退出登录:确认弹窗 → 清 token → 清全局登录态 → 守卫自动踢回登录页
Future<void> _confirmLogout(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定要退出当前账号吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('退出'),
),
],
),
);
if (ok != true) return;
// ① 清内存 Token:Dio 拦截器立刻停止携带 Authorization
DioClient.instance.accessToken = null;
// ② 清磁盘缓存:安全存储的 token + prefs 里的用户信息(记住密码保留)
await UserStorage.instance.clearAll();
// ③ 清全局登录态 → set(null) 内部 authTick.value++
// → GoRouter.refresh() → 守卫 redirect 把当前页踢回 /login。
// 这里不需要手动 context.go('/login'),守卫会自动接管。
ref.read(currentUserProvider.notifier).set(null);
}
/// 用户信息头部:圆形头像 + 用户名 + 班级
///
/// 头像显示三级优先:
/// 1. 刚选中的本地新头像(上传成功后替换为服务器 URL)
/// 2. 服务器返回的 avatar 网络图(登录/缓存里有 URL)
/// 3. 默认头像:用户名首字圆(还没上传过头像时兜底)
///
/// 点击头像 → 弹出「拍照/从相册」→ 选完 OSS 直传(场景 avatar)
/// → 上传成功更新全局用户头像(currentUserProvider + 本地缓存)。
class _UserHeader extends ConsumerStatefulWidget {
const _UserHeader({
required this.name,
required this.className,
required this.avatar,
});
final String name;
final String className;
final String avatar;
@override
ConsumerState<_UserHeader> createState() => _UserHeaderState();
}
class _UserHeaderState extends ConsumerState<_UserHeader> {
static const String _avatarHeroTag = 'my-page-avatar';
/// 刚选中的本地新头像(null = 还没选)
PickedImage? _pickedAvatar;
/// 点击头像:
/// 1. 没头像 → 直接更换;
/// 2. 有头像 → 先弹动作表,支持"预览头像 / 更换头像"。
Future<void> _onAvatarTap() async {
if (!_hasPreviewableAvatar) {
await _pickAndUploadAvatar();
return;
}
final action = await showModalBottomSheet<String>(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (dialogContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.visibility_outlined),
title: const Text('预览头像'),
onTap: () => Navigator.of(dialogContext).pop('preview'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('更换头像'),
onTap: () => Navigator.of(dialogContext).pop('change'),
),
const SizedBox(height: 8),
],
),
),
);
if (!mounted) return;
if (action == 'preview') {
await _previewAvatar();
return;
}
if (action == 'change') {
await _pickAndUploadAvatar();
}
}
bool get _hasPreviewableAvatar {
return _pickedAvatar != null || widget.avatar.isNotEmpty;
}
Future<void> _previewAvatar() async {
final picked = _pickedAvatar;
if (picked?.bytes != null) {
await ImagePreviewViewer.showSingle(
context,
imageProvider: MemoryImage(picked!.bytes!),
heroTag: _avatarHeroTag,
label: '头像预览',
);
return;
}
if (widget.avatar.isNotEmpty) {
await ImagePreviewViewer.showSingle(
context,
imageProvider: NetworkImage(widget.avatar),
heroTag: _avatarHeroTag,
label: '头像预览',
);
}
}
/// 选择头像:拍照/相册 → OSS 直传 → 调业务接口更新头像字段 → 更新全局用户
///
/// 这里和 UniApp 旧项目保持一致,完整链路是:
/// 1. 先选图片;
/// 2. 本地压缩/方向修正;
/// 3. 上传到 OSS,拿到最终图片 url;
/// 4. 调 /api/v1/User/UpdateUserAvatar,把服务器里的 avatar 字段改掉;
/// 5. 更新 currentUserProvider + 本地缓存,让界面立刻刷新。
///
/// 为什么"我的"页这里不再重新请求用户信息接口:
/// 登录成功时已经把完整用户信息拉下来放进了 currentUserProvider;
/// 头像上传成功后,本次只有 avatar 这一个字段发生变化,
/// 所以前端直接做一次局部更新就够了,没必要再额外请求整份用户资料。
Future<void> _pickAndUploadAvatar() async {
final picked = await PickerService.instance.showPickSheet(context, maxCount: 1);
if (picked == null || picked.isEmpty) return;
final image = picked.first;
// 先显示本地临时预览,让用户选完图后有即时反馈。
setState(() => _pickedAvatar = image);
// ① 读图片字节:Web 走 bytes;原生走 photo_manager 纯 Dart 的 originBytes
// (不 import dart:io,Web 才能编译)
final bytes = image.bytes ?? await image.entity?.originBytes;
if (bytes == null || bytes.isEmpty) {
_toast('读取图片失败,请重试');
return;
}
// ② 压缩 + EXIF 方向修正(后台 isolate 跑,不卡页面)
// 头像只需 512 长边;null = 尺寸超出 14~4000 像素硬限制
final processed = await ImageProcessor.process(
bytes: bytes,
fileName: image.name,
maxDimension: 512,
);
if (processed == null) {
_toast('图片尺寸不符合要求,请重新选择');
return;
}
// ③ OSS 直传:申请路径 → 拿 STS → V4 签名 → PUT(拦截器自动带 Token)
try {
final fileName = _replaceExtension(
image.name ?? 'avatar_${DateTime.now().millisecondsSinceEpoch}.jpg',
processed.extension,
);
debugPrint(
'头像处理:${processed.originalBytes}B -> ${processed.processedBytes}B '
'(${processed.contentType}, $fileName)',
);
final url = await OssUploader.uploadImage(
bytes: processed.bytes,
fileName: fileName,
contentType: processed.contentType,
scene: UploadScene.avatar,
);
// ④ 通知业务接口"当前用户头像已改成这个 OSS 地址"。
// 这一步对应 UniApp 的 UpdateUserAvatar({ avatar: url }),
// 目的是把服务器里当前用户资料的 avatar 字段真正更新掉。
await UserApi.updateUserAvatar(url);
// ⑤ 头像字段只改本地当前用户信息即可,不需要"我的"页再重新请求接口。
// updateAvatar(url) 内部会做两件事:
// - 更新 currentUserProvider 的内存状态;
// - 把更新后的用户对象写回 SharedPreferences。
// 因此所有 ref.watch(currentUserProvider) 的页面都会一起刷新。
await ref.read(currentUserProvider.notifier).updateAvatar(url);
// 上传成功后清掉本地临时预览,改为展示服务器返回的头像 URL。
// 否则界面会一直优先显示 _pickedAvatar,看起来像"方向没修正",
// 实际上你看到的是本地原图/相册缩略图,不是处理后的上传结果。
if (mounted) {
setState(() => _pickedAvatar = null);
}
if (!mounted) return;
_toast('头像上传成功');
} catch (e) {
if (!mounted) return;
_toast('头像上传失败:$e');
}
}
/// 轻提示(统一用 SnackBar)
void _toast(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}
/// 头像主体:按「本地新图 → 网络图 → 默认首字圆」三级渲染
Widget _buildAvatar() {
final size = 62.w;
final picked = _pickedAvatar;
// 一级:刚选中的本地头像
if (picked != null) {
return Hero(
tag: _avatarHeroTag,
child: ClipOval(
child: PickerService.buildThumb(picked, width: size, height: size),
),
);
}
// 二级:服务器返回的网络头像(加载失败自动降级到默认头像)
if (widget.avatar.isNotEmpty) {
return Hero(
tag: _avatarHeroTag,
child: ClipOval(
child: Image.network(
widget.avatar,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => _buildDefaultAvatar(),
),
),
);
}
// 三级:默认头像(首字圆)
return _buildDefaultAvatar();
}
/// 默认头像:用户名首字圆形色块
Widget _buildDefaultAvatar() {
final avatarChar =
widget.name.isNotEmpty ? widget.name.characters.first : '?';
return Container(
width: 62.w,
height: 62.h,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF4A90E2),
),
alignment: Alignment.center,
child: Text(
avatarChar,
style: TextStyle(
fontSize: 26.sp,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 0),
child: Row(
children: [
// 头像(点击可更换)
GestureDetector(
onTap: _onAvatarTap,
child: Stack(
children: [
_buildAvatar(),
// 右下角小相机角标,暗示可点击换头像
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 20.w,
height: 20.h,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFF4A90E2),
border: Border.fromBorderSide(
BorderSide(color: Colors.white, width: 2),
),
),
child: Icon(
Icons.photo_camera,
size: 11.w,
color: Colors.white,
),
),
),
],
),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.name,
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.bold,
color: const Color(0xFF1A1A1A),
),
),
SizedBox(height: 4.h),
Text(
widget.className,
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF8A8A8A)),
),
],
),
),
],
),
);
}
}
String _replaceExtension(String fileName, String extension) {
final dotIndex = fileName.lastIndexOf('.');
final safeExtension = extension.toLowerCase();
if (dotIndex <= 0) return '$fileName.$safeExtension';
return '${fileName.substring(0, dotIndex)}.$safeExtension';
}
/// 菜单列表项数据
class _MenuItem {
const _MenuItem({
required this.color,
required this.title,
required this.onTap,
this.iconData,
this.trailingText,
});
final Color color;
final String title;
final VoidCallback onTap; // 点击跳转
final IconData? iconData; // 普通图标
final String? trailingText; // 右侧文字,如 "382积分"
}
/// 圆角白色卡片菜单容器
class _MenuCard extends StatelessWidget {
const _MenuCard({required this.items});
final List<_MenuItem> items;
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.symmetric(horizontal: 16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
children: [
for (int i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(
height: 1,
thickness: 1,
indent: 64.w,
endIndent: 16.w,
color: const Color(0xFFF0F2F5),
),
_buildRow(items[i]),
],
],
),
);
}
Widget _buildRow(_MenuItem item) {
return InkWell(
onTap: item.onTap,
child: SizedBox(
height: 64.h,
child: Row(
children: [
SizedBox(width: 16.w),
// 左侧彩色方形图标
Container(
width: 36.w,
height: 36.h,
decoration: BoxDecoration(
color: item.color,
borderRadius: BorderRadius.circular(10.r),
),
alignment: Alignment.center,
child: Icon(item.iconData, color: Colors.white, size: 20.w),
),
SizedBox(width: 12.w),
// 中间标题
Expanded(
child: Text(
item.title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: const Color(0xFF333333),
),
),
),
// 右侧文字(如积分)
if (item.trailingText != null) ...[
Text(
item.trailingText!,
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)),
),
SizedBox(width: 2.w),
],
// 右侧浅蓝色箭头
const Icon(Icons.chevron_right, size: 22, color: Color(0xFF7FB0F2)),
SizedBox(width: 10.w),
],
),
),
);
}
}
+230
View File
@@ -0,0 +1,230 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/api/my_point/api.dart';
import 'package:new_learn/core/api/my_point/model.dart';
import 'package:new_learn/core/logger/app_talker.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
/// ============================================================
/// 【MyPointPage】我的积分页(点「我的」tab 的"我的积分"进入)
///
/// 一、数据来源(对齐 Vue3 端 api/userPoint/index.ts):
/// 1. 顶部积分卡:当前总积分来自 Riverpod 全局用户
/// currentUser.userPoints(登录时已拉取,无需再请求接口)
/// 2. 积分明细列表:GetUserPointRecordPagedList(游标分页,见下方)
///
/// 二、列表为什么用 CommonPagedList:
/// 积分记录可能几千条,必须懒加载(只渲染屏幕可见行)+ 上拉分页。
/// 组件基于 infinite_scroll_pagination 封装(Flutter Favorite 认证),
/// 已内置:下拉刷新 / 触底加载 / 防重入 / 失败重试 / 空状态 / 没有更多。
/// 页面只做两件事:load(调接口)+ buildItem(画每一行)。
///
/// 三、页码分页:
/// CommonPagedList 的 load 只给 pageKey(首页 1,之后 +1),业务把
/// pageKey 当 pageIndex 用。hasNext 用 total 判断:总条数 > 已请求条数。
/// 对应 Vue3 端 type.ts 的 UserPointRecordRequest{pageIndex,pageSize}。
///
/// 四、高频坑:
/// 1. 列表要包在 Expanded 里(Column 里无限高度会报 unbounded height);
/// 2. 积分正负号决定颜色:points>0 绿色加分,<0 橙色扣分;
/// 3. bizTime 后端直接给字符串,不需要自己格式化;
/// 4. go_router ShellRoute push 出来的页面不自动显示返回箭头
/// (issue #144687),必须手动加 leading,见 build 里 AppBar。
/// ============================================================
class MyPointPage extends ConsumerStatefulWidget {
const MyPointPage({super.key});
@override
ConsumerState<MyPointPage> createState() => _MyPointPageState();
}
class _MyPointPageState extends ConsumerState<MyPointPage> {
/// 每页条数(CommonPagedList 无 pageSize 概念,由业务在 load 里自定)
static const int _pageSize = 10;
@override
void initState() {
super.initState();
talker.debug('打开页面:我的积分');
}
@override
Widget build(BuildContext context) {
// 全局用户:当前总积分(登录后才有值;未登录 0)
final user = ref.watch(currentUserProvider).value;
return Scaffold(
appBar: AppBar(
title: const Text('我的积分'),
// go_router 已知 issue #144687:从 ShellRoute(4 tab)内 push 出来的
// 页面,AppBar 不自动显示返回箭头。这里手动加:
// 能 pop(正常从"我的"进入)→ pop 回上一页;
// 不能 pop(如 Web 深链/刷新直达)→ 兜底回"我的"tab。
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () =>
context.canPop() ? context.pop() : context.go('/my'),
),
),
body: Column(
children: [
// ---------- 顶部积分卡 ----------
_PointHeaderCard(totalPoints: user?.userPoints ?? 0),
// ---------- 积分明细列表(分页) ----------
Expanded(
child: CommonPagedList<UserPointRecord>(
load: (page) async {
talker.debug('加载积分记录:pageIndex=$page pageSize=$_pageSize');
// pageKey 当 pageIndex;CommonPagedList 从 1 开始递增
final res = await UserPointApi.instance.getRecordPage(
pageIndex: page,
pageSize: _pageSize,
);
return PagingResult<UserPointRecord>(
data: res.data,
hasNext: res.total > page * _pageSize,
);
},
buildItem: (context, item, index) => _RecordRow(record: item),
),
),
],
),
);
}
}
/// 顶部积分卡:当前总积分
class _PointHeaderCard extends StatelessWidget {
const _PointHeaderCard({required this.totalPoints});
final double totalPoints;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 12.h),
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 18.h),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFF9800), Color(0xFFFFB74D)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'当前积分',
style: TextStyle(fontSize: 13.sp, color: Colors.white.withValues(alpha: 0.9)),
),
SizedBox(height: 6.h),
Text(
// 积分可能是小数(接口返回 105.5)
totalPoints.toString(),
style: TextStyle(
fontSize: 34.sp,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8.h),
Text(
'积分明细 · 变动随时记录',
style: TextStyle(
fontSize: 12.sp,
color: Colors.white70,
),
),
],
),
);
}
}
/// 单条积分记录行
class _RecordRow extends StatelessWidget {
const _RecordRow({required this.record});
final UserPointRecord record;
@override
Widget build(BuildContext context) {
// 正负号决定颜色:加分绿 / 扣分橙 / 0 灰
final isAdd = record.points > 0;
final isSub = record.points < 0;
final Color signColor = isAdd
? const Color(0xFF2E7D32)
: (isSub ? const Color(0xFFE65100) : const Color(0xFF9E9E9E));
final Color iconBg = isAdd
? const Color(0xFFE8F5E9)
: (isSub ? const Color(0xFFFFF3E0) : const Color(0xFFF5F5F5));
final String sign = isAdd ? '+' : (isSub ? '-' : '');
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h),
child: Row(
children: [
// 左侧圆形图标(加减符号)
Container(
width: 40.w,
height: 40.h,
decoration: BoxDecoration(
color: iconBg,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
sign,
style: TextStyle(
fontSize: 22.sp,
color: signColor,
fontWeight: FontWeight.bold,
),
),
),
SizedBox(width: 12.w),
// 中间:说明 + 时间
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
record.pointMessage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w500,
color: const Color(0xFF333333),
),
),
SizedBox(height: 4.h),
Text(
record.bizTime,
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
],
),
),
SizedBox(width: 8.w),
// 右侧:积分变动值
Text(
'$sign${record.points.abs()}',
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: signColor,
),
),
],
),
);
}
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【RecitePage】「背诵」tab 页 —— 空页面占位
///
/// 后续开发计划:这里放背单词的卡片流(正面/背面翻转、记忆曲线等)。
/// 现在先占位,保证 4 个 tab 都能正常切换。
/// ============================================================
class RecitePage extends StatelessWidget {
const RecitePage({super.key});
@override
Widget build(BuildContext context) {
return const _Placeholder(
icon: Icons.auto_stories,
title: '背诵',
subtitle: '功能开发中,敬请期待',
);
}
}
/// 通用占位组件:一个居中图标 + 标题 + 副标题
class _Placeholder extends StatelessWidget {
const _Placeholder({
required this.icon,
required this.title,
required this.subtitle,
});
final IconData icon;
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64.w, color: const Color(0xFFC9CFD8)),
SizedBox(height: 12.h),
Text(
title,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.bold,
color: const Color(0xFF666666),
),
),
SizedBox(height: 6.h),
Text(
subtitle,
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF999999)),
),
],
),
),
);
}
}
+317
View File
@@ -0,0 +1,317 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/video_player_page.dart';
import 'package:new_learn/features/auth/login_page.dart';
import 'package:new_learn/pages/demo/align_demo.dart';
import 'package:new_learn/pages/demo/animation_demo.dart';
import 'package:new_learn/pages/demo/form_demo.dart';
import 'package:new_learn/pages/demo/grid_demo.dart';
import 'package:new_learn/pages/demo/image_demo.dart';
import 'package:new_learn/pages/demo/input_demo.dart';
import 'package:new_learn/pages/demo/isar_watch_demo.dart';
import 'package:new_learn/pages/demo/list_demo.dart';
import 'package:new_learn/pages/demo/note_picker_demo.dart';
import 'package:new_learn/pages/demo/oss_upload_demo.dart';
import 'package:new_learn/pages/demo/audio_player_demo.dart';
import 'package:new_learn/pages/demo/rich_text_demo.dart';
import 'package:new_learn/pages/demo/dio_demo.dart';
import 'package:new_learn/pages/demo/dio_pro_demo.dart';
import 'package:new_learn/pages/demo/responsive_demo.dart';
import 'package:new_learn/pages/demo/share_demo.dart';
import 'package:new_learn/pages/demo/seed_palette_demo.dart';
import 'package:new_learn/pages/demo/sliver_demo.dart';
import 'package:new_learn/pages/demo/stack_demo.dart';
import 'package:new_learn/pages/demo/theme_demo.dart';
import 'package:new_learn/pages/demo/text_demo.dart';
import 'package:new_learn/pages/demo/wrong_flow_demo.dart';
import 'package:new_learn/pages/demo/smooth_sheet_demo.dart';
import 'package:new_learn/pages/demo/wrap_demo.dart';
import 'package:new_learn/pages/demo/exam_demo.dart';
/// ============================================================
/// 【TaskPage】「任务」tab 页 —— 学习 Demo 汇总入口
///
/// 一、本页作用:
/// 把之前学的所有组件 Demo 收进一个列表,点击每一项通过
/// Navigator.push 路由跳转到对应页面 —— 这就是真实 App 里
/// 「列表 → 详情」的跳转模型。
///
/// 二、路由跳转的两种写法(都可用):
/// 1. MaterialPageRoute:无需注册路由表,即写即用(本页采用);
/// 2. 命名路由 pushNamed:需在 MaterialApp.routes 里先注册。
/// 推荐本页方式:页面少时最简单;页面多了再统一用命名路由。
///
/// 三、结构化知识点:
/// _DemoItem 是纯数据模型(标题/副标题/图标/构造器),
/// 列表只用数据驱动生成 UI,新增一个 Demo 只需加一行数据。
/// ============================================================
class TaskPage extends StatelessWidget {
const TaskPage({super.key});
/// 全部学习 Demo 的数据清单(新增 Demo 只需在这里加一行)
///
/// 每行四项:标题 / 副标题 / 图标 / 页面构造器(用 _page 包装)。
/// 写页面构造器的三种姿势(任选,见 _page 函数注释):
/// - _page(LoginPage.new) :构造函数 tear-off(最简洁);
/// - 需要给页面传参就写闭包,
/// 如 _page(() => VideoPlayerPage(url: '...'));
/// - 页面能 const 就写 _page(() => const Xxx()),享 const 优化。
/// _page 内部是惰性创建:点击列表项时才 new 页面,不点不实例化。
static final List<_DemoItem> _demos = [
_DemoItem(
'登录页(真实接口)',
'账号/密码 + 协议 + POST /api/v1/User/Login',
Icons.login,
_page(LoginPage.new),
),
_DemoItem(
'Align 对齐布局',
'Alignment 定位 + SizedBox 尺寸',
Icons.align_horizontal_left,
_page(AlignDemoPage.new),
),
_DemoItem(
'Wrap 自动换行 + Chip 标签',
'流式布局、ActionChip/FilterChip',
Icons.wrap_text,
_page(WrapDemoPage.new),
),
_DemoItem(
'Stack 层叠布局',
'Positioned 定位 + 角标',
Icons.layers,
_page(StackDemoPage.new),
),
_DemoItem(
'TextField 输入框',
'controller + dispose + 密码可见',
Icons.text_fields,
_page(InputDemoPage.new),
),
_DemoItem(
'表单校验 Form',
'validator 规则 + 即时校验 + 两段式提交',
Icons.rule,
_page(FormDemoPage.new),
),
_DemoItem(
'动画体系',
'隐式动画 + 答题反馈 + Hero + 自绘进度环',
Icons.auto_awesome,
_page(AnimationDemoPage.new),
),
_DemoItem(
'品牌换肤(两级主题)',
'一级品牌色板 + 二级页面细节(SkyBlue/Red)',
Icons.palette_outlined,
_page(ThemeDemoPage.new),
),
_DemoItem(
'Seed 色板(主题生效演示)',
'Material 组件颜色全部来自 colorSchemeSeed 自动生成',
Icons.palette,
_page(SeedPaletteDemoPage.new),
),
_DemoItem(
'Isar watch 响应式',
'错题本:数据库变化自动刷新(仅真机)',
Icons.sync,
_page(IsarWatchDemoPage.new),
),
_DemoItem(
'接口+Isar+watch 链路',
'答题→接口上报→错题本自动刷新(仅真机)',
Icons.sync_alt,
_page(WrongFlowDemoPage.new),
),
_DemoItem(
'Text 文本组件',
'样式/截断/富文本/手势',
Icons.title,
_page(TextDemoPage.new),
),
_DemoItem(
'Image 图片组件',
'网络图/本地图/圆角/圆形头像',
Icons.image,
_page(ImageDemoPage.new),
),
_DemoItem(
'ListView 列表',
'下拉刷新 + 上拉加载 + 回到顶部',
Icons.view_list,
_page(ListDemoPage.new),
),
_DemoItem(
'GridView 网格',
'count/builder/extent 三种构造',
Icons.grid_view,
_page(GridDemoPage.new),
),
_DemoItem(
'CustomScrollView 混排',
'Sliver 系列 + 可折叠头部',
Icons.view_day,
_page(SliverDemoPage.new),
),
_DemoItem(
'Dio 网络请求封装',
'拦截器 + 错误映射 + 模型',
Icons.cloud_download,
_page(DioDemoPage.new),
),
_DemoItem(
'Dio 生产级深化',
'取消请求 + 进度回调 + 失败重试',
Icons.downloading,
_page(DioProDemoPage.new),
),
_DemoItem(
'笔记图片选择',
'相册多选/拍照 + 缩略图 + 上传占位',
Icons.photo_library_outlined,
_page(NotePickerPage.new),
),
_DemoItem(
'OSS 直传(V4 签名)',
'客户端签名 + PUT 直传阿里云 OSS + 进度条',
Icons.cloud_upload_outlined,
_page(OssUploadDemoPage.new),
),
_DemoItem(
'视频播放',
'video_player + chewie 在线点播',
Icons.play_circle_outline,
// Flutter 官方文档示例视频(bee.mp4),国内网络可直连;
// 以后接真实课程视频时把 url 换成后端地址即可
_page(
() => const VideoPlayerPage(
url:
'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4',
title: '视频播放',
),
),
),
_DemoItem(
'音频播放',
'just_audio 控制条(播放/进度/倍速/循环)',
Icons.music_note,
_page(AudioPlayerDemoPage.new),
),
_DemoItem(
'富文本渲染',
'题目/解析 HTML → widget(audio 自动换组件)',
Icons.text_snippet_outlined,
_page(RichTextDemoPage.new),
),
_DemoItem(
'响应式布局 手机 vs Pad',
'断点判断 + 单列/多列切换',
Icons.phone_iphone,
_page(ResponsiveDemoPage.new),
),
_DemoItem(
'分享功能',
'文本/图片/Widget截图分享(海报)',
Icons.share_outlined,
_page(ShareDemoPage.new),
),
_DemoItem(
'常驻多停靠底部面板',
'smooth_sheets · 3 停靠点 · 内部 ListView 联动',
Icons.pan_tool_outlined,
_page(SmoothSheetDemoPage.new),
),
_DemoItem(
'试卷练习(大数据滑动)',
'PageView.builder 100题 + 题型分发 + 续答持久化',
Icons.description_outlined,
_page(ExamDemoPage.new),
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('任务'),
centerTitle: true,
),
body: ListView.separated(
padding: EdgeInsets.all(16.w),
itemCount: _demos.length,
separatorBuilder: (_, _) => SizedBox(height: 10.h),
itemBuilder: (context, index) {
final demo = _demos[index];
return Card(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r),
side: const BorderSide(color: Color(0xFFEEF1F5)),
),
child: ListTile(
leading: Container(
width: 40.w,
height: 40.h,
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(10.r),
),
child: Icon(demo.icon, color: const Color(0xFF3B82F6)),
),
title: Text(
demo.title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
subtitle: Text(
demo.subtitle,
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
trailing: const Icon(
Icons.chevron_right,
color: Color(0xFFC0C6CF),
),
// 点击 → 路由跳转到对应 Demo 页面
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: demo.builder),
);
},
),
);
},
),
);
}
}
/// 把一个页面构造函数包装成 WidgetBuilder(惰性创建,点击才实例化)
///
/// 原理拆解(对照 _DemoItem.builder 的类型 WidgetBuilder):
/// - WidgetBuilder = Widget Function(BuildContext context),带 context 参数;
/// - 列表项只需要"无参创建页面",用不到 context → _page 把它吞掉;
/// - _page(Xxx.new) 里的 .new 是【构造函数 tear-off】(Dart 2.15+ 语法),
/// 类型为 `Xxx Function()`,等价手写 `() => Xxx()`。
///
/// 三种等价写法(效果相同):
/// _page(Xxx.new) // tear-off,最简洁
/// _page(() => const Xxx()) // 显式闭包 + const 构造(享 const 优化)
/// (_) => const Xxx() // 不用 _page,直接就是 WidgetBuilder
WidgetBuilder _page(Widget Function() create) {
return (_) => create();
}
/// Demo 列表项数据模型
class _DemoItem {
const _DemoItem(this.title, this.subtitle, this.icon, this.builder);
final String title;
final String subtitle;
final IconData icon;
final WidgetBuilder builder;
}
+126
View File
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:new_learn/core/app.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/providers/privacy_provider.dart';
import 'package:new_learn/core/storage/database/app_database.dart';
import 'package:new_learn/core/storage/privacy_storage.dart';
/// App 启动入口 —— 真正的业务代码都在 app.dart / pages/ 里
///
/// 〇、环境 / 品牌参数(--dart-define 编译期注入,详见 core/config/app_config.dart):
/// 日常开发(默认正式环境 + 爱学蝶变,零参数):
/// flutter run
/// 连测试环境调试:
/// flutter run --dart-define=ENV=test
/// 打楚才正式包:
/// flutter build apk --release --dart-define=ENV=prod --dart-define=BRAND=chucai
/// 打豫志测试包:
/// flutter build apk --release --dart-define=ENV=test --dart-define=BRAND=yuzhi
/// BRAND 可选:aixue / chucai / yule / yuzhi / qinxiao / wansi / shucai
/// (品牌表见 core/config/app_brand.dart;不传默认 aixue=爱学蝶变)
///
/// 一、启动顺序(每步都是插件初始化,必须 runApp 前 await):
/// 1. WidgetsFlutterBinding.ensureInitialized():插件通道必须在 runApp 前就绪;
/// 2. await DioClient.restoreToken():冷启动把磁盘上的 Token 回填内存,
/// 这样即便没进登录页,后续请求也自动带 Authorization;
/// 3. await PrivacyStorage.readAccepted():恢复启动隐私协议同意状态;
/// 未同意时,App 首屏会先被 StartupGate + 路由守卫拦住;
/// 4. await AppDatabase.instance.init():初始化本地业务数据库(Isar,
/// 题库/错题/播放记录集合;web 调试模式自动跳过,见 app_database.dart);
/// 5. runApp:ProviderScope 包根(Riverpod 全局状态容器),进入 UI。
///
/// 二、完整路由运行流程(从 main() 到页面渲染):
///
/// main()
/// │
/// ├─ 1. WidgetsFlutterBinding.ensureInitialized()
/// ├─ 2. GoRouter.optionURLReflectsImperativeAPIs = true
/// ├─ 3. await DioClient.restoreToken() ← 恢复登录态
/// ├─ 4. privacyAcceptedNotifier.value = ... ← 恢复隐私同意状态
/// ├─ 5. await AppDatabase.instance.init() ← 初始化 Isar
/// │
/// └─ 6. runApp(ProviderScope(child: TabApp()))
/// │
/// └─ MaterialApp.router(routerConfig: appRouter)
/// │
/// ├─ builder: PrivacyStartupGate ← 视觉第一拦
/// │ ├─ 未同意隐私 → 全屏盖 PrivacyConsentPage
/// │ └─ 已同意隐私 → 放行 child(路由树)
/// │
/// └─ appRouter (GoRouter)
/// │
/// ├─ redirect: _guardLogin ← 每次导航都过一遍
/// │ │
/// │ │ 判断逻辑(按顺序):
/// │ │ ① 未同意隐私 且不在隐私页 → 重定向 /privacy-consent
/// │ │ ② 已同意隐私 且在隐私页 → 已登录去 /,未登录去 /login
/// │ │ ③ 未登录 且不在登录页 → 重定向 /login
/// │ │ ④ 已登录 且在登录页 → 重定向 /(首页)
/// │ │ ⑤ 以上都不满足 → return null(放行)
/// │ │
/// │ └─ 触发时机:
/// │ • 每次 context.push/go 导航时
/// │ • authTick 变化时(登录/登出 → 自动 refresh)
/// │ • privacyAcceptedNotifier 变化时
/// │
/// └─ routes 路由表:
/// │
/// ├─ ShellRoute (MainTabPage) ← 底部 4 tab 壳
/// │ ├─ / → TaskPage (任务)
/// │ ├─ /recite → RecitePage (背诵)
/// │ ├─ /class → ClassPage (班级)
/// │ └─ /my → MyPage (我的)
/// │ │
/// │ └─ MainTabPage.initState
/// │ └─ addPostFrameCallback
/// │ └─ _checkVersion() ← 版本检查(仅冷启动一次)
/// │
/// ├─ /privacy-consent → PrivacyConsentPage
/// ├─ /user-agreement → LegalDocumentPage
/// ├─ /privacy-policy → LegalDocumentPage
/// ├─ /login → LoginPage
/// └─ /myPoint → MyPointPage
///
/// 三、用户正常流程:
///
/// 首次安装:
/// 启动 → PrivacyStartupGate 拦住 → 隐私协议页 → 同意
/// → redirect 重定向 /login → 登录成功 → authTick 变化
/// → redirect 重定向 / → ShellRoute → MainTabPage → 版本检查
///
/// 老用户(已登录):
/// 启动 → 已同意隐私 + 有 Token → redirect 直接放行
/// → / → ShellRoute → MainTabPage → 版本检查
///
/// 四、关键文件对照:
/// main.dart = 启动脚本(本文件)
/// core/app.dart = 根组件(ScreenUtil + MaterialApp + PrivacyStartupGate)
/// core/router/app_router.dart = 路由表 + 登录守卫(对标 vue-router/index.ts)
/// core/router/main_tab_page.dart = 底部 tab 壳 + 版本检查触发点
/// core/router/router_keys.dart = 全局 NavigatorKey
///
/// 想单独跑某个学习 Demo 时,临时改这里,例如:
/// runApp(const ProviderScope(child: MaterialApp(home: DioDemoPage())));
/// 跑完记得改回 runApp(const ProviderScope(child: TabApp()));
Future<void> main() async {
// 需要在启动 App 之前读取本地存储、调用原生插件,必须写在 runApp 之前!
// 作用: 初始化 Flutter 和原生的绑定桥梁
WidgetsFlutterBinding.ensureInitialized();
// go_router 17.x 大坑:命令式导航(context.push/go)默认不写回浏览器
// 地址栏(optionURLReflectsImperativeAPIs 默认 false)。必须在使用
// appRouter(runApp)之前全局开启,否则 web 调试 URL 不随页面变化。
GoRouter.optionURLReflectsImperativeAPIs = true;
// 冷启动恢复登录态(Token 从安全存储回填内存)
await DioClient.instance.restoreToken();
// 冷启动恢复"是否已同意启动隐私协议"状态。
// 这个值会同时驱动:
// 1. app.dart 里的 StartupGate(视觉上先拦住);
// 2. app_router.dart 里的 redirect(路由层再兜底一次)。
privacyAcceptedNotifier.value = await PrivacyStorage.instance.readAccepted();
// 初始化本地业务数据库(Isar 题库/错题/播放记录;web 调试自动跳过)
await AppDatabase.instance.init();
// ProviderScope:Riverpod 所有 provider 的容器,必须包在根
runApp(const ProviderScope(child: TabApp()));
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/providers/user_provider.dart';
/// ============================================================
/// Align 对齐布局演示页 + Riverpod 全局用户信息展示
///
/// 一、Align 两种使用方式:
/// 1. 直接运行本页面:把 main() 改为
/// runApp(const ProviderScope(child: MaterialApp(home: AlignDemoPage())));
/// 2. 复制 body 核心代码到任意 Scaffold 的 body 中:
///
/// body: Container(
/// width: 300,
/// height: 300,
/// color: Colors.grey[200],
/// child: Align(
/// alignment: Alignment.bottomLeft, // 子组件放到父盒子左下角
/// child: Container(...), // 用户信息卡片
/// ),
/// ),
///
/// 二、Flutter 核心布局规则(务必理解):
/// 1. 父组件给子组件传递【约束】(Constraints),子组件在约束范围内决定自身【大小】;
/// 2. Align 会把自身区域扩大到父组件允许的最大范围(宽高撑满),
/// 然后通过 alignment 把子组件放到指定位置;
/// 3. 子组件的 size(宽高)如果不写,Align 会取子组件的固有尺寸;
/// 一旦写了固定 width/height,就由子组件自己决定,Align 不再干预。
///
/// 三、Riverpod 全局状态用法(本次新增重点):
/// - AlignDemoPage 改成 ConsumerWidget(build 多一个 WidgetRef ref 参数);
/// - `ref.watch(currentUserProvider)` 订阅全局用户,登录后任何页面都拿到;
/// - `.value` 是 CurrentUserExtension?:登录了有值,未登录是 null;
/// - 登录页登录成功时会 set 这个 provider,本卡片自动刷新(无需手动 setState)。
///
/// 四、高频坑:
/// 1. 用了 ref 的组件必须继承 ConsumerWidget / ConsumerStatefulWidget,
/// 普通 StatelessWidget 拿不到 ref(编译报错);
/// 2. ref.watch 要在 build 里调用(声明式依赖),ref.read 才能放回调里;
/// 3. 加了 .w/.h/.sp 的 widget 不能再用 const(尺寸是运行期算的)。
/// ============================================================
class AlignDemoPage extends ConsumerWidget {
const AlignDemoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Riverpod 全局用户:登录后全 App 共享,未登录为 null
final user = ref.watch(currentUserProvider).value;
return Scaffold(
appBar: AppBar(title: const Text('Align 对齐布局 Demo11')),
// ---------- 下方即为核心代码,可直接复制到 Scaffold body ----------
body: Container(
width: 300.w,
height: 300.h,
color: Colors.grey[300],
child: Align(
alignment: Alignment.bottomLeft, // 子组件放到父盒子左下角
child: Container(
width: 240.w,
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8.w),
),
child: Column(
mainAxisSize: MainAxisSize.min, // 卡片高度跟随内容
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'用户:${user?.userName ?? '未登录'}',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 6.h),
Text(
'班级:${user?.className ?? '-'}',
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
SizedBox(height: 4.h),
Text(
'积分:${user?.userPoints ?? 0}',
style: TextStyle(color: Colors.white, fontSize: 14.sp),
),
],
),
),
),
),
);
}
}
+481
View File
@@ -0,0 +1,481 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【AnimationDemoPage】Flutter 动画体系 Demo(4 个区域)
///
/// 一、动画体系总览(对标 CSS):
/// 1. 隐式动画(ImplicitlyAnimatedWidget) = CSS transition:
/// 只要属性值变化,组件自己补过渡帧。如 AnimatedContainer/
/// AnimatedOpacity/AnimatedSwitcher。零 controller,最简单。
/// 2. 显式动画(AnimationController) = CSS keyframes:
/// controller 手动驱动(forward/repeat),配合 Tween 定义
/// 起始值曲线,可精确控制时长/曲线/监听状态。最灵活。
/// 3. Hero 共享元素转场 = 页面跳转时元素"飞过去"
/// 4. CustomPaint 自绘 = 用 Canvas 画任意图形
///
/// 二、为什么 Flutter 动画要区分隐式/显式:
/// - 隐式适合"状态切换"(点选变绿、展开收起)——写一行 duration 即可;
/// - 显式适合"连续/回弹/自定义曲线"(答题对错弹跳、点赞缩放)——
/// 需要 controller 控制时序;它是单方向的值驱动,方向/起点自己定。
/// 判断口诀:只需要"A 变成 B 的过渡"→ 隐式;
/// 需要"按我的节奏动起来"→ 显式 controller。
///
/// 三、核心类速查:
/// - AnimationController(vsync: this):驱动一切,durations 定义时长;
/// vsync 必须传(页面 State with SingleTickerProviderStateMixin)
/// - `Tween<double>(begin, end)`:定义 0→1 之间插值出的值
/// - CurvedAnimation / CurveTween:套曲线(easeOut/bounceOut...)
/// - AnimatedBuilder(animation, builder):监听 controller 重建子树
/// - TweenSequence:分段动画(先弹大再回弹 = 两个 Tween 按权重拼)
/// - Hero(tag, child):页面 A/B 里同 tag 的两个组件自动共享转场
///
/// 四、高频坑:
/// 1. AnimationController 必须在 dispose 里 dispose,且页面必须
/// with SingleTickerProviderStateMixin(否则报 Ticker 错误);
/// 2. 隐式动画组件一定要写 duration,漏了就瞬变没有过渡;
/// 3. AnimatedBuilder 的 builder 里别放不会变的子组件(重绘浪费),
/// 不变的子树用 child 参数缓存;
/// 4. CustomPainter 的 shouldRepaint 要正确比较参数,否则 Slider
/// 拖动时不重绘(或一直重绘)。
/// ============================================================
class AnimationDemoPage extends StatefulWidget {
const AnimationDemoPage({super.key});
@override
State<AnimationDemoPage> createState() => _AnimationDemoPageState();
}
class _AnimationDemoPageState extends State<AnimationDemoPage>
with SingleTickerProviderStateMixin {
// ==================== ① 隐式动画状态 ====================
bool _boxExpanded = false; // 方块是否展开
// ==================== ② 显式动画(答题反馈) ====================
/// 唯一 controller:驱动对/错两套动画(forward 到 1.0)
late final AnimationController _answerCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
/// 选对:放大 1.0 → 1.25 → 回弹到 1.0(TweenSequence 分段)
late final Animation<double> _correctScale = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 1.25)
.chain(CurveTween(curve: Curves.easeOut)),
weight: 40,
),
TweenSequenceItem(
tween: Tween(begin: 1.25, end: 1.0)
.chain(CurveTween(curve: Curves.bounceOut)),
weight: 60,
),
]).animate(_answerCtrl);
/// 选错:水平抖动 -8 → 8 → -6 → 0
late final Animation<double> _wrongShake = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(begin: 0.0, end: -8.0)
.chain(CurveTween(curve: Curves.easeOut)),
weight: 20,
),
TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 30),
TweenSequenceItem(tween: Tween(begin: 8.0, end: -6.0), weight: 30),
TweenSequenceItem(tween: Tween(begin: -6.0, end: 0.0), weight: 20),
]).animate(_answerCtrl);
static const String _question = '属性变化自动产生过渡动画,用哪个组件?';
static const List<String> _options = [
'A. AnimationController',
'B. AnimatedContainer',
'C. CustomPaint',
'D. Hero',
];
static const int _rightIndex = 1; // 正确答案 B
int? _selected; // 已选选项(null = 未选)
bool? _isRight; // 本次选择对不对
// ==================== ④ CustomPaint 进度环状态 ====================
double _ringProgress = 0.76; // 0~1,配合 Slider 拖动演示实时重绘
@override
void dispose() {
// 显式动画红线:controller 必须释放
_answerCtrl.dispose();
super.dispose();
}
/// 点击选项:记录选择并触发对应反馈动画
void _onPick(int index) {
setState(() {
_selected = index;
_isRight = index == _rightIndex;
});
_answerCtrl.forward(from: 0); // 每次从 0 重新播放
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('动画体系 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionTitle('① 隐式动画 = CSS transition'),
_buildImplicitCard(),
SizedBox(height: 20.h),
_buildSectionTitle('② 显式动画 = 答题反馈(controller + TweenSequence)'),
_buildAnswerCard(),
SizedBox(height: 20.h),
_buildSectionTitle('③ Hero 共享元素转场'),
_buildHeroCard(),
SizedBox(height: 20.h),
_buildSectionTitle('④ CustomPaint 自绘进度环'),
_buildRingCard(),
],
),
),
);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF3B82F6),
),
);
}
// ==================== ① 隐式动画卡片 ====================
/// AnimatedContainer:属性(宽/圆角/颜色)变化自动补过渡帧
Widget _buildImplicitCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('点击方块:宽 / 圆角 / 颜色 三处属性同时变,'
'AnimatedContainer 自动补过渡'),
SizedBox(height: 12.h),
Center(
child: GestureDetector(
onTap: () => setState(() => _boxExpanded = !_boxExpanded),
child: AnimatedContainer(
// 核心:duration 必须写,否则"瞬变"没有过渡
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _boxExpanded ? 200.w : 120.w,
height: 90.h,
decoration: BoxDecoration(
color: _boxExpanded
? const Color(0xFF3B82F6)
: const Color(0xFFFF9800),
borderRadius: BorderRadius.circular(_boxExpanded ? 45.r : 8.r),
),
alignment: Alignment.center,
child: Text(
_boxExpanded ? '展开了' : '点我',
style: TextStyle(fontSize: 14.sp, color: Colors.white),
),
),
),
),
],
),
);
}
// ==================== ② 显式动画卡片(答题反馈) ====================
Widget _buildAnswerCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_question,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
SizedBox(height: 12.h),
for (int i = 0; i < _options.length; i++) ...[
_buildOption(i),
if (i < _options.length - 1) SizedBox(height: 8.h),
],
SizedBox(height: 12.h),
if (_selected != null)
Text(
_isRight == true ? '✅ 回答正确' : '❌ 回答错误',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w500,
color: _isRight == true
? const Color(0xFF22C55E)
: const Color(0xFFEF4444),
),
),
],
),
);
}
Widget _buildOption(int index) {
final isSelected = _selected == index;
final isRight = isSelected && _isRight == true;
final isWrong = isSelected && _isRight == false;
// 选项本体(颜色由选中/对错决定)
Widget body = Container(
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h),
decoration: BoxDecoration(
color: isRight
? const Color(0xFFE8F7EE)
: isWrong
? const Color(0xFFFDECEA)
: Colors.white,
borderRadius: BorderRadius.circular(8.r),
border: Border.all(
color: isRight
? const Color(0xFF22C55E)
: isWrong
? const Color(0xFFEF4444)
: const Color(0xFFE5E7EB),
),
),
child: Row(
children: [
Icon(
isRight
? Icons.check_circle
: isWrong
? Icons.cancel
: Icons.radio_button_unchecked,
size: 18,
color: isRight
? const Color(0xFF22C55E)
: isWrong
? const Color(0xFFEF4444)
: const Color(0xFF9CA3AF),
),
SizedBox(width: 8.w),
Text(
_options[index],
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF333333),
),
),
],
),
);
// 对:用 AnimatedBuilder 包 Transform.scale 做"弹跳放大"
if (isRight) {
body = AnimatedBuilder(
animation: _correctScale,
// child 缓存不变的子树,避免 builder 反复重建文字
child: body,
builder: (context, child) =>
Transform.scale(scale: _correctScale.value, child: child),
);
}
// 错:水平抖动
if (isWrong) {
body = AnimatedBuilder(
animation: _wrongShake,
child: body,
builder: (context, child) =>
Transform.translate(offset: Offset(_wrongShake.value, 0), child: child),
);
}
return GestureDetector(onTap: () => _onPick(index), child: body);
}
// ==================== ③ Hero 转场卡片 ====================
Widget _buildHeroCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('点击封面 → 共享元素飞到详情页(Hero 同 tag 配对)'),
SizedBox(height: 12.h),
Center(
child: GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const _HeroDetailPage(),
),
),
child: Hero(
// 页面 A/B 中 tag 相同 → 转场时自动共享动画
tag: 'hero-demo-cover',
child: Container(
width: 120.w,
height: 72.h,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF3B82F6), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(12.r),
),
alignment: Alignment.center,
child: const Icon(Icons.school, color: Colors.white, size: 32),
),
),
),
),
],
),
);
}
// ==================== ④ CustomPaint 进度环卡片 ====================
Widget _buildRingCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('CustomPainter 画圆弧 + 居中文字(拖动滑块实时重绘)'),
SizedBox(height: 12.h),
Center(
child: SizedBox(
width: 120.w,
height: 120.w,
child: CustomPaint(
painter: _ProgressRingPainter(
progress: _ringProgress,
color: const Color(0xFF3B82F6),
),
child: Center(
child: Text(
'${(_ringProgress * 100).round()}%',
style: TextStyle(
fontSize: 20.sp,
fontWeight: FontWeight.bold,
color: const Color(0xFF333333),
),
),
),
),
),
),
SizedBox(height: 8.h),
Slider(
value: _ringProgress,
onChanged: (v) => setState(() => _ringProgress = v),
),
],
),
);
}
}
/// Hero 详情页:同 tag 的大图(转场时封面"飞"到这里的尺寸)
class _HeroDetailPage extends StatelessWidget {
const _HeroDetailPage();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Hero 详情页')),
body: Center(
child: Hero(
tag: 'hero-demo-cover',
child: Container(
width: 280.w,
height: 168.h,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF3B82F6), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(16.r),
),
alignment: Alignment.center,
child: const Icon(Icons.school, color: Colors.white, size: 64),
),
),
),
);
}
}
/// 自绘进度环:CustomPainter 在 Canvas 上画两段圆弧
class _ProgressRingPainter extends CustomPainter {
const _ProgressRingPainter({required this.progress, required this.color});
/// 进度 0.0 ~ 1.0
final double progress;
final Color color;
@override
void paint(Canvas canvas, Size size) {
// 圆环几何:内缩一个线宽,让笔画完整落在画布内
const stroke = 10.0;
final rect = Offset.zero & size;
final arcRect = rect.deflate(stroke / 2);
// 背景环(浅灰,画满 360°)
final bgPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..color = const Color(0xFFE5E7EB);
canvas.drawArc(arcRect, -0.5 * 3.14159, 2 * 3.14159, false, bgPaint);
// 前景环(从 12 点方向顺时针画 progress 的比例)
final fgPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round // 圆头端点
..color = color;
canvas.drawArc(
arcRect,
-0.5 * 3.14159, // 起点:-90° = 12 点方向
2 * 3.14159 * progress,
false,
fgPaint,
);
}
/// 滑块拖动时参数变了才重绘(返回 true = 重绘)
@override
bool shouldRepaint(_ProgressRingPainter oldDelegate) {
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}
+80
View File
@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/audio_player_card.dart';
/// ============================================================
/// 【AudioPlayerDemoPage】音频播放 Demo(just_audio 控制条组件)
///
/// 一、本页解决什么问题:
/// 展示 AudioPlayerCard 的用法:传 asset 即可得到一个
/// 完整的音频控制条(播放/暂停、进度拖动、倍速、循环)。
///
/// 二、音频源说明:
/// 用【本地 assets 音频】做 demo:本地文件 seek 跳播无需重新缓冲,
/// 进度条拖动秒响应(在线流 seek 会重新缓冲,见组件文档"高频坑")。
/// 以后接真实课程音频时,把 asset 换成 url 传后端在线地址即可。
///
/// 三、高频坑:
/// 1. AudioPlayerCard 只是"控制条",不是整页 —— 它设计成可嵌进
/// 任意列表/详情页(如课程详情里的音频小节);
/// 2. 切后台会自动暂停(组件内部处理),无需业务页操心;
/// 3. 若要"列表连续播放",后面在 AudioPlayerCard 上加
/// setAudioSources + 上一首/下一首即可(just_audio 原生支持);
/// 4. 网络流 seek 后会有一次"缓冲中 X%"(进度条浅色区域 = 已缓冲),
/// 这是 ExoPlayer 的正常机制,不是卡死。
/// ============================================================
class AudioPlayerDemoPage extends StatelessWidget {
const AudioPlayerDemoPage({super.key});
/// 本地测试音频(30 秒,来自 assets/audio/sample-30s.mp3)
// static const String _audioAsset = 'assets/audio/sample-30s.mp3';
/// 【临时调试】网络源:观察 seek 后进度条的"缓冲区域 + 缓冲中 X%"。
/// ⚠ 踩坑:之前用 samplelib.com —— 实测它的服务器【不支持 HTTP Range】
/// (Range 请求返回 200 而非 206),导致:第一次点进度条中间段,
/// ExoPlayer 无法字节定位 → 位置被重置回起点;第二次打开因为有系统
/// 缓存 → 看似正常(会误导排查方向!)。
/// 现用 SoundHelix(curl 实测 206 Partial Content + Accept-Ranges: bytes),
/// 服务器在境外,网络慢 = 缓冲条涨得慢看得清,正好演示缓冲机制。
/// 以后接自己的课程音频,务必先验收服务器支持 Range:
/// curl -s -D - -o NUL -H "Range: bytes=0-1023" <音频url>
/// 看到 HTTP/1.1 206 Partial Content 才算支持 seek 跳播。
static const String _audioUrl =
'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('音频播放'),
centerTitle: true,
),
body: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
children: [
// 音频控制条
// 【临时调试】切网络源看缓冲条;要回本地源时取消下面注释
// 并把上面的 url 改回 asset。
// const AudioPlayerCard(
// asset: _audioAsset,
// title: '本地测试音频(30 秒)',
// autoPlay: true,
// ),
const AudioPlayerCard(
url: _audioUrl,
title: '网络测试音频(约 6 分钟)',
autoPlay: true,
),
SizedBox(height: 20.h),
const Text(
'支持:播放/暂停、进度拖动(本地秒响应)、倍速切换、循环;\n切后台自动暂停;全部通过 props 配置。',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Color(0xFF999999)),
),
],
),
),
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 详情页
/// 独立文件 + 公开类 → 别的地方用 import 引入即可
/// 接收两种参数:构造函数参数(方式1)或命名路由 arguments(方式3)
class DetailPage extends StatelessWidget {
const DetailPage({super.key, this.name = '默认', this.age = 0});
final String name;
final int age;
@override
Widget build(BuildContext context) {
// 读取命名路由的 arguments(从 pushNamed 进来时才有)
final args = ModalRoute.of(context)?.settings.arguments;
final argMap = args is Map<String, dynamic> ? args : null;
return Scaffold(
appBar: AppBar(title: const Text('详情页')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 优先用 arguments 里的值,否则用构造函数传的值
Text(
'姓名:${argMap?['name'] ?? name}',
style: TextStyle(fontSize: 20.sp),
),
SizedBox(height: 8.h),
Text(
'年龄:${argMap?['age'] ?? age}',
style: TextStyle(fontSize: 20.sp),
),
SizedBox(height: 24.h),
// 手动 pop(AppBar 的返回箭头也是 pop)
OutlinedButton(
onPressed: () => Navigator.pop(context),
child: const Text('返回上一页'),
),
],
),
),
);
}
}
+214
View File
@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/network/dio_client.dart';
import 'package:new_learn/core/network/post_api.dart';
/// ============================================================
/// Dio 网络请求 Demo 页
///
/// 一、演示内容:
/// 1. GET 单条:PostApi.fetchPost(1) → 加载中/成功/失败三种 UI;
/// 2. GET 列表:PostApi.fetchPosts() → 展示 `List<PostModel>`;
/// 3. POST 新建:PostApi.createPost() → 展示服务器回执;
/// 4. 错误兜底:请求不存在的路径,演示统一错误提示。
///
/// 二、页面写法要点:
/// 1. 请求期间 _loading=true 显示 CircularProgressIndicator,
/// 按钮禁用,防止重复点击;
/// 2. try/catch 只 catch ApiException —— 封装层已把 DioException
/// 翻译成中文提示,页面不用关心底层细节;
/// 3. 所有 await 之后 setState 前必须 if (!mounted) return;
/// (用户可能已切走页面,context 已失效)。
/// ============================================================
class DioDemoPage extends StatefulWidget {
const DioDemoPage({super.key});
@override
State<DioDemoPage> createState() => _DioDemoPageState();
}
class _DioDemoPageState extends State<DioDemoPage> {
bool _loading = false; // 请求中
String? _error; // 错误提示
String _resultTitle = '点击上方按钮发起请求'; // 结果区标题
String _resultBody = ''; // 结果区内容
/// 统一请求入口:loading → 请求 → 成功/失败 → 结束
Future<void> _run(Future<void> Function() action) async {
setState(() {
_loading = true;
_error = null;
});
try {
await action();
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _error = '请求失败:${e.message}');
} catch (e) {
if (!mounted) return;
setState(() => _error = '未知异常:$e');
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// GET 单条帖子
Future<void> _fetchOne() async {
await _run(() async {
final post = await PostApi.fetchPost(1);
if (!mounted) return;
setState(() {
_resultTitle = 'GET /posts/1 成功';
_resultBody = 'id: ${post.id}\nuserId: ${post.userId}\n'
'title: ${post.title}\nbody: ${post.body}';
});
});
}
/// GET 帖子列表(前 5 条)
Future<void> _fetchList() async {
await _run(() async {
final posts = await PostApi.fetchPosts(userId: 1);
if (!mounted) return;
setState(() {
_resultTitle = 'GET /posts?userId=1 成功,共 ${posts.length} 条';
_resultBody = posts
.take(5)
.map((p) => '#${p.id} ${p.title}')
.join('\n');
});
});
}
/// POST 新建帖子
Future<void> _create() async {
await _run(() async {
final post = await PostApi.createPost(
title: 'Flutter 学习 Demo',
body: 'Dio 封装 POST 请求测试',
);
if (!mounted) return;
setState(() {
_resultTitle = 'POST /posts 成功(模拟创建)';
_resultBody = 'id: ${post.id}\nuserId: ${post.userId}\n'
'title: ${post.title}\nbody: ${post.body}';
});
});
}
/// 错误演示:请求不存在的接口
Future<void> _fetchError() async {
await _run(() async {
await DioClient.instance.get('/not-exist-path');
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dio 网络请求封装 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 三个操作按钮
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: _loading ? null : _fetchOne,
icon: const Icon(Icons.south_west),
label: const Text('GET 单条'),
),
FilledButton.icon(
onPressed: _loading ? null : _fetchList,
icon: const Icon(Icons.list),
label: const Text('GET 列表'),
),
FilledButton.icon(
onPressed: _loading ? null : _create,
icon: const Icon(Icons.add),
label: const Text('POST 新建'),
),
OutlinedButton.icon(
onPressed: _loading ? null : _fetchError,
icon: const Icon(Icons.warning_amber),
label: const Text('故意报错'),
),
],
),
SizedBox(height: 20.h),
// 加载中
if (_loading)
Padding(
padding: EdgeInsets.symmetric(vertical: 40.h),
child: Center(child: CircularProgressIndicator()),
),
// 错误提示
if (_error != null)
Container(
width: double.infinity,
margin: EdgeInsets.only(top: 12.h),
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: const Color(0xFFFDECEA),
borderRadius: BorderRadius.circular(8.r),
),
child: Text(
_error!,
style: const TextStyle(color: Color(0xFFD32F2F)),
),
),
// 结果展示
SizedBox(height: 12.h),
Container(
width: double.infinity,
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_resultTitle,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
SizedBox(height: 8.h),
SelectableText(
_resultBody.isEmpty ? '(暂无数据)' : _resultBody,
style: TextStyle(
fontSize: 13.sp,
height: 1.6,
color: Color(0xFF666666),
),
),
],
),
),
SizedBox(height: 12.h),
// 日志提示
Text(
'💡 请求/响应日志在控制台打印(debugPrint),'
'可在 DioClient 拦截器里查看完整 URL。',
style: TextStyle(fontSize: 12.sp, color: Color(0xFF999999)),
),
],
),
),
);
}
}
+218
View File
@@ -0,0 +1,218 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/network/dio_client.dart';
/// ============================================================
/// 【DioProDemoPage】Dio 生产级能力 Demo(取消 / 进度 / 重试)
///
/// 一、演示三个生产级场景:
/// 1. 下载进度:onReceiveProgress 实时百分比(配合 LinearProgressIndicator);
/// 2. 请求取消:CancelToken 用户手动取消 + 页面销毁自动取消(防泄漏);
/// 3. 失败重试:网络层错误自动指数退避重试 N 次(控制台看 ↻ 日志)。
///
/// 二、为什么需要 CancelToken:
/// 页面销毁但请求还在跑 → Future 没人接,响应回调还在 setState
/// (对已销毁的 State setState 会报错/泄漏)。做法:
/// 页面持有一个 CancelToken,dispose 时 cancel(),请求立即抛
/// DioExceptionType.cancel → _mapError 翻译成"请求已取消",
/// 回调里的 setState 不会执行到。
///
/// 三、为什么重试只对"网络层错误"生效:
/// - 可重试:connectionError(断网/域名解析失败)、超时、闪断;
/// - 不可重试:主动取消(用户意图)、badResponse(4xx/5xx,
/// 重试大概率同样失败,POST 还会重复提交数据)。
/// 这是生产级的取舍,见 dio_client.dart 的 _canRetry。
///
/// 四、高频坑:
/// 1. onReceiveProgress 里 setState 前必须判 mounted(回调频率高);
/// 2. total <= 0 时(响应没有 Content-Length)进度除零,要兜底;
/// 3. CancelToken 一旦取消就不能复用,每次请求要新建;
/// 4. 取消会走 onError 分支(DioExceptionType.cancel),不是不走回调;
/// 5. Web 调试跨域(CORS):application/json 是非简单头,跨域 GET 会先
/// 发 OPTIONS 预检,静态服务器不支持就 405 被拦。下载媒体/图片时
/// 用 headers 覆盖成 text/plain 简单头即可绕过(真机无此问题)。
/// ============================================================
class DioProDemoPage extends StatefulWidget {
const DioProDemoPage({super.key});
@override
State<DioProDemoPage> createState() => _DioProDemoPageState();
}
class _DioProDemoPageState extends State<DioProDemoPage> {
/// 大文件地址(约 2MB,用来让进度条动起来;绝对 URL 会覆盖 baseUrl)
static const String _bigUrl =
'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4';
CancelToken? _token; // 当前下载的取消凭证
double _progress = 0; // 0.0 ~ 1.0(未开始为 0)
bool _downloading = false; // 下载中
bool _retryEnabled = true; // 重试开关
String _log = ''; // 操作日志
@override
void dispose() {
// 页面销毁 → 自动取消在途请求(核心防泄漏点)
_token?.cancel('页面已销毁');
super.dispose();
}
/// 开始下载:带进度回调 + 可取消 + 可重试
Future<void> _startDownload() async {
if (_downloading) return;
final token = CancelToken(); // 每个请求新建一个(取消后不可复用)
_token = token;
setState(() {
_downloading = true;
_progress = 0;
_log = '';
});
try {
await DioClient.instance.get(
_bigUrl,
cancelToken: token,
// Web 调试必加!全局头是 application/json(非简单头),跨域 GET
// 会先发 OPTIONS 预检,flutter.github.io 的静态服务器不支持预检
// (返回 405)→ 被浏览器 CORS 拦截。改成 text/plain 简单头后
// 不再预检,直接放行(源已带 Access-Control-Allow-Origin: *)。
headers: const {'Content-Type': 'text/plain'},
// 下载进度回调:(已接收字节, 总字节)。total<=0 时兜底成 0
onReceiveProgress: (received, total) {
if (!mounted || token.isCancelled) return;
setState(() => _progress = total <= 0 ? 0 : received / total);
},
retries: _retryEnabled ? 2 : 0,
);
if (!mounted) return;
setState(() {
_log += '✅ 下载完成,共 ${(_progress * 100).round()}%\n';
_downloading = false;
});
} on ApiException catch (e) {
// 取消也会走到这里("请求已取消"),统一复位 UI
if (!mounted) return;
setState(() {
_log += '✖ ${e.message}\n';
_downloading = false;
});
}
}
/// 用户手动取消当前下载
void _cancelDownload() {
_token?.cancel('用户点击取消');
setState(() => _log += '⏹ 已发起取消,等待请求中断...\n');
}
/// 重试演示:请求不存在的域名 → DNS 解析失败(connectionError)
/// → 自动重试 N 次(指数退避)→ 抛"网络异常"
Future<void> _testRetry() async {
if (_downloading) return;
final stopwatch = Stopwatch()..start();
setState(() => _log = '');
try {
await DioClient.instance.get(
'https://no-such-domain-abcxyz.com/api/test',
retries: _retryEnabled ? 3 : 0,
);
} on ApiException catch (e) {
if (!mounted) return;
stopwatch.stop();
setState(() {
_log = '✖ ${e.message}\n'
'⏱ 共耗时 ${stopwatch.elapsed.inMilliseconds}ms\n'
'(重试开关:${_retryEnabled ? '开' : '关'})\n'
'📋 看控制台日志:重试开时有 4 行请求 + 3 行 ↻ 重试';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dio 生产级能力 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 重试开关
SwitchListTile(
value: _retryEnabled,
onChanged: (v) => setState(() => _retryEnabled = v),
title: const Text('失败自动重试(指数退避)'),
subtitle: const Text('重试开 → 下载重试 2 次 / 错误域名重试 3 次'),
contentPadding: EdgeInsets.zero,
),
SizedBox(height: 8.h),
// 操作按钮
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: _downloading ? null : _startDownload,
icon: const Icon(Icons.download),
label: const Text('开始下载'),
),
OutlinedButton.icon(
onPressed: _downloading ? _cancelDownload : null,
icon: const Icon(Icons.stop_circle_outlined),
label: const Text('取消下载'),
),
OutlinedButton.icon(
onPressed: _downloading ? null : _testRetry,
icon: const Icon(Icons.refresh),
label: const Text('重试演示'),
),
],
),
SizedBox(height: 20.h),
// 下载进度
Text(
'下载进度:${(_progress * 100).round()}%',
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF666666)),
),
SizedBox(height: 8.h),
LinearProgressIndicator(
value: _progress,
minHeight: 8,
borderRadius: BorderRadius.circular(4.r),
backgroundColor: const Color(0xFFE3E8EF),
color: const Color(0xFF3B82F6),
),
SizedBox(height: 20.h),
// 日志区
Container(
width: double.infinity,
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: SelectableText(
_log.isEmpty ? '(操作日志,如:✅ 下载完成 / ✖ 网络异常)' : _log,
style: TextStyle(
fontSize: 13.sp,
height: 1.6,
color: const Color(0xFF666666),
),
),
),
SizedBox(height: 12.h),
Text(
'💡 上传进度用法:DioClient.post(path, data: FormData.fromMap({...}), '
'onSendProgress: (sent, total) {...}) —— 笔记图片上传的下一步就接它。',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
),
],
),
),
);
}
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/features/exam/exam_page.dart';
import 'package:new_learn/features/exam/paper_provider.dart';
/// ============================================================
/// 【ExamDemoPage】试卷练习 Demo 入口 —— 大数据滑动验证页
///
/// 一、本 Demo 验证什么(对标 Vue3 swiper 性能瓶颈):
/// PageView.builder 懒加载:100 道题只构建视口 ±1 页,
/// 滑出的页面 Element 自动回收 → 大数组不卡;
/// 同时验证题型分发(单选/多选/判断/解答)与续答持久化。
///
/// 二、演示要点:
/// 1. 选择题目数量(50/100/200)→ 开始练习;
/// 2. 快速左右滑动感受流畅度(allowImplicitScrolling 预构建);
/// 3. 答几题后退出页面 → 重新进入 → SnackBar 提示恢复进度;
/// 4. 交卷 → 自动判分(客观题)+ 清理本地进度。
///
/// 三、续答持久化说明:
/// 答案主状态在 Riverpod(内存),Isar AnswerRecord 是备份层;
/// Web 调试时 store 为空实现 → 自动降级为纯内存(不落盘)。
/// ============================================================
class ExamDemoPage extends ConsumerWidget {
const ExamDemoPage({super.key});
static const List<int> _sizes = [50, 100, 200];
@override
Widget build(BuildContext context, WidgetRef ref) {
final size = ref.watch(paperSizeProvider);
return Scaffold(
appBar: AppBar(title: const Text('试卷练习 Demo(大数据滑动)')),
body: ListView(
padding: EdgeInsets.all(16.w),
children: [
_buildTipCard(),
SizedBox(height: 16.h),
_buildSizeCard(context, ref, size),
SizedBox(height: 16.h),
FilledButton.icon(
style: FilledButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 14.h),
),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ExamPage()),
);
},
icon: const Icon(Icons.play_arrow),
label: Text(
'开始练习($size 题)',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
),
SizedBox(height: 16.h),
const Text(
'架构要点',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF3B82F6),
),
),
SizedBox(height: 8.h),
const _ArchPoint(
icon: Icons.bolt,
title: 'PageView.builder 懒加载',
desc: '只构建视口 ±1 页,滑出即回收;allowImplicitScrolling 预构建相邻页,'
'100 题滑动不掉帧 —— 规避 Vue3 swiper 全量 DOM 挂载的瓶颈。',
),
SizedBox(height: 10.h),
const _ArchPoint(
icon: Icons.account_tree_outlined,
title: '题型分发(QuestionCard)',
desc: '按 question.type switch 到单选/多选/判断/解答视图;'
'组件纯展示不碰 provider,ExamPage 唯一桥接 Riverpod。',
),
SizedBox(height: 10.h),
const _ArchPoint(
icon: Icons.save_outlined,
title: '续答持久化(Riverpod + Isar)',
desc: '答案主状态在内存,每次作答异步写 AnswerRecord(Isar);'
'退出重进自动恢复进度,交卷成功后按 paperId 清理。',
),
],
),
);
}
Widget _buildTipCard() {
return Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(12.r),
),
child: const Text(
'模拟真实试卷:100 题含单选/多选/判断/解答四种题型,'
'题干为 HTML 富文本(RichTextContent 渲染)。'
'快速滑动体验大数据流畅度;答几题后退出一键返回,重新进入可恢复进度。',
style: TextStyle(fontSize: 13, height: 1.6, color: Color(0xFF3B82F6)),
),
);
}
Widget _buildSizeCard(BuildContext context, WidgetRef ref, int size) {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'试卷题目数量(大数据规模)',
style: TextStyle(
fontSize: 14.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF333333),
),
),
SizedBox(height: 12.h),
SegmentedButton<int>(
segments: [
for (final s in _sizes)
ButtonSegment(value: s, label: Text('$s 题')),
],
selected: {size},
onSelectionChanged: (selection) {
ref.read(paperSizeProvider.notifier).setSize(selection.first);
},
),
],
),
);
}
}
/// 架构要点行(图标 + 标题 + 描述)
class _ArchPoint extends StatelessWidget {
const _ArchPoint({
required this.icon,
required this.title,
required this.desc,
});
final IconData icon;
final String title;
final String desc;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(12.w),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
border: Border.all(color: const Color(0xFFEEF1F5)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: const Color(0xFF3B82F6)),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF333333),
),
),
SizedBox(height: 4.h),
Text(
desc,
style: TextStyle(
fontSize: 12.sp,
height: 1.6,
color: const Color(0xFF666666),
),
),
],
),
),
],
),
);
}
}
+357
View File
@@ -0,0 +1,357 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// 【FormDemoPage】表单校验 Demo(Form + TextFormField + validator)
///
/// 一、和 Vue 表单校验的对照(关键差异):
/// Vue: v-model 双向绑定 + rules 规则对象 + validate()(async-validator)
/// Flutter: TextEditingController 持有值(无 v-model)+
/// 每个 TextFormField 自带 validator 函数 +
/// formKey.currentState.validate() 统一触发
/// validator 是【同步函数】,返回 String?:
/// null → 校验通过;非 null → 错误文本(自动显示在输入框下方红字)
/// —— 所以 Flutter 没有"规则对象",每个字段的规则就写在它自己的
/// validator 里,就近维护,一眼能看到这个字段的所有规则。
///
/// 二、三种校验时机(AutovalidateMode):
/// - disabled(默认) → 只在点提交 validate() 时校验
/// - always → build 时立刻校验(空表单会满屏报错,慎用)
/// - onUserInteraction → 用户碰过该字段后才校验(推荐:
/// 未输入不报错,输错即时提示,提交时再兜底校验一次)
///
/// 三、本页演示的 7 个知识点:
/// 1. 必填/长度校验(用户名 2-8 位)
/// 2. 正则校验(手机号 / 邮箱)
/// 3. 密码强度(必须同时含字母和数字)
/// 4. 跨字段对比(确认密码 vs 密码,validator 里读另一个 controller)
/// 5. FormField<bool> 校验非输入型字段(协议勾选,errorText 手动渲染)
/// 6. 两段式校验:validate() 同步通过后,再模拟异步查重
/// (validator 不能 await,异步业务校验必须放提交后)
/// 7. 防重复提交(_submitting 置灰按钮)+ 重置表单
///
/// 四、高频坑:
/// 1. formState.reset() 只清【校验错误】和 FormField 状态,
/// 不碰 controller.text —— 清空输入要手动 clear();
/// 2. 确认密码的 validator 依赖 _pwdC.text,两个 controller 都必须
/// 在 dispose 里释放,否则内存泄漏;
/// 3. 手机号正则要用 ^...$ 全匹配,不然 11 位之后还能续数字;
/// 4. 加了 .w/.h/.sp 的 widget 不能再用 const(运行时才算尺寸);
/// 5. 异步(await)之后用 context 必须先用 mounted 判空。
/// ============================================================
class FormDemoPage extends StatefulWidget {
const FormDemoPage({super.key});
@override
State<FormDemoPage> createState() => _FormDemoPageState();
}
class _FormDemoPageState extends State<FormDemoPage> {
/// Form 的唯一钥匙:提交(validate)/ 重置(reset)都靠它
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
/// 每个输入框一个 controller(dispose 里必须释放)
final TextEditingController _nameC = TextEditingController();
final TextEditingController _phoneC = TextEditingController();
final TextEditingController _emailC = TextEditingController();
final TextEditingController _pwdC = TextEditingController();
final TextEditingController _pwd2C = TextEditingController();
bool _showPwd = false; // 密码明文/密文
bool _agreed = false; // 协议是否勾选(FormField 外部镜像,重置用)
bool _submitting = false; // 提交中(防重复提交)
@override
void dispose() {
_nameC.dispose();
_phoneC.dispose();
_emailC.dispose();
_pwdC.dispose();
_pwd2C.dispose();
super.dispose();
}
// ==================== 校验规则(每个字段一个 validator) ====================
/// 用户名:必填 + 2-8 位
String? _validateName(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入用户名';
if (s.length < 2 || s.length > 8) return '用户名需 2-8 个字符';
return null;
}
/// 手机号:必填 + 正则全匹配(^...$)
String? _validatePhone(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入手机号';
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(s)) return '手机号格式不正确';
return null;
}
/// 邮箱:选填,填了才校验格式
String? _validateEmail(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return null;
if (!RegExp(r'^[\w.+-]+@[\w-]+\.[\w.-]+$').hasMatch(s)) return '邮箱格式不正确';
return null;
}
/// 密码:必填 + 6-20 位 + 同时含字母和数字
String? _validatePwd(String? v) {
final s = v ?? '';
if (s.isEmpty) return '请输入密码';
if (s.length < 6 || s.length > 20) return '密码需 6-20 位';
if (!RegExp(r'(?=.*[A-Za-z])(?=.*\d)').hasMatch(s)) return '密码需同时包含字母和数字';
return null;
}
/// 确认密码:必填 + 跨字段对比(读 _pwdC.text)
String? _validatePwd2(String? v) {
final s = v ?? '';
if (s.isEmpty) return '请再次输入密码';
if (s != _pwdC.text) return '两次密码不一致';
return null;
}
// ==================== 提交 / 重置 ====================
/// 两段式校验:先同步 validate(),通过后模拟异步查重
Future<void> _submit() async {
if (_submitting) return; // 防连点
FocusScope.of(context).unfocus(); // 收起键盘
// ① 同步校验:所有子字段的 validator 逐个执行,只要有一个返回
// 非 null → validate() 为 false,错误红字已自动显示。
if (!_formKey.currentState!.validate()) {
_toast('有字段未通过校验,请检查红字提示');
return;
}
// ② 同步通过 → 异步业务校验(查重/验证码)。validator 是同步函数
// 不能 await,异步校验必须在 validate() 之后单独做。
setState(() => _submitting = true);
await Future.delayed(const Duration(seconds: 1)); // 模拟查重请求
if (!mounted) return; // 异步后必须判 mounted 才能用 context
setState(() => _submitting = false);
_toast('校验全部通过,注册成功!');
}
/// 重置:formState.reset() 只清校验错误,输入内容要手动 clear()
void _reset() {
_formKey.currentState?.reset();
_nameC.clear();
_phoneC.clear();
_emailC.clear();
_pwdC.clear();
_pwd2C.clear();
setState(() => _agreed = false);
}
void _toast(String msg) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
);
}
// ==================== UI ====================
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('表单校验 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(20.w),
child: Form(
key: _formKey,
// onUserInteraction:用户碰过的字段才即时校验,未碰不报错
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTipCard(),
SizedBox(height: 16.h),
_buildField(
label: '用户名',
hint: '2-8 个字符',
icon: Icons.person_outline,
controller: _nameC,
validator: _validateName,
),
SizedBox(height: 16.h),
_buildField(
label: '手机号',
hint: '11 位大陆手机号',
icon: Icons.phone_iphone,
controller: _phoneC,
validator: _validatePhone,
keyboardType: TextInputType.phone,
),
SizedBox(height: 16.h),
_buildField(
label: '邮箱(选填)',
hint: '例:name@example.com',
icon: Icons.mail_outline,
controller: _emailC,
validator: _validateEmail,
keyboardType: TextInputType.emailAddress,
),
SizedBox(height: 16.h),
// 密码:带"眼睛"显隐切换
TextFormField(
controller: _pwdC,
obscureText: !_showPwd,
validator: _validatePwd,
decoration: InputDecoration(
labelText: '密码',
hintText: '6-20 位,含字母和数字',
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
icon: Icon(
_showPwd ? Icons.visibility_outlined : Icons.visibility_off_outlined,
),
onPressed: () => setState(() => _showPwd = !_showPwd),
),
border: const OutlineInputBorder(),
),
),
SizedBox(height: 16.h),
_buildField(
label: '确认密码',
hint: '再次输入密码',
icon: Icons.lock_outline,
controller: _pwd2C,
validator: _validatePwd2,
),
SizedBox(height: 16.h),
_buildAgreement(),
SizedBox(height: 24.h),
_buildButtons(),
],
),
),
),
);
}
/// 顶部说明卡片
Widget _buildTipCard() {
return Container(
padding: EdgeInsets.all(16.w),
decoration: BoxDecoration(
color: const Color(0xFFE8F0FE),
borderRadius: BorderRadius.circular(12.r),
),
child: const Text(
'校验机制:Form 收集所有子字段的 validator,'
'点提交统一 validate()。validator 返回 null=通过,'
'返回字符串=错误红字(自动显示)。autovalidateMode 控制即时校验。',
style: TextStyle(fontSize: 13, color: Color(0xFF3B82F6)),
),
);
}
/// 通用输入框(label/hint/图标/校验规则全部参数化)
Widget _buildField({
required String label,
required String hint,
required IconData icon,
required TextEditingController controller,
required String? Function(String?) validator,
TextInputType? keyboardType,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
labelText: label,
hintText: hint,
prefixIcon: Icon(icon),
border: const OutlineInputBorder(),
),
);
}
/// 协议勾选:FormField<bool> 校验"非输入型"字段。
/// 注意:自定义 FormField 的 errorText 要自己在 builder 里渲染。
Widget _buildAgreement() {
return FormField<bool>(
initialValue: _agreed,
validator: (v) => (v ?? false) ? null : '请先勾选同意协议',
builder: (field) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
onTap: () {
field.didChange(!(field.value ?? false)); // 更新 FormField 自身
setState(() => _agreed = field.value ?? false); // 同步外部镜像
},
child: Row(
children: [
Icon(
_agreed
? Icons.check_circle_rounded
: Icons.radio_button_unchecked,
size: 24,
color: _agreed
? const Color(0xFF3B82F6)
: const Color(0xFFC0C4CC),
),
SizedBox(width: 8.w),
const Text('我已阅读并同意《用户协议》和《隐私政策》'),
],
),
),
if (field.hasError)
Padding(
padding: EdgeInsets.only(top: 4.h),
child: Text(
field.errorText!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
],
);
},
);
}
/// 提交 + 重置按钮
Widget _buildButtons() {
return Row(
children: [
Expanded(
child: SizedBox(
height: 48.h,
child: FilledButton(
// 提交中禁用:防重复提交
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
)
: const Text('提交'),
),
),
),
SizedBox(width: 12.w),
Expanded(
child: SizedBox(
height: 48.h,
child: OutlinedButton(
onPressed: _submitting ? null : _reset,
child: const Text('重置'),
),
),
),
],
);
}
}
+167
View File
@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// ============================================================
/// GridView 网格布局演示页
///
/// 一、两种使用方式:
/// 1. 直接运行本页面:runApp(const MaterialApp(home: GridDemoPage()))
/// 2. 复制某段核心代码到任意 Scaffold 的 body 中。
///
/// 二、三个常用构造:
/// GridView.count : 固定列数,写死 children(一次性全部创建)
/// GridView.builder : 懒加载,itemCount + itemBuilder,大量数据首选
/// GridView.extent : 按"每项最大宽度"自适应列数(屏幕越宽列越多)
///
/// 三、核心参数:
/// crossAxisCount : 列数(GridView.count 专用)
/// maxCrossAxisExtent: 每项最大宽度(GridView.extent 专用)
/// mainAxisSpacing : 主轴(上下行)间距
/// crossAxisSpacing : 交叉轴(左右列)间距
/// childAspectRatio : 每项 宽:高 比例,默认 1(正方形)
///
/// 四、高频坑:
/// 1. GridView 放 Column 必须 SizedBox 定高或 Expanded(同 ListView);
/// 2. childAspectRatio 太小(如 0.5)时文字会被挤压溢出,调比例或缩小字号。
///
/// 五、和 CustomScrollView(Sliver) 的本质区别(重点记忆):
/// GridView = CustomScrollView + 1 个 SliverGrid(框架内部自动包的)。
/// SDK 源码:GridView extends BoxScrollView,而 ScrollView.build()
/// 返回的就是 CustomScrollView——所以 GridView 是"只有网格这一种内容"
/// 的简化套餐,两者都是"单个滚动 + 懒加载"。
/// · 页面只有网格 → body: GridView.builder(...) 直接用
/// · 页面要混排 AppBar + 网格 + 列表 → CustomScrollView + slivers
/// · 本页外层 SingleChildScrollView 只是"展示台":为并排演示 3 种构造,
/// 内部塞的是固定高 GridView(嵌套滚动)。真实网格页别学这个外层!
/// ============================================================
class GridDemoPage extends StatelessWidget {
const GridDemoPage({super.key});
// 网格配色(循环使用)
static const List<Color> _colors = [
Color(0xFF3B82F6), Color(0xFF8E6FE8), Color(0xFFF05252),
Color(0xFFFF9800), Color(0xFF4CAF50), Color(0xFF00BCD4),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('GridView 网格 Demo')),
body: SingleChildScrollView(
padding: EdgeInsets.all(16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _SectionTitle('Demo1:GridView.count 固定 3 列'),
Text(
'写死 children,一次性全创建,适合少量固定网格',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
SizedBox(
height: 260.h,
child: GridView.count(
crossAxisCount: 3, // 列数
mainAxisSpacing: 8.w, // 上下间距
crossAxisSpacing: 8.w, // 左右间距
childAspectRatio: 1, // 宽:高 = 1:1 正方形
children: [
for (int i = 0; i < 12; i++) _GridItem(index: i),
],
),
),
SizedBox(height: 32.h),
const _SectionTitle('Demo2:GridView.builder 懒加载'),
Text(
'itemCount: 100,只创建可见的格子,滚到哪建到哪',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
SizedBox(
height: 260.h,
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, // 4 列小格子
mainAxisSpacing: 8.w,
crossAxisSpacing: 8.w,
childAspectRatio: 1,
),
itemCount: 100, // 假装 100 个格子
itemBuilder: (context, index) => _GridItem(index: index),
),
),
SizedBox(height: 32.h),
const _SectionTitle('Demo3:GridView.extent 自适应列宽'),
Text(
'maxCrossAxisExtent: 120 → 每项最多 120 宽,屏幕越宽列数自动变多',
style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888)),
),
SizedBox(height: 8.h),
SizedBox(
height: 260.h,
child: GridView.extent(
maxCrossAxisExtent: 80.w, // 每项最多 80 宽
mainAxisSpacing: 8.w,
crossAxisSpacing: 8.w,
childAspectRatio: 1,
children: [
for (int i = 0; i < 12; i++) _GridItem(index: i),
],
),
),
],
),
),
);
}
}
/// 单个网格项:彩色方块 + 序号
class _GridItem extends StatelessWidget {
const _GridItem({required this.index});
final int index;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: GridDemoPage._colors[index % GridDemoPage._colors.length],
borderRadius: BorderRadius.circular(8.r),
),
alignment: Alignment.center,
child: Text(
'$index',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.bold,
),
),
);
}
}
/// 区块标题
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.title);
final String title;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: 4.h),
child: Text(
title,
style: TextStyle(
fontSize: 16.sp,
fontWeight: FontWeight.bold,
color: Color(0xFF333333),
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More