feat(auth): 对接后端 open 模块认证接口并完善登录页

- 更新接口路径、字段和短信验证码场景,新增忘记密码/重置密码流程
- 增加 401 自动刷新 token 与统一错误处理
- 重构登录页 UI,支持退出确认、协议与法律文档入口
This commit is contained in:
2026-08-17 09:30:25 +08:00
parent 1396487af9
commit 8478a99eba
20 changed files with 1963 additions and 194 deletions

View File

@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/network/error_message.dart';
import '../../../core/storage/token_storage.dart';
import '../data/auth_providers.dart';
import '../domain/auth_models.dart';
@@ -64,26 +66,64 @@ class AuthController extends Notifier<AuthState> {
}
/// 发送短信验证码并启动 60s 倒计时。
///
/// 发送前先做本地校验:手机号为空或格式不正确时直接提示,不发起请求、
/// 不启动倒计时,避免出现「空号也能点但弹报错」的体验问题。
Future<void> sendSmsCode({
required String phone,
required SmsScene scene,
}) async {
if (state.isSendingCode || state.countdownSeconds > 0) return;
final trimmed = phone.trim();
final phoneError = _validatePhone(trimmed);
if (phoneError != null) {
state = state.copyWith(alert: phoneError);
return;
}
state = state.copyWith(isSendingCode: true, clearAlert: true);
try {
await ref.read(authRepositoryProvider).sendSmsCode(
phone: phone,
phone: trimmed,
scene: scene,
);
_startCountdown();
} catch (e) {
state = state.copyWith(
isSendingCode: false,
alert: _messageOf(e),
alert: userMessageOf(e),
);
}
}
/// 校验手机号:空或不符合 11 位中国大陆手机号规则时返回提示文案。
String? _validatePhone(String phone) {
if (phone.isEmpty) return '请输入手机号';
// 1 开头的 11 位手机号。
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) return '手机号格式不正确';
return null;
}
/// 重置密码(忘记密码流程)。
Future<bool> resetPassword({
required String phone,
required String code,
required String password,
}) async {
state = state.copyWith(isLoading: true, clearAlert: true);
try {
await ref.read(authRepositoryProvider).resetPassword(
phone: phone,
code: code,
password: password,
);
state = state.copyWith(isLoading: false);
return true;
} catch (e) {
state = state.copyWith(isLoading: false, alert: userMessageOf(e));
return false;
}
}
/// 消费一次性提示。
void consumeAlert() {
if (state.alert != null) {
@@ -105,11 +145,21 @@ class AuthController extends Notifier<AuthState> {
state = state.copyWith(isLoading: false);
return true;
} catch (e) {
state = state.copyWith(isLoading: false, alert: _messageOf(e));
state = state.copyWith(isLoading: false, alert: userMessageOf(e));
// 鉴权失效:本地令牌可能已作废,主动登出清理。
if (_isUnauthorized(e)) {
logout();
}
return false;
}
}
/// 判断异常是否为鉴权失效401
bool _isUnauthorized(Object e) {
if (e is ApiException) return e.code == '401';
return false;
}
void _startCountdown() {
state = state.copyWith(isSendingCode: false, countdownSeconds: 60);
_countdownTimer?.cancel();
@@ -125,10 +175,7 @@ class AuthController extends Notifier<AuthState> {
}
String _messageOf(Object e) {
if (e is Exception) {
return e.toString().replaceFirst('Exception: ', '');
}
return '操作失败,请稍后重试';
return userMessageOf(e);
}
}

View File

@@ -0,0 +1,236 @@
import 'package:flutter/cupertino.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../l10n/app_localizations.dart';
import '../domain/auth_models.dart';
import 'auth_controller.dart';
/// 忘记密码页。
///
/// 业务动作委托给 [authControllerProvider]:发送验证码走 resetPassword 场景,
/// 提交走 resetPassword。UI 仅负责输入收集与导航。
class ForgotPasswordPage extends ConsumerStatefulWidget {
const ForgotPasswordPage({super.key});
@override
ConsumerState<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
}
class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
final _phoneController = TextEditingController();
final _codeController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_phoneController.dispose();
_codeController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
final phone = _phoneController.text.trim();
final phoneError = _validatePhone(phone);
if (phoneError != null) {
_showAlert(phoneError);
return;
}
if (_codeController.text.trim().isEmpty) {
_showAlert(l10n.codeEmpty);
return;
}
if (_passwordController.text.isEmpty) {
_showAlert(l10n.passwordEmpty);
return;
}
final ok = await ref.read(authControllerProvider.notifier).resetPassword(
phone: phone,
code: _codeController.text.trim(),
password: _passwordController.text,
);
if (ok && mounted) {
_showAlert(l10n.resetPasswordSuccess);
// 重置成功后返回登录页。
context.go('/login');
}
}
String? _validatePhone(String phone) {
if (phone.isEmpty) return l10n.phoneEmpty;
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) return l10n.phoneInvalid;
return null;
}
@override
Widget build(BuildContext context) {
ref.listen<String?>(authControllerProvider.select((s) => s.alert),
(_, alert) {
if (alert != null) {
_showAlert(alert);
ref.read(authControllerProvider.notifier).consumeAlert();
}
});
final state = ref.watch(authControllerProvider);
final l10n = AppLocalizations.of(context);
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) context.pop();
},
child: CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground,
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.forgotPasswordTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
CupertinoTextField(
controller: _phoneController,
placeholder: l10n.phoneHint,
keyboardType: TextInputType.phone,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: CupertinoColors.white,
borderRadius: BorderRadius.circular(10),
),
),
const SizedBox(height: 12),
_CodeField(
controller: _codeController,
countdownSeconds: state.countdownSeconds,
isSending: state.isSendingCode,
codeHint: l10n.codeHint,
getCodeLabel: l10n.getCode,
sendingLabel: l10n.sending,
onSend: () async {
final phone = _phoneController.text.trim();
final phoneError = _validatePhone(phone);
if (phoneError != null) {
_showAlert(phoneError);
return;
}
await ref.read(authControllerProvider.notifier).sendSmsCode(
phone: phone,
scene: SmsScene.resetPassword,
);
},
),
const SizedBox(height: 12),
CupertinoTextField(
controller: _passwordController,
placeholder: l10n.newPasswordHint,
obscureText: true,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: CupertinoColors.white,
borderRadius: BorderRadius.circular(10),
),
),
const SizedBox(height: 24),
CupertinoButton.filled(
onPressed: state.isLoading ? null : _submit,
child: state.isLoading
? const CupertinoActivityIndicator()
: Text(l10n.resetPassword),
),
const SizedBox(height: 12),
CupertinoButton(
onPressed: () => context.go('/login'),
child: Text(l10n.hasAccount),
),
],
),
),
),
),
);
}
AppLocalizations get l10n => AppLocalizations.of(context);
void _showAlert(String message) {
final l10n = AppLocalizations.of(context);
debugPrint('[Dialog] ForgotPasswordPage._showAlert 即将弹出提示弹窗'
' title=${l10n.alertTitle} message=$message');
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
title: Text(l10n.alertTitle),
content: Text(message),
actions: [
CupertinoDialogAction(
child: Text(l10n.confirm),
onPressed: () => Navigator.of(context).pop(),
),
],
),
);
}
}
/// 验证码输入 + 发送按钮(独立小组件)。提取为私有无状态组件避免冗长内联。
class _CodeField extends StatelessWidget {
const _CodeField({
required this.controller,
required this.countdownSeconds,
required this.isSending,
required this.codeHint,
required this.getCodeLabel,
required this.sendingLabel,
required this.onSend,
});
final TextEditingController controller;
final int countdownSeconds;
final bool isSending;
final String codeHint;
final String getCodeLabel;
final String sendingLabel;
final Future<void> Function() onSend;
@override
Widget build(BuildContext context) {
final counting = countdownSeconds > 0;
return Row(
children: [
Expanded(
child: CupertinoTextField(
controller: controller,
placeholder: codeHint,
keyboardType: TextInputType.number,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: CupertinoColors.white,
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(width: 12),
CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 12),
onPressed: (counting || isSending) ? null : () => onSend(),
child: Text(
counting
? '${countdownSeconds}s'
: (isSending ? sendingLabel : getCodeLabel),
style: const TextStyle(color: CupertinoColors.activeBlue),
),
),
],
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/cupertino.dart';
import 'package:go_router/go_router.dart';
import '../../../l10n/app_localizations.dart';
/// 法律文档类型。
enum LegalDocumentType {
userAgreement,
privacyPolicy,
}
/// 用户协议 / 隐私政策内容页。
///
/// 通过路由 [extra] 的 `type` 字段区分展示内容。标题与正文均由 l10n 提供,
/// 随系统语言自动切换(见 [AppLocalizations])。
class LegalDocumentPage extends StatelessWidget {
const LegalDocumentPage({super.key, required this.type});
final LegalDocumentType type;
static LegalDocumentPage fromExtra(Object? extra) {
final map = extra as Map<String, dynamic>?;
final raw = map?['type'] as String? ?? 'userAgreement';
final type = LegalDocumentType.values.firstWhere(
(e) => e.name == raw,
orElse: () => LegalDocumentType.userAgreement,
);
return LegalDocumentPage(type: type);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final (title, content) = switch (type) {
LegalDocumentType.userAgreement =>
(l10n.userAgreement, l10n.userAgreementContent),
LegalDocumentType.privacyPolicy =>
(l10n.privacyPolicy, l10n.privacyPolicyContent),
};
return CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground,
navigationBar: CupertinoNavigationBar(
middle: Text(title),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: SingleChildScrollView(
child: Text(
content,
style: const TextStyle(
fontSize: 15,
height: 1.6,
color: CupertinoColors.label,
),
),
),
),
),
);
}
}

View File

@@ -1,7 +1,9 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../app/theme/app_theme.dart';
import '../../../l10n/app_localizations.dart';
import '../domain/auth_models.dart';
import 'auth_controller.dart';
@@ -24,6 +26,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
/// true: 验证码登录false: 密码登录。
bool _isSmsLogin = false;
/// 密码是否明文显示。
bool _isPasswordVisible = false;
@override
void dispose() {
_phoneController.dispose();
@@ -32,6 +37,37 @@ class _LoginPageState extends ConsumerState<LoginPage> {
super.dispose();
}
/// 拦截系统返回手势:登录页为初始路由,返回时弹确认退出弹窗。
Future<bool> _onWillPop() async {
final shouldExit = await _showExitConfirm();
return shouldExit ?? false;
}
/// 弹出「确认退出应用」对话框,返回 true 表示用户确认退出。
Future<bool?> _showExitConfirm() {
final l10n = AppLocalizations.of(context);
debugPrint('[Dialog] LoginPage._showExitConfirm 即将弹出退出确认弹窗'
' title=${l10n.exitConfirmTitle} content=${l10n.exitConfirmContent}');
return showCupertinoDialog<bool>(
context: context,
builder: (_) => CupertinoAlertDialog(
title: Text(l10n.exitConfirmTitle),
content: Text(l10n.exitConfirmContent),
actions: [
CupertinoDialogAction(
child: Text(l10n.cancel),
onPressed: () => Navigator.of(context).pop(false),
),
CupertinoDialogAction(
isDestructiveAction: true,
child: Text(l10n.exitApp),
onPressed: () => Navigator.of(context).pop(true),
),
],
),
);
}
Future<void> _submit() async {
final phone = _phoneController.text.trim();
final ok = _isSmsLogin
@@ -46,108 +82,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
if (ok && mounted) context.go('/home');
}
@override
Widget build(BuildContext context) {
ref.listen<String?>(authControllerProvider.select((s) => s.alert),
(_, alert) {
if (alert != null) {
_showAlert(alert);
ref.read(authControllerProvider.notifier).consumeAlert();
}
});
final state = ref.watch(authControllerProvider);
final l10n = AppLocalizations.of(context);
return CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground,
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.loginTitle),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 24),
CupertinoSlidingSegmentedControl<bool>(
groupValue: _isSmsLogin,
children: {
false: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(l10n.passwordLogin),
),
true: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(l10n.smsLogin),
),
},
onValueChanged: (value) {
if (value != null) setState(() => _isSmsLogin = value);
},
),
const SizedBox(height: 24),
CupertinoTextField(
controller: _phoneController,
placeholder: l10n.phoneHint,
keyboardType: TextInputType.phone,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: CupertinoColors.white,
borderRadius: BorderRadius.circular(10),
),
),
const SizedBox(height: 12),
if (_isSmsLogin)
_CodeField(
controller: _codeController,
countdownSeconds: state.countdownSeconds,
isSending: state.isSendingCode,
codeHint: l10n.codeHint,
getCodeLabel: l10n.getCode,
sendingLabel: l10n.sending,
onSend: () async {
await ref
.read(authControllerProvider.notifier)
.sendSmsCode(
phone: _phoneController.text.trim(),
scene: SmsScene.login,
);
},
)
else
CupertinoTextField(
controller: _passwordController,
placeholder: l10n.passwordHint,
obscureText: true,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: CupertinoColors.white,
borderRadius: BorderRadius.circular(10),
),
),
const SizedBox(height: 24),
CupertinoButton.filled(
onPressed: state.isLoading ? null : _submit,
child: state.isLoading
? const CupertinoActivityIndicator()
: Text(l10n.login),
),
const SizedBox(height: 12),
CupertinoButton(
onPressed: () => context.go('/register'),
child: Text(l10n.noAccount),
),
],
),
),
),
);
}
void _showAlert(String message) {
final l10n = AppLocalizations.of(context);
debugPrint('[Dialog] LoginPage._showAlert 即将弹出提示弹窗'
' title=${l10n.alertTitle} message=$message');
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
@@ -162,9 +100,470 @@ class _LoginPageState extends ConsumerState<LoginPage> {
),
);
}
void _showPlaceholderAlert(String message) {
_showAlert(message);
}
@override
Widget build(BuildContext context) {
ref.listen<String?>(authControllerProvider.select((s) => s.alert),
(_, alert) {
if (alert != null) {
_showAlert(alert);
ref.read(authControllerProvider.notifier).consumeAlert();
}
});
final state = ref.watch(authControllerProvider);
final l10n = AppLocalizations.of(context);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
await _onWillPop();
},
child: CupertinoPageScaffold(
backgroundColor: CupertinoColors.white,
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 48),
_buildHeader(l10n),
const SizedBox(height: 40),
_LoginTypeToggle(
isSmsLogin: _isSmsLogin,
passwordLabel: l10n.passwordLogin,
smsLabel: l10n.smsLogin,
onChanged: (value) {
if (value != _isSmsLogin) {
setState(() => _isSmsLogin = value);
}
},
),
const SizedBox(height: 28),
_InputField(
controller: _phoneController,
placeholder: l10n.phoneHintDetailed,
prefixIcon: CupertinoIcons.device_phone_portrait,
keyboardType: TextInputType.phone,
),
const SizedBox(height: 16),
if (_isSmsLogin)
_CodeField(
controller: _codeController,
countdownSeconds: state.countdownSeconds,
isSending: state.isSendingCode,
codeHint: l10n.codeHint,
getCodeLabel: l10n.getCode,
sendingLabel: l10n.sending,
onSend: () async {
final phone = _phoneController.text.trim();
if (phone.isEmpty) {
_showAlert(l10n.phoneEmpty);
return;
}
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) {
_showAlert(l10n.phoneInvalid);
return;
}
await ref
.read(authControllerProvider.notifier)
.sendSmsCode(
phone: phone,
scene: SmsScene.login,
);
},
)
else
_PasswordField(
controller: _passwordController,
placeholder: l10n.passwordHintDetailed,
isVisible: _isPasswordVisible,
onVisibilityChanged: (visible) {
setState(() => _isPasswordVisible = visible);
},
),
const SizedBox(height: 28),
SizedBox(
height: 48,
child: CupertinoButton.filled(
borderRadius: BorderRadius.circular(24),
padding: EdgeInsets.zero,
onPressed: state.isLoading ? null : _submit,
child: state.isLoading
? const CupertinoActivityIndicator(
color: CupertinoColors.white,
)
: Text(
l10n.login,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
child: Text(
l10n.registerTitle,
style: const TextStyle(
color: AppTheme.primary,
fontSize: 14,
),
),
onPressed: () => context.push('/register'),
),
CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
child: Text(
l10n.forgotPassword,
style: const TextStyle(
color: CupertinoColors.secondaryLabel,
fontSize: 14,
),
),
onPressed: () => context.push('/forgot-password'),
),
],
),
const SizedBox(height: 32),
_buildDivider(l10n.otherLoginMethods),
const SizedBox(height: 20),
_buildWeChatLoginButton(l10n),
const SizedBox(height: 28),
_buildAgreement(l10n),
const SizedBox(height: 16),
],
),
),
),
),
);
}
Widget _buildHeader(AppLocalizations l10n) {
return Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(22),
),
child: const Icon(
CupertinoIcons.lock_fill,
color: CupertinoColors.white,
size: 36,
),
),
const SizedBox(height: 24),
Text(
l10n.welcomeLoginTitle,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: CupertinoColors.black,
),
),
const SizedBox(height: 10),
Text(
l10n.welcomeLoginSubtitle,
style: const TextStyle(
fontSize: 14,
color: CupertinoColors.secondaryLabel,
),
),
],
);
}
Widget _buildDivider(String label) {
return Row(
children: [
Expanded(
child: Container(
height: 1,
color: CupertinoColors.systemGrey5,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
label,
style: const TextStyle(
fontSize: 13,
color: CupertinoColors.tertiaryLabel,
),
),
),
Expanded(
child: Container(
height: 1,
color: CupertinoColors.systemGrey5,
),
),
],
);
}
Widget _buildWeChatLoginButton(AppLocalizations l10n) {
return SizedBox(
height: 48,
child: CupertinoButton(
color: const Color(0xFF07C160),
borderRadius: BorderRadius.circular(24),
padding: EdgeInsets.zero,
minimumSize: Size.zero,
onPressed: () => _showPlaceholderAlert('TODO: 调起微信登录'),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
CupertinoIcons.chat_bubble_fill,
color: CupertinoColors.white,
size: 20,
),
const SizedBox(width: 8),
Text(
l10n.wechatLogin,
style: const TextStyle(
color: CupertinoColors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
Widget _buildAgreement(AppLocalizations l10n) {
return Center(
child: Text.rich(
TextSpan(
text: l10n.userAgreementPrefix,
style: const TextStyle(
fontSize: 12,
color: CupertinoColors.tertiaryLabel,
),
children: [
TextSpan(
text: l10n.userAgreement,
style: const TextStyle(
color: AppTheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => context.push(
'/legal',
extra: {'type': 'userAgreement'},
),
),
TextSpan(text: l10n.andConnector),
TextSpan(
text: l10n.privacyPolicy,
style: const TextStyle(
color: AppTheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => context.push(
'/legal',
extra: {'type': 'privacyPolicy'},
),
),
],
),
textAlign: TextAlign.center,
),
);
}
}
/// 验证码输入 + 发送按钮(独立小组件,避免内联冗长)。提取为私有无状态组件
/// 登录方式切换(密码 / 验证码)
class _LoginTypeToggle extends StatelessWidget {
const _LoginTypeToggle({
required this.isSmsLogin,
required this.passwordLabel,
required this.smsLabel,
required this.onChanged,
});
final bool isSmsLogin;
final String passwordLabel;
final String smsLabel;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return Container(
height: 44,
decoration: BoxDecoration(
color: const Color(0xFFF5F7FA),
borderRadius: BorderRadius.circular(22),
),
child: Row(
children: [
Expanded(
child: _ToggleItem(
label: passwordLabel,
selected: !isSmsLogin,
onTap: () => onChanged(false),
),
),
Expanded(
child: _ToggleItem(
label: smsLabel,
selected: isSmsLogin,
onTap: () => onChanged(true),
),
),
],
),
);
}
}
class _ToggleItem extends StatelessWidget {
const _ToggleItem({
required this.label,
required this.selected,
required this.onTap,
});
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
decoration: BoxDecoration(
color: selected ? AppTheme.primary : null,
borderRadius: BorderRadius.circular(22),
),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
color: selected
? CupertinoColors.white
: CupertinoColors.secondaryLabel,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
);
}
}
/// 带前缀图标的输入框。
class _InputField extends StatelessWidget {
const _InputField({
required this.controller,
required this.placeholder,
required this.prefixIcon,
this.keyboardType,
this.obscureText = false,
this.suffix,
});
final TextEditingController controller;
final String placeholder;
final IconData prefixIcon;
final TextInputType? keyboardType;
final bool obscureText;
final Widget? suffix;
@override
Widget build(BuildContext context) {
return Container(
height: 52,
decoration: BoxDecoration(
color: const Color(0xFFF5F7FA),
borderRadius: BorderRadius.circular(12),
),
child: CupertinoTextField(
controller: controller,
placeholder: placeholder,
keyboardType: keyboardType,
obscureText: obscureText,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(),
prefix: Padding(
padding: const EdgeInsets.only(left: 16),
child: Icon(
prefixIcon,
color: CupertinoColors.tertiaryLabel,
size: 20,
),
),
suffix: suffix,
style: const TextStyle(
fontSize: 15,
color: CupertinoColors.label,
),
placeholderStyle: const TextStyle(
fontSize: 15,
color: CupertinoColors.tertiaryLabel,
),
),
);
}
}
/// 密码输入框(含显隐切换)。
class _PasswordField extends StatelessWidget {
const _PasswordField({
required this.controller,
required this.placeholder,
required this.isVisible,
required this.onVisibilityChanged,
});
final TextEditingController controller;
final String placeholder;
final bool isVisible;
final ValueChanged<bool> onVisibilityChanged;
@override
Widget build(BuildContext context) {
return _InputField(
controller: controller,
placeholder: placeholder,
prefixIcon: CupertinoIcons.lock,
obscureText: !isVisible,
suffix: CupertinoButton(
padding: const EdgeInsets.only(right: 12),
minimumSize: Size.zero,
onPressed: () => onVisibilityChanged(!isVisible),
child: Icon(
isVisible ? CupertinoIcons.eye_slash : CupertinoIcons.eye,
color: CupertinoColors.tertiaryLabel,
size: 20,
),
),
);
}
}
/// 验证码输入 + 发送按钮(独立小组件,避免内联冗长)。
class _CodeField extends StatelessWidget {
const _CodeField({
required this.controller,
@@ -190,15 +589,11 @@ class _CodeField extends StatelessWidget {
return Row(
children: [
Expanded(
child: CupertinoTextField(
child: _InputField(
controller: controller,
placeholder: codeHint,
prefixIcon: CupertinoIcons.lock,
keyboardType: TextInputType.number,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: CupertinoColors.white,
borderRadius: BorderRadius.circular(10),
),
),
),
const SizedBox(width: 12),
@@ -209,7 +604,7 @@ class _CodeField extends StatelessWidget {
counting
? '${countdownSeconds}s'
: (isSending ? sendingLabel : getCodeLabel),
style: const TextStyle(color: CupertinoColors.activeBlue),
style: const TextStyle(color: AppTheme.primary),
),
),
],

View File

@@ -51,12 +51,22 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
final state = ref.watch(authControllerProvider);
final l10n = AppLocalizations.of(context);
return CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground,
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.registerTitle),
),
child: SafeArea(
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) context.pop();
},
child: CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground,
navigationBar: CupertinoNavigationBar(
middle: Text(l10n.registerTitle),
leading: CupertinoButton(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back),
onPressed: () => context.pop(),
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -82,8 +92,17 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
getCodeLabel: l10n.getCode,
sendingLabel: l10n.sending,
onSend: () async {
final phone = _phoneController.text.trim();
if (phone.isEmpty) {
_showAlert(l10n.phoneEmpty);
return;
}
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) {
_showAlert(l10n.phoneInvalid);
return;
}
await ref.read(authControllerProvider.notifier).sendSmsCode(
phone: _phoneController.text.trim(),
phone: phone,
scene: SmsScene.register,
);
},
@@ -115,11 +134,14 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
),
),
),
),
);
}
void _showAlert(String message) {
final l10n = AppLocalizations.of(context);
debugPrint('[Dialog] RegisterPage._showAlert 即将弹出提示弹窗'
' title=${l10n.alertTitle} message=$message');
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(