feat(mobile-app): 添加手机号+密码登录页面和路由
功能: - 创建 PhoneLoginPage 用于账号恢复功能 - 实现手机号和密码输入界面 - 添加输入验证(手机号格式、密码长度) - 添加密码可见性切换 - 添加登录按钮和加载状态 - 配置 phoneLogin 路由到 app_router - 添加 RouteNames.phoneLogin 常量 UI设计: - 深色渐变背景(与其他认证页面一致) - 返回按钮 - 手机号和密码输入框 - 错误提示区域 - 登录按钮(带加载状态) - 注册提示链接 待实现: - 后端手机号+密码登录 API - 登录成功后的token保存和状态更新 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ed6c08914c
commit
65f3e75f59
|
|
@ -0,0 +1,504 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../core/services/account_service.dart';
|
||||
import '../../../../core/storage/secure_storage.dart';
|
||||
import '../../../../core/storage/storage_keys.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
/// 手机号+密码登录页面
|
||||
/// 用于"恢复账号"功能
|
||||
class PhoneLoginPage extends ConsumerStatefulWidget {
|
||||
const PhoneLoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PhoneLoginPage> createState() => _PhoneLoginPageState();
|
||||
}
|
||||
|
||||
class _PhoneLoginPageState extends ConsumerState<PhoneLoginPage> {
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final FocusNode _phoneFocusNode = FocusNode();
|
||||
final FocusNode _passwordFocusNode = FocusNode();
|
||||
|
||||
bool _isLoggingIn = false;
|
||||
String? _errorMessage;
|
||||
bool _obscurePassword = true; // 是否隐藏密码
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_passwordController.dispose();
|
||||
_phoneFocusNode.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 验证手机号格式
|
||||
bool _isValidPhoneNumber(String phone) {
|
||||
// 中国大陆手机号:1开头,第二位3-9,共11位
|
||||
final regex = RegExp(r'^1[3-9]\d{9}$');
|
||||
return regex.hasMatch(phone);
|
||||
}
|
||||
|
||||
/// 验证输入
|
||||
String? _validateInputs() {
|
||||
final phone = _phoneController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
|
||||
if (phone.isEmpty) {
|
||||
return '请输入手机号';
|
||||
}
|
||||
|
||||
if (!_isValidPhoneNumber(phone)) {
|
||||
return '请输入正确的手机号';
|
||||
}
|
||||
|
||||
if (password.isEmpty) {
|
||||
return '请输入密码';
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return '密码至少6位';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 执行登录
|
||||
Future<void> _login() async {
|
||||
// 验证输入
|
||||
final validationError = _validateInputs();
|
||||
if (validationError != null) {
|
||||
setState(() {
|
||||
_errorMessage = validationError;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoggingIn = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final phone = _phoneController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
|
||||
debugPrint('[PhoneLoginPage] 开始登录 - 手机号: $phone');
|
||||
|
||||
// 调用登录 API(需要在 AccountService 中添加)
|
||||
// TODO: 实现手机号+密码登录 API
|
||||
// final response = await accountService.loginWithPassword(phone, password);
|
||||
|
||||
// 暂时模拟登录失败
|
||||
throw Exception('手机号+密码登录 API 尚未实现');
|
||||
|
||||
// 登录成功后的处理:
|
||||
// 1. 保存 access token 和 refresh token
|
||||
// 2. 保存用户信息(userId, accountSequence, referralCode)
|
||||
// 3. 检查钱包状态
|
||||
// 4. 跳转到主页
|
||||
|
||||
// if (mounted) {
|
||||
// // 更新认证状态
|
||||
// await ref.read(authProvider.notifier).checkAuthStatus();
|
||||
//
|
||||
// // 跳转到主页(龙虎榜)
|
||||
// context.go(RoutePaths.ranking);
|
||||
// }
|
||||
} catch (e) {
|
||||
debugPrint('[PhoneLoginPage] 登录失败: $e');
|
||||
setState(() {
|
||||
_errorMessage = '登录失败: $e';
|
||||
});
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoggingIn = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF1A1A2E),
|
||||
Color(0xFF16213E),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部导航栏
|
||||
_buildAppBar(),
|
||||
// 表单内容
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 24.w),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(height: 40.h),
|
||||
// 标题
|
||||
Text(
|
||||
'恢复账号',
|
||||
style: TextStyle(
|
||||
fontSize: 32.sp,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
'使用手机号和密码登录',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 48.h),
|
||||
// 手机号输入框
|
||||
_buildPhoneInput(),
|
||||
SizedBox(height: 16.h),
|
||||
// 密码输入框
|
||||
_buildPasswordInput(),
|
||||
// 错误提示
|
||||
if (_errorMessage != null) ...[
|
||||
SizedBox(height: 16.h),
|
||||
_buildErrorMessage(),
|
||||
],
|
||||
SizedBox(height: 32.h),
|
||||
// 登录按钮
|
||||
_buildLoginButton(),
|
||||
SizedBox(height: 24.h),
|
||||
// 分割线和提示
|
||||
_buildDivider(),
|
||||
SizedBox(height: 24.h),
|
||||
// 注册提示
|
||||
_buildRegisterHint(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建顶部导航栏
|
||||
Widget _buildAppBar() {
|
||||
return Container(
|
||||
height: 56.h,
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => context.pop(),
|
||||
child: Container(
|
||||
width: 40.w,
|
||||
height: 40.w,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.arrow_back,
|
||||
color: Colors.white,
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建手机号输入框
|
||||
Widget _buildPhoneInput() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'手机号',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _phoneController,
|
||||
focusNode: _phoneFocusNode,
|
||||
keyboardType: TextInputType.phone,
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(11),
|
||||
],
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入手机号',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
vertical: 14.h,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.phone_android,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_errorMessage != null) {
|
||||
setState(() => _errorMessage = null);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建密码输入框
|
||||
Widget _buildPasswordInput() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'密码',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
obscureText: _obscurePassword,
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: Colors.white,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '请输入密码',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
color: Colors.white.withOpacity(0.4),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16.w,
|
||||
vertical: 14.h,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
size: 20.sp,
|
||||
),
|
||||
suffixIcon: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
child: Icon(
|
||||
_obscurePassword ? Icons.visibility_off : Icons.visibility,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
size: 20.sp,
|
||||
),
|
||||
),
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_errorMessage != null) {
|
||||
setState(() => _errorMessage = null);
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) => _login(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建错误提示
|
||||
Widget _buildErrorMessage() {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(12.w),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
border: Border.all(
|
||||
color: Colors.red.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
size: 16.sp,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建登录按钮
|
||||
Widget _buildLoginButton() {
|
||||
final canLogin = !_isLoggingIn &&
|
||||
_phoneController.text.trim().isNotEmpty &&
|
||||
_passwordController.text.isNotEmpty;
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 50.h,
|
||||
child: ElevatedButton(
|
||||
onPressed: canLogin ? _login : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: canLogin
|
||||
? const Color(0xFFD4AF37)
|
||||
: Colors.white.withOpacity(0.2),
|
||||
disabledBackgroundColor: Colors.white.withOpacity(0.2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: _isLoggingIn
|
||||
? SizedBox(
|
||||
width: 20.w,
|
||||
height: 20.h,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'登录',
|
||||
style: TextStyle(
|
||||
fontSize: 16.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: canLogin ? Colors.white : Colors.white.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建分割线
|
||||
Widget _buildDivider() {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
thickness: 1,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||
child: Text(
|
||||
'或',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
thickness: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建注册提示
|
||||
Widget _buildRegisterHint() {
|
||||
return Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'还没有账号?',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: Colors.white.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 4.w),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
// 返回向导页
|
||||
context.go(RoutePaths.guide);
|
||||
},
|
||||
child: Text(
|
||||
'立即注册',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFFD4AF37),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import '../features/auth/presentation/pages/verify_mnemonic_page.dart';
|
|||
import '../features/auth/presentation/pages/wallet_created_page.dart';
|
||||
import '../features/auth/presentation/pages/import_mnemonic_page.dart';
|
||||
import '../features/auth/presentation/pages/phone_register_page.dart';
|
||||
import '../features/auth/presentation/pages/phone_login_page.dart';
|
||||
import '../features/auth/presentation/pages/sms_verify_page.dart';
|
||||
import '../features/auth/presentation/pages/set_password_page.dart';
|
||||
import '../features/home/presentation/pages/home_shell_page.dart';
|
||||
|
|
@ -145,6 +146,15 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
|||
},
|
||||
),
|
||||
|
||||
// Phone Login (手机号+密码登录)
|
||||
GoRoute(
|
||||
path: RoutePaths.phoneLogin,
|
||||
name: RouteNames.phoneLogin,
|
||||
builder: (context, state) {
|
||||
return const PhoneLoginPage();
|
||||
},
|
||||
),
|
||||
|
||||
// SMS Verify (短信验证码)
|
||||
GoRoute(
|
||||
path: RoutePaths.smsVerify,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ class RouteNames {
|
|||
static const importWallet = 'import-wallet';
|
||||
static const importMnemonic = 'import-mnemonic';
|
||||
static const phoneRegister = 'phone-register';
|
||||
static const phoneLogin = 'phone-login';
|
||||
static const smsVerify = 'sms-verify';
|
||||
static const setPassword = 'set-password';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue