1. 添加flutter_inappwebview依赖并配置各平台插件注册 2. 新增我的报告列表页、详情WebView页 3. 新增报告模块API和数据模型 4. 配置路由跳转,修复我的菜单跳转逻辑 5. 添加AGP9兼容补丁修复插件构建问题 6. 注释掉登录成功的SnackBar提示
807 lines
27 KiB
Dart
807 lines
27 KiB
Dart
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:cached_network_image/cached_network_image.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';
|
||
import 'package:new_learn/core/utils/img_url.dart';
|
||
import 'package:new_learn/core/utils/screen_adapt_template.dart';
|
||
import 'package:new_learn/core/components/confirm_box.dart';
|
||
import 'package:new_learn/core/components/center_toast.dart';
|
||
|
||
/// ============================================================
|
||
/// 【LoginPage】登录页 —— 对照 Vue 版 pages/login/index.vue
|
||
///
|
||
/// 一、布局结构(自上而下,对齐 Vue 视觉):
|
||
/// 1. 全屏背景图(顶部 CachedNetworkImage + 底部白色圆角卡片盖上去)
|
||
/// 2. 欢迎图(品牌 welcome image,Pad/phone 尺寸不同)
|
||
/// 3. 白色圆角卡片容器:
|
||
/// - 账号输入框(右侧下拉箭头 → Overlay 弹出登录历史)
|
||
/// - 密码输入框
|
||
/// - 记住密码 / 忘记密码
|
||
/// - 立即登录按钮
|
||
/// - 注册入口(仅 isAuditMode=true 时显示)
|
||
/// - 协议勾选 + 《用户协议》《隐私政策》
|
||
///
|
||
/// 二、登录历史(对齐 Vue loginHistory):
|
||
/// - 最多 3 条,存在 flutter_secure_storage 里一条 JSON
|
||
/// - 账号框右侧有历史才显示 ⌄ 箭头
|
||
/// - 点击箭头 / 账号框聚焦 → Overlay 下拉列表(定位到输入框正下方)
|
||
/// - 选中某条 → 回填账号,rememberPassword=true 时回填密码
|
||
/// - 登录成功 → 插头部 + 去重 + 截断 3 条 → 持久化
|
||
///
|
||
/// 三、审核模式 / 记住密码 / 登录流程(不变)。
|
||
/// ============================================================
|
||
class LoginPage extends ConsumerStatefulWidget {
|
||
const LoginPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||
}
|
||
|
||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||
// ---------- 表单控制器 ----------
|
||
final TextEditingController _accountController = TextEditingController();
|
||
final TextEditingController _passwordController = TextEditingController();
|
||
|
||
// ---------- 状态 ----------
|
||
bool _obscurePassword = true;
|
||
bool _rememberPassword = true;
|
||
bool _hasAgreed = false;
|
||
bool _logging = false;
|
||
bool _isAuditMode = false;
|
||
|
||
// ---------- 登录历史 ----------
|
||
final List<LoginHistoryItem> _loginHistory = [];
|
||
bool _showHistory = false;
|
||
OverlayEntry? _historyOverlay;
|
||
|
||
// 用来算账号框位置 → Overlay 定位
|
||
final GlobalKey _accountFieldKey = GlobalKey();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadHistoryAndFill();
|
||
_fetchAuditMode();
|
||
}
|
||
|
||
/// 启动时:加载历史 → 第一条 rememberPassword=true 的回填账号+密码 → 有历史自动展开下拉
|
||
Future<void> _loadHistoryAndFill() async {
|
||
final history = await UserStorage.instance.readLoginHistory();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_loginHistory
|
||
..clear()
|
||
..addAll(history);
|
||
if (history.isNotEmpty && history.first.rememberPassword) {
|
||
_accountController.text = history.first.account;
|
||
_passwordController.text = history.first.password;
|
||
_rememberPassword = true;
|
||
}
|
||
});
|
||
// 等账号框渲染完再展开(Overlay 定位需要 RenderBox)
|
||
if (history.isNotEmpty) {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (mounted) _showHistoryOverlay();
|
||
});
|
||
}
|
||
}
|
||
|
||
/// 非阻塞获取审核模式
|
||
Future<void> _fetchAuditMode() async {
|
||
try {
|
||
final audit = await UserApi.getAuditMode();
|
||
if (!mounted) return;
|
||
setState(() => _isAuditMode = audit);
|
||
} catch (_) {
|
||
// 接口失败保持 false
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_hideHistory();
|
||
_accountController.dispose();
|
||
_passwordController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
// ==================== 登录历史 Overlay ====================
|
||
|
||
/// 账号框聚焦 / 点击箭头 → 切换下拉
|
||
void _toggleHistory() {
|
||
if (_loginHistory.isEmpty) return;
|
||
if (_showHistory) {
|
||
_hideHistory();
|
||
} else {
|
||
_showHistoryOverlay();
|
||
}
|
||
}
|
||
|
||
void _showHistoryOverlay() {
|
||
if (_loginHistory.isEmpty) return;
|
||
_hideHistory();
|
||
|
||
final renderBox =
|
||
_accountFieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||
if (renderBox == null || !mounted) return;
|
||
final overlay = Overlay.of(context);
|
||
final size = MediaQuery.sizeOf(context);
|
||
|
||
final entry = OverlayEntry(
|
||
builder: (_) {
|
||
// 算账号框位置
|
||
final box =
|
||
_accountFieldKey.currentContext?.findRenderObject() as RenderBox?;
|
||
if (box == null) return const SizedBox.shrink();
|
||
final topLeft = box.localToGlobal(Offset.zero);
|
||
final fieldWidth = box.size.width;
|
||
|
||
// 列表高度:最多 3 条,每条 40.h,加边框圆角
|
||
final itemHeight = 40.h;
|
||
final listHeight = itemHeight * _loginHistory.length + 2; // +2 边框
|
||
|
||
return Stack(
|
||
children: [
|
||
// 全屏点击遮罩(点任何地方收起)
|
||
Positioned.fill(
|
||
child: GestureDetector(
|
||
onTap: _hideHistory,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: const SizedBox.expand(),
|
||
),
|
||
),
|
||
// 下拉列表(定位到账号框正下方)
|
||
Positioned(
|
||
top: topLeft.dy + box.size.height + 4.h,
|
||
left: topLeft.dx,
|
||
width: fieldWidth,
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
child: Container(
|
||
height: listHeight,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(8.r),
|
||
border: Border.all(color: const Color(0xFFEEEEEE)),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.1),
|
||
offset: Offset(0, 6.h),
|
||
blurRadius: 16,
|
||
),
|
||
],
|
||
),
|
||
child: ListView.separated(
|
||
padding: EdgeInsets.zero,
|
||
itemCount: _loginHistory.length,
|
||
separatorBuilder: (_, __) =>
|
||
const Divider(height: 1, color: Color(0xFFF0F0F0)),
|
||
itemBuilder: (context, index) {
|
||
final item = _loginHistory[index];
|
||
return GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => _selectHistory(index),
|
||
child: Container(
|
||
height: itemHeight,
|
||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
item.userName.isNotEmpty
|
||
? item.userName
|
||
: item.account,
|
||
style: TextStyle(
|
||
fontSize: 13.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: const Color(0xFF323233),
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
if (item.userName.isNotEmpty)
|
||
Text(
|
||
item.account,
|
||
style: TextStyle(
|
||
fontSize: 11.sp,
|
||
color: const Color(0xFF999999),
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () => _deleteHistory(index),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Padding(
|
||
padding: EdgeInsets.all(8.r),
|
||
child: Icon(
|
||
Icons.close,
|
||
size: 14,
|
||
color: const Color(0xFFC0C4CC),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
|
||
setState(() => _showHistory = true);
|
||
_historyOverlay = entry;
|
||
overlay.insert(entry);
|
||
}
|
||
|
||
void _hideHistory() {
|
||
_historyOverlay?.remove();
|
||
_historyOverlay = null;
|
||
if (mounted && _showHistory) {
|
||
setState(() => _showHistory = false);
|
||
}
|
||
}
|
||
|
||
/// 选中某条历史 → 回填账号 + 可选回填密码
|
||
Future<void> _selectHistory(int index) async {
|
||
final item = _loginHistory[index];
|
||
_hideHistory();
|
||
setState(() {
|
||
_accountController.text = item.account;
|
||
if (item.rememberPassword) {
|
||
_passwordController.text = item.password;
|
||
_rememberPassword = true;
|
||
} else {
|
||
_passwordController.text = '';
|
||
}
|
||
});
|
||
}
|
||
|
||
/// 删除某条历史 → 立即持久化
|
||
Future<void> _deleteHistory(int index) async {
|
||
_loginHistory.removeAt(index);
|
||
await UserStorage.instance.saveLoginHistory(_loginHistory);
|
||
if (!mounted) return;
|
||
// 如果删空了,直接收起列表
|
||
if (_loginHistory.isEmpty) {
|
||
_hideHistory();
|
||
} else {
|
||
// 重绘 Overlay(因为列表长度变了)
|
||
_hideHistory();
|
||
_showHistoryOverlay();
|
||
}
|
||
}
|
||
|
||
/// 登录成功后把本次账号存入历史(插头部 + 去重 + 截断 3 条)
|
||
Future<void> _updateLoginHistory({
|
||
required String account,
|
||
required String userName,
|
||
required String password,
|
||
required bool rememberPassword,
|
||
}) async {
|
||
// 移除同账号的旧记录
|
||
_loginHistory.removeWhere((e) => e.account == account);
|
||
// 插头部
|
||
_loginHistory.insert(
|
||
0,
|
||
LoginHistoryItem(
|
||
account: account,
|
||
userName: userName,
|
||
password: rememberPassword ? password : '',
|
||
rememberPassword: rememberPassword,
|
||
),
|
||
);
|
||
// 持久化(saveLoginHistory 内部自动截断到 3 条)
|
||
await UserStorage.instance.saveLoginHistory(_loginHistory);
|
||
}
|
||
|
||
// ==================== 导航 ====================
|
||
|
||
void _goToAgreement() => context.push('/user-agreement');
|
||
void _goToPrivacy() => context.push('/privacy-policy');
|
||
|
||
Future<void> _forgetPassword() async {
|
||
await showConfirmBox(
|
||
context,
|
||
title: '忘记密码',
|
||
content: '请联系学习官找回密码!',
|
||
showCancel: false,
|
||
confirmText: '确定',
|
||
);
|
||
}
|
||
|
||
void _goToRegister() => context.push('/register');
|
||
|
||
// ==================== 登录逻辑 ====================
|
||
|
||
Future<void> _handleLogin() async {
|
||
if (_logging) return;
|
||
final account = _accountController.text.trim();
|
||
final password = _passwordController.text;
|
||
|
||
if (account.isEmpty) {
|
||
CenterToast.show(context, '请填写账号');
|
||
return;
|
||
}
|
||
if (password.isEmpty) {
|
||
CenterToast.show(context, '请填写密码');
|
||
return;
|
||
}
|
||
if (!_hasAgreed) {
|
||
final agreed = await showConfirmBox(
|
||
context,
|
||
title: '用户协议及隐私条款',
|
||
content:
|
||
'感谢您选择AI爱学蝶变!我们非常重视您的个人信息和隐私保护。'
|
||
'为了更好地保障您的个人权益,在您使用我们的产品前请务必审慎阅读'
|
||
'《隐私政策》和《用户协议》内的所有条款。同意并继续开始使用我们的产品和服务!',
|
||
cancelText: '不同意',
|
||
confirmText: '同意并继续',
|
||
);
|
||
if (!agreed || !mounted) return;
|
||
setState(() => _hasAgreed = true);
|
||
}
|
||
|
||
setState(() => _logging = true);
|
||
try {
|
||
final res = await UserApi.login(account: account, password: password);
|
||
if (res.token.isEmpty) {
|
||
if (mounted) _toast('登录响应缺少 token 字段,请检查后端返回结构');
|
||
return;
|
||
}
|
||
await UserStorage.instance.saveToken(res.token);
|
||
DioClient.instance.accessToken = res.token;
|
||
|
||
try {
|
||
final user = await UserApi.getCurrentUser(forceRefresh: true);
|
||
ref.read(currentUserProvider.notifier).set(user);
|
||
// 存登录历史(userName 从拉到的完整信息里取)
|
||
await _updateLoginHistory(
|
||
account: account,
|
||
userName: user.userName,
|
||
password: password,
|
||
rememberPassword: _rememberPassword,
|
||
);
|
||
} catch (_) {
|
||
debugPrint('⚠ 【用户信息】拉取失败,已跳过缓存');
|
||
// 即使拉用户信息失败也存历史(userName 留空)
|
||
await _updateLoginHistory(
|
||
account: account,
|
||
userName: '',
|
||
password: password,
|
||
rememberPassword: _rememberPassword,
|
||
);
|
||
}
|
||
|
||
if (mounted) {
|
||
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) {
|
||
if (mounted) _toast(e.message);
|
||
} catch (e) {
|
||
if (mounted) _toast('登录失败,请稍后重试');
|
||
} finally {
|
||
if (mounted) setState(() => _logging = false);
|
||
}
|
||
}
|
||
|
||
void _toast(String msg) {
|
||
ScaffoldMessenger.of(context)
|
||
..hideCurrentSnackBar()
|
||
..showSnackBar(
|
||
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
|
||
);
|
||
}
|
||
|
||
// ==================== UI ====================
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isPad = MediaQuery.sizeOf(context).width >= AppAdapt.tabletBreakpoint;
|
||
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF5F8FA),
|
||
body: Stack(
|
||
children: [
|
||
// ① 底层:全屏背景图
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
isPad
|
||
? '/StaticResource/2026-06-02/welcome-bg-pad1780393110709.png'
|
||
: '/K12/materials/img/2026-03-27/779bc42523a24f919cb0d70de40fda51.png',
|
||
),
|
||
fit: BoxFit.fitWidth,
|
||
alignment: Alignment.topCenter,
|
||
placeholder: (_, __) => const SizedBox.shrink(),
|
||
errorWidget: (_, __, ___) =>
|
||
Container(height: 400.h, color: const Color(0xFFE8F0FE)),
|
||
),
|
||
),
|
||
|
||
// ② 内容层
|
||
SafeArea(
|
||
child: Column(
|
||
children: [
|
||
SizedBox(height: 12.h),
|
||
|
||
// 欢迎图
|
||
Transform.translate(
|
||
offset: Offset(-7.5.w, 0),
|
||
child: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
isPad
|
||
? '/StaticResource/2026-06-02/welcome-pad1780393110710.png'
|
||
: '/K12/materials/img/2026-03-27/a8b186d3055747ddaece6ad04d17399e.png',
|
||
),
|
||
width: isPad ? 473.w : 334.w,
|
||
height: isPad ? 270.h : 196.h,
|
||
placeholder: (_, __) =>
|
||
SizedBox(width: 334.w, height: 196.h),
|
||
errorWidget: (_, __, ___) =>
|
||
SizedBox(width: 334.w, height: 196.h),
|
||
),
|
||
),
|
||
|
||
// 白色圆角卡片(SingleChildScrollView 吸收键盘弹起时的溢出)
|
||
Expanded(
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: EdgeInsets.fromLTRB(
|
||
isPad ? 66.w : 25.w,
|
||
50.h,
|
||
isPad ? 66.w : 25.w,
|
||
40.h,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: Radius.circular(15.r),
|
||
topRight: Radius.circular(15.r),
|
||
),
|
||
),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// 账号输入框(key 用来算 Overlay 位置 + 下拉箭头)
|
||
_buildAccountField(),
|
||
SizedBox(height: 25.h),
|
||
|
||
// 密码输入框
|
||
_buildPasswordField(),
|
||
SizedBox(height: 25.h),
|
||
|
||
_buildTipsRow(),
|
||
SizedBox(height: 50.h),
|
||
|
||
_buildLoginButton(),
|
||
|
||
if (_isAuditMode) ...[
|
||
SizedBox(height: 20.h),
|
||
_buildRegisterLink(),
|
||
],
|
||
|
||
SizedBox(height: 25.h),
|
||
_buildAgreementRow(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 账号输入框(带 GlobalKey 给 Overlay 定位用,右侧有历史才显示下拉箭头)
|
||
Widget _buildAccountField() {
|
||
return Container(
|
||
key: _accountFieldKey,
|
||
height: 48.h,
|
||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF9F9F9),
|
||
borderRadius: BorderRadius.circular(24.r),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/K12/materials/img/2026-03-27/b80f8d877d1e4cb3b077ca1cb77a6d3a.png',
|
||
),
|
||
width: 24.w,
|
||
height: 24.h,
|
||
placeholder: (_, __) =>
|
||
const Icon(Icons.person_outline_rounded, size: 24),
|
||
errorWidget: (_, __, ___) =>
|
||
const Icon(Icons.person_outline_rounded, size: 24),
|
||
),
|
||
SizedBox(width: 10.w),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _accountController,
|
||
style: TextStyle(fontSize: 16.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: _toggleHistory,
|
||
child: Icon(
|
||
_showHistory
|
||
? Icons.keyboard_arrow_up
|
||
: Icons.keyboard_arrow_down,
|
||
color: const Color(0xFFC0C4CC),
|
||
size: 24,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 密码输入框
|
||
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: [
|
||
CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/K12/materials/img/2026-03-27/07ee4c28ee1f42f8a021a227f56d81f4.png',
|
||
),
|
||
width: 24.w,
|
||
height: 24.h,
|
||
placeholder: (_, __) =>
|
||
const Icon(Icons.lock_outline_rounded, size: 24),
|
||
errorWidget: (_, __, ___) =>
|
||
const Icon(Icons.lock_outline_rounded, size: 24),
|
||
),
|
||
SizedBox(width: 10.w),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _passwordController,
|
||
obscureText: _obscurePassword,
|
||
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF323233)),
|
||
decoration: InputDecoration(
|
||
isCollapsed: true,
|
||
border: InputBorder.none,
|
||
hintText: '请输入密码',
|
||
hintStyle: TextStyle(
|
||
fontSize: 13.sp,
|
||
color: const Color(0xFFC0C4CC),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () => setState(() => _obscurePassword = !_obscurePassword),
|
||
child: _obscurePassword
|
||
? CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/K12/materials/img/2026-03-27/a2572338119745c2850e38f726db9f7f.png',
|
||
),
|
||
width: 24.w,
|
||
height: 24.h,
|
||
placeholder: (_, __) => const Icon(
|
||
Icons.visibility_off_outlined,
|
||
size: 24,
|
||
color: Color(0xFFC0C4CC),
|
||
),
|
||
errorWidget: (_, __, ___) => const Icon(
|
||
Icons.visibility_off_outlined,
|
||
size: 24,
|
||
color: Color(0xFFC0C4CC),
|
||
),
|
||
)
|
||
: CachedNetworkImage(
|
||
imageUrl: ImgUrl.build(
|
||
'/K12/materials/img/2026-03-27/02221c9e4c60409087f9ec51eed7b2d7.png',
|
||
),
|
||
width: 24.w,
|
||
height: 24.h,
|
||
placeholder: (_, __) => const Icon(
|
||
Icons.visibility_outlined,
|
||
size: 24,
|
||
color: Color(0xFFC0C4CC),
|
||
),
|
||
errorWidget: (_, __, ___) => const Icon(
|
||
Icons.visibility_outlined,
|
||
size: 24,
|
||
color: Color(0xFFC0C4CC),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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: 16.w,
|
||
color: _rememberPassword
|
||
? const Color(0xFF3D7DF5)
|
||
: const Color(0xFFC0C4CC),
|
||
),
|
||
SizedBox(width: 4.w),
|
||
Flexible(
|
||
child: Text(
|
||
'记住密码',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 14.sp,
|
||
color: const Color(0xFF323233),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
InkWell(
|
||
onTap: _forgetPassword,
|
||
child: Text(
|
||
'忘记密码?',
|
||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF3D7DF5)),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildLoginButton() {
|
||
return SizedBox(
|
||
width: double.infinity,
|
||
height: 48.h,
|
||
child: FilledButton(
|
||
onPressed: _logging ? null : _handleLogin,
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: const Color(0xFF3D7DF5),
|
||
disabledBackgroundColor: const Color(
|
||
0xFF3D7DF5,
|
||
).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: 18.sp, color: Colors.white),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildRegisterLink() {
|
||
return Center(
|
||
child: GestureDetector(
|
||
onTap: _goToRegister,
|
||
child: Text.rich(
|
||
TextSpan(
|
||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
|
||
children: [
|
||
const TextSpan(text: '没有账号?'),
|
||
TextSpan(
|
||
text: '去注册',
|
||
style: const TextStyle(color: Color(0xFF3D7DF5)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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: 16.w,
|
||
color: _hasAgreed
|
||
? const Color(0xFF3D7DF5)
|
||
: const Color(0xFFC0C4CC),
|
||
),
|
||
),
|
||
SizedBox(width: 6.w),
|
||
Flexible(
|
||
child: RichText(
|
||
text: TextSpan(
|
||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF323233)),
|
||
children: [
|
||
const TextSpan(text: '我已阅读并同意'),
|
||
TextSpan(
|
||
text: '《用户协议》',
|
||
style: const TextStyle(color: Color(0xFF3D7DF5)),
|
||
recognizer: TapGestureRecognizer()..onTap = _goToAgreement,
|
||
),
|
||
const TextSpan(text: '和'),
|
||
TextSpan(
|
||
text: '《隐私政策》',
|
||
style: const TextStyle(color: Color(0xFF3D7DF5)),
|
||
recognizer: TapGestureRecognizer()..onTap = _goToPrivacy,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|