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
@@ -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;
}