8d890c0424
- 新增 lib/core/network/:ApiClient(dio 封装,错误信封→类型化异常,base URL 经 --dart-define=PATBOND_API_BASE_URL 注入)、AuthInterceptor(Bearer + 设备标识)、TokenRefresher(单飞刷新,401/40101 重放一次,仅 40102/再 401 清会话) - 新增 lib/features/auth/:SessionManager(token 只进 flutter_secure_storage)、AuthRepository(login/register/refresh/logout/me,注册带 Idempotency-Key)、Splash(500ms 最短停留/5s 超时/错误态重试+改用账号登录)、登录页与注册页(照 12 号组装稿,错误三层映射,预留区不渲染) - App 根组件改为认证状态机驱动(Splash↔登录↔主壳 300ms fade);个人中心退出登录接入真实 logout - 顺带修复:FIX-1 促销卡渐变改 [primaryStrong, primary]、FIX-2 补 helperStyle: muted、README dart format 命令补 --output=none - 新增依赖:dio ^5.11.1、flutter_secure_storage ^11.0.0、uuid ^4.6.0 - 门禁:dart format(0 changed)/ flutter analyze(No issues)/ flutter test(30 passed,其中新增 23:TokenRefresher 5 + AuthRepository 10 + 登录页 4 + 注册页 4) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
319 lines
11 KiB
Dart
319 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/semantics.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:patbond_flutter/core/network/api_exception.dart';
|
|
import 'package:patbond_flutter/core/theme/app_theme.dart';
|
|
import 'package:patbond_flutter/core/widgets/app_text_field.dart';
|
|
import 'package:patbond_flutter/core/widgets/auth_scaffold.dart';
|
|
import 'package:patbond_flutter/core/widgets/inline_error_banner.dart';
|
|
import 'package:patbond_flutter/core/widgets/primary_button.dart';
|
|
import 'package:patbond_flutter/features/auth/auth_repository.dart';
|
|
|
|
enum _Field { username, phone, password, confirm }
|
|
|
|
/// 注册页(12 号组装稿 §6)。字段按 ADR-004:用户名 + 手机号 + 密码 + 确认密码;
|
|
/// 短信验证码行与第三方登录预留区一律不渲染。注册成功即建立会话直接进首页。
|
|
class RegisterPage extends StatefulWidget {
|
|
const RegisterPage({required this.authRepository, super.key});
|
|
|
|
final AuthRepository authRepository;
|
|
|
|
@override
|
|
State<RegisterPage> createState() => _RegisterPageState();
|
|
}
|
|
|
|
class _RegisterPageState extends State<RegisterPage> {
|
|
static final _usernamePattern = RegExp(r'^[A-Za-z][A-Za-z0-9_]{2,19}$');
|
|
static final _phonePattern = RegExp(r'^1\d{10}$');
|
|
|
|
final _usernameCtrl = TextEditingController();
|
|
final _phoneCtrl = TextEditingController();
|
|
final _passwordCtrl = TextEditingController();
|
|
final _confirmCtrl = TextEditingController();
|
|
String? _usernameError;
|
|
String? _phoneError;
|
|
String? _passwordError;
|
|
String? _confirmError;
|
|
String? _formError;
|
|
bool _submitting = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_usernameCtrl.dispose();
|
|
_phoneCtrl.dispose();
|
|
_passwordCtrl.dispose();
|
|
_confirmCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
String? _validateUsernameValue() {
|
|
final value = _usernameCtrl.text.trim();
|
|
if (value.isEmpty) return '请输入用户名';
|
|
if (!_usernamePattern.hasMatch(value)) {
|
|
return '用户名需 3–20 位,字母开头,可含数字和下划线';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String? _validatePhoneValue() {
|
|
final value = _phoneCtrl.text.trim();
|
|
if (value.isEmpty) return '请输入手机号';
|
|
if (!_phonePattern.hasMatch(value)) return '请输入正确的 11 位手机号';
|
|
return null;
|
|
}
|
|
|
|
String? _validatePasswordValue() {
|
|
final value = _passwordCtrl.text;
|
|
final lengthOk = value.length >= 8 && value.length <= 32;
|
|
final hasLetter = value.contains(RegExp('[A-Za-z]'));
|
|
final hasDigit = value.contains(RegExp('[0-9]'));
|
|
if (!lengthOk || !hasLetter || !hasDigit) {
|
|
return '密码需 8–32 位,且同时包含字母和数字';
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String? _validateConfirmValue() =>
|
|
_confirmCtrl.text == _passwordCtrl.text ? null : '两次输入的密码不一致';
|
|
|
|
void _validateOnBlur(_Field field) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
switch (field) {
|
|
case _Field.username:
|
|
_usernameError = _validateUsernameValue();
|
|
case _Field.phone:
|
|
_phoneError = _validatePhoneValue();
|
|
case _Field.password:
|
|
_passwordError = _validatePasswordValue();
|
|
case _Field.confirm:
|
|
_confirmError = _validateConfirmValue();
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 字段变更清除本字段错误与横幅;密码变更时若确认密码已有值,
|
|
/// 同步重校验一致性(组装稿 §6)。
|
|
void _clearError(_Field field) {
|
|
setState(() {
|
|
_formError = null;
|
|
switch (field) {
|
|
case _Field.username:
|
|
_usernameError = null;
|
|
case _Field.phone:
|
|
_phoneError = null;
|
|
case _Field.password:
|
|
_passwordError = null;
|
|
if (_confirmCtrl.text.isNotEmpty) {
|
|
_confirmError = _validateConfirmValue();
|
|
}
|
|
case _Field.confirm:
|
|
_confirmError = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _showFormError(String message) {
|
|
setState(() => _formError = message);
|
|
SemanticsService.sendAnnouncement(
|
|
View.of(context),
|
|
message,
|
|
TextDirection.ltr,
|
|
);
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (_submitting) return;
|
|
final usernameError = _validateUsernameValue();
|
|
final phoneError = _validatePhoneValue();
|
|
final passwordError = _validatePasswordValue();
|
|
final confirmError = _validateConfirmValue();
|
|
if (usernameError != null ||
|
|
phoneError != null ||
|
|
passwordError != null ||
|
|
confirmError != null) {
|
|
setState(() {
|
|
_usernameError = usernameError;
|
|
_phoneError = phoneError;
|
|
_passwordError = passwordError;
|
|
_confirmError = confirmError;
|
|
});
|
|
return;
|
|
}
|
|
setState(() {
|
|
_submitting = true;
|
|
_formError = null;
|
|
});
|
|
try {
|
|
await widget.authRepository.register(
|
|
username: _usernameCtrl.text.trim(),
|
|
phone: _phoneCtrl.text.trim(),
|
|
password: _passwordCtrl.text,
|
|
);
|
|
TextInput.finishAutofillContext();
|
|
if (!mounted) return;
|
|
// 会话已建立,根路由切主壳;弹掉注册页露出新首页,不回登录页。
|
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
} on ApiBusinessException catch (error) {
|
|
if (!mounted) return;
|
|
switch (error.code) {
|
|
case ApiCodes.usernameTaken:
|
|
setState(() => _usernameError = '该用户名已被使用');
|
|
case ApiCodes.phoneTaken:
|
|
setState(() => _phoneError = '该手机号已注册,可直接登录');
|
|
default:
|
|
_showFormError('注册失败,请稍后重试');
|
|
}
|
|
} on ApiRateLimitException {
|
|
if (!mounted) return;
|
|
_showFormError('尝试次数过多,请稍后再试');
|
|
} on ApiNetworkException {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: const Text('网络异常,请检查网络后重试'),
|
|
action: SnackBarAction(label: '重试', onPressed: _submit),
|
|
),
|
|
);
|
|
} on SessionExpiredException {
|
|
// 注册请求不带会话,理论不可达。
|
|
} finally {
|
|
if (mounted) setState(() => _submitting = false);
|
|
}
|
|
}
|
|
|
|
Widget _blurValidated(_Field field, Widget child) {
|
|
return Focus(
|
|
onFocusChange: (hasFocus) {
|
|
if (!hasFocus) _validateOnBlur(field);
|
|
},
|
|
child: child,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AuthScaffold(
|
|
appBar: AppBar(
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 0,
|
|
foregroundColor: AppColors.ink,
|
|
),
|
|
child: AutofillGroup(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 8),
|
|
Text('创建账号', style: Theme.of(context).textTheme.headlineSmall),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'加入 Patbond,记录毛孩子的每一天',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 32),
|
|
_blurValidated(
|
|
_Field.username,
|
|
AppTextField(
|
|
label: '用户名',
|
|
controller: _usernameCtrl,
|
|
prefixIcon: Icons.person_outline_rounded,
|
|
errorText: _usernameError,
|
|
enabled: !_submitting,
|
|
textInputAction: TextInputAction.next,
|
|
autofillHints: const [AutofillHints.newUsername],
|
|
onChanged: (_) => _clearError(_Field.username),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_blurValidated(
|
|
_Field.phone,
|
|
AppTextField(
|
|
label: '手机号',
|
|
controller: _phoneCtrl,
|
|
prefixIcon: Icons.phone_iphone_rounded,
|
|
errorText: _phoneError,
|
|
enabled: !_submitting,
|
|
keyboardType: TextInputType.phone,
|
|
textInputAction: TextInputAction.next,
|
|
autofillHints: const [AutofillHints.telephoneNumber],
|
|
onChanged: (_) => _clearError(_Field.phone),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_blurValidated(
|
|
_Field.password,
|
|
AppTextField(
|
|
label: '密码',
|
|
controller: _passwordCtrl,
|
|
prefixIcon: Icons.lock_outline_rounded,
|
|
obscurable: true,
|
|
errorText: _passwordError,
|
|
helperText: '密码 8–32 位,需包含字母和数字',
|
|
enabled: !_submitting,
|
|
textInputAction: TextInputAction.next,
|
|
autofillHints: const [AutofillHints.newPassword],
|
|
onChanged: (_) => _clearError(_Field.password),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_blurValidated(
|
|
_Field.confirm,
|
|
AppTextField(
|
|
label: '确认密码',
|
|
controller: _confirmCtrl,
|
|
prefixIcon: Icons.lock_outline_rounded,
|
|
obscurable: true,
|
|
errorText: _confirmError,
|
|
enabled: !_submitting,
|
|
textInputAction: TextInputAction.done,
|
|
autofillHints: const [AutofillHints.newPassword],
|
|
onChanged: (_) => _clearError(_Field.confirm),
|
|
onSubmitted: (_) => _submit(),
|
|
),
|
|
),
|
|
if (_formError != null) ...[
|
|
const SizedBox(height: 16),
|
|
InlineErrorBanner(message: _formError!),
|
|
],
|
|
const SizedBox(height: 32),
|
|
PrimaryButton(
|
|
label: '注册',
|
|
isLoading: _submitting,
|
|
onPressed: _submit,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Center(
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text(
|
|
'已有账号?',
|
|
style: TextStyle(color: AppColors.muted, fontSize: 14),
|
|
),
|
|
TextButton(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.primaryStrong,
|
|
minimumSize: const Size(44, 44),
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
textStyle: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
onPressed: _submitting
|
|
? null
|
|
: () => Navigator.of(context).pop(),
|
|
child: const Text('直接登录'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|