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