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/wallet_created_page.dart';
|
||||||
import '../features/auth/presentation/pages/import_mnemonic_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_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/sms_verify_page.dart';
|
||||||
import '../features/auth/presentation/pages/set_password_page.dart';
|
import '../features/auth/presentation/pages/set_password_page.dart';
|
||||||
import '../features/home/presentation/pages/home_shell_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 (短信验证码)
|
// SMS Verify (短信验证码)
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RoutePaths.smsVerify,
|
path: RoutePaths.smsVerify,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ class RouteNames {
|
||||||
static const importWallet = 'import-wallet';
|
static const importWallet = 'import-wallet';
|
||||||
static const importMnemonic = 'import-mnemonic';
|
static const importMnemonic = 'import-mnemonic';
|
||||||
static const phoneRegister = 'phone-register';
|
static const phoneRegister = 'phone-register';
|
||||||
|
static const phoneLogin = 'phone-login';
|
||||||
static const smsVerify = 'sms-verify';
|
static const smsVerify = 'sms-verify';
|
||||||
static const setPassword = 'set-password';
|
static const setPassword = 'set-password';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue