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:
hailin 2025-12-20 20:13:09 -08:00
parent ed6c08914c
commit 65f3e75f59
3 changed files with 515 additions and 0 deletions

View File

@ -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) {
// 13-911
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,
),
),
),
],
),
);
}
}

View File

@ -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,

View File

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