progress in login page

This commit is contained in:
Hesabix 2025-09-15 22:50:54 +03:30
parent f00d2eca6d
commit 5af2bf0460
8 changed files with 432 additions and 289 deletions

View file

@ -29,6 +29,10 @@ class Settings(BaseSettings):
captcha_secret: str = "change_me_captcha" captcha_secret: str = "change_me_captcha"
reset_password_ttl_seconds: int = 3600 reset_password_ttl_seconds: int = 3600
# Phone normalization
# Used as default region when parsing phone numbers without a country code
default_phone_region: str = "IR"
# CORS # CORS
cors_allowed_origins: list[str] = ["*"] cors_allowed_origins: list[str] = ["*"]

View file

@ -22,9 +22,14 @@ def _normalize_email(email: str | None) -> str | None:
def _normalize_mobile(mobile: str | None) -> str | None: def _normalize_mobile(mobile: str | None) -> str | None:
if not mobile: if not mobile:
return None return None
# Try parse as international; fallback no region # Clean input: keep digits and leading plus
raw = mobile.strip()
raw = ''.join(ch for ch in raw if ch.isdigit() or ch == '+')
try: try:
num = phonenumbers.parse(mobile, None) from app.core.settings import get_settings
settings = get_settings()
region = None if raw.startswith('+') else settings.default_phone_region
num = phonenumbers.parse(raw, region)
if not phonenumbers.is_valid_number(num): if not phonenumbers.is_valid_number(num):
return None return None
return phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.E164) return phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.E164)

View file

@ -29,5 +29,12 @@
"refresh": "Refresh" "refresh": "Refresh"
, ,
"captchaRequired": "Captcha is required." "captchaRequired": "Captcha is required."
,
"sendReset": "Send reset code"
,
"registerFailed": "Registration failed. Please try again.",
"resetFailed": "Request failed. Please try again."
,
"fixFormErrors": "Please fix the form errors."
} }

View file

@ -29,5 +29,12 @@
"refresh": "تازه‌سازی" "refresh": "تازه‌سازی"
, ,
"captchaRequired": "کد امنیتی الزامی است." "captchaRequired": "کد امنیتی الزامی است."
,
"sendReset": "ارسال کد بازیابی"
,
"registerFailed": "عضویت ناموفق بود. لطفاً دوباره تلاش کنید.",
"resetFailed": "ارسال کد بازیابی ناموفق بود. لطفاً دوباره تلاش کنید."
,
"fixFormErrors": "لطفاً خطاهای فرم را برطرف کنید."
} }

View file

@ -265,6 +265,30 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Captcha is required.'** /// **'Captcha is required.'**
String get captchaRequired; String get captchaRequired;
/// No description provided for @sendReset.
///
/// In en, this message translates to:
/// **'Send reset code'**
String get sendReset;
/// No description provided for @registerFailed.
///
/// In en, this message translates to:
/// **'Registration failed. Please try again.'**
String get registerFailed;
/// No description provided for @resetFailed.
///
/// In en, this message translates to:
/// **'Request failed. Please try again.'**
String get resetFailed;
/// No description provided for @fixFormErrors.
///
/// In en, this message translates to:
/// **'Please fix the form errors.'**
String get fixFormErrors;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -93,4 +93,16 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get captchaRequired => 'Captcha is required.'; String get captchaRequired => 'Captcha is required.';
@override
String get sendReset => 'Send reset code';
@override
String get registerFailed => 'Registration failed. Please try again.';
@override
String get resetFailed => 'Request failed. Please try again.';
@override
String get fixFormErrors => 'Please fix the form errors.';
} }

View file

@ -92,4 +92,16 @@ class AppLocalizationsFa extends AppLocalizations {
@override @override
String get captchaRequired => 'کد امنیتی الزامی است.'; String get captchaRequired => 'کد امنیتی الزامی است.';
@override
String get sendReset => 'ارسال کد بازیابی';
@override
String get registerFailed => 'عضویت ناموفق بود. لطفاً دوباره تلاش کنید.';
@override
String get resetFailed => 'ارسال کد بازیابی ناموفق بود. لطفاً دوباره تلاش کنید.';
@override
String get fixFormErrors => 'لطفاً خطاهای فرم را برطرف کنید.';
} }

View file

@ -12,7 +12,6 @@ import '../core/locale_controller.dart';
import '../theme/theme_controller.dart'; import '../theme/theme_controller.dart';
import '../widgets/auth_footer.dart'; import '../widgets/auth_footer.dart';
import '../core/auth_store.dart'; import '../core/auth_store.dart';
import '../widgets/error_notice.dart';
class LoginPage extends StatefulWidget { class LoginPage extends StatefulWidget {
final LocaleController localeController; final LocaleController localeController;
@ -34,7 +33,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
Uint8List? _loginCaptchaImage; Uint8List? _loginCaptchaImage;
Timer? _loginCaptchaTimer; Timer? _loginCaptchaTimer;
bool _loadingLogin = false; bool _loadingLogin = false;
String? _errorText;
// Register // Register
final _registerKey = GlobalKey<FormState>(); final _registerKey = GlobalKey<FormState>();
@ -48,7 +46,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
Uint8List? _registerCaptchaImage; Uint8List? _registerCaptchaImage;
bool _loadingRegister = false; bool _loadingRegister = false;
Timer? _registerCaptchaTimer; Timer? _registerCaptchaTimer;
String? _registerErrorText;
// Forgot password // Forgot password
final _forgotKey = GlobalKey<FormState>(); final _forgotKey = GlobalKey<FormState>();
@ -58,7 +55,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
Uint8List? _forgotCaptchaImage; Uint8List? _forgotCaptchaImage;
bool _loadingForgot = false; bool _loadingForgot = false;
Timer? _forgotCaptchaTimer; Timer? _forgotCaptchaTimer;
String? _forgotErrorText;
@override @override
void dispose() { void dispose() {
@ -155,7 +151,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
setState(() { setState(() {
_loadingLogin = true; _loadingLogin = true;
_errorText = null;
}); });
try { try {
@ -183,8 +178,9 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
final msg = _extractErrorMessage(e, AppLocalizations.of(context)); final msg = _extractErrorMessage(e, AppLocalizations.of(context));
_showSnack(msg); _showSnack(msg);
setState(() { setState(() {
_errorText = msg; _loginCaptchaCtrl.clear();
}); });
// فقط اسنکبار نمایش داده میشود؛ وضعیت داخلی خطا ذخیره نمیشود
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { setState(() {
@ -196,19 +192,33 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
} }
Future<void> _onRegister() async { Future<void> _onRegister() async {
final form = _registerKey.currentState;
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
if (form == null || !form.validate()) return; // اعتبارسنجی دستی و نمایش فقط Snackbar
if (_firstNameCtrl.text.trim().isEmpty) {
_showSnack('${t.firstName} ${t.requiredField}');
return;
}
if (_lastNameCtrl.text.trim().isEmpty) {
_showSnack('${t.lastName} ${t.requiredField}');
return;
}
if (_emailCtrl.text.trim().isEmpty && _mobileCtrl.text.trim().isEmpty) {
final msg = '${t.email} / ${t.mobile} ${t.requiredField}';
_showSnack(msg);
return;
}
if (_registerPasswordCtrl.text.isEmpty) {
_showSnack('${t.password} ${t.requiredField}');
return;
}
if (_registerCaptchaId == null || _registerCaptchaCtrl.text.trim().isEmpty) {
_showSnack(t.captchaRequired);
return;
}
setState(() => _loadingRegister = true); setState(() => _loadingRegister = true);
try { try {
final api = ApiClient(); final api = ApiClient();
if (_emailCtrl.text.trim().isEmpty && _mobileCtrl.text.trim().isEmpty) {
final msg = '${t.email} / ${t.mobile} ${t.requiredField}';
setState(() { _registerErrorText = msg; });
_showSnack(msg);
return;
}
await api.post<Map<String, dynamic>>( await api.post<Map<String, dynamic>>(
'/api/v1/auth/register', '/api/v1/auth/register',
data: { data: {
@ -223,14 +233,15 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
); );
if (!mounted) return; if (!mounted) return;
setState(() { _registerErrorText = null; });
_showSnack(t.registerSuccess); _showSnack(t.registerSuccess);
DefaultTabController.of(context).animateTo(0); DefaultTabController.of(context).animateTo(0);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final msg = _extractErrorMessage(e, AppLocalizations.of(context)); final msg = _extractErrorMessage(e, AppLocalizations.of(context));
setState(() { _registerErrorText = msg; }); _showSnack(msg.isEmpty ? t.registerFailed : msg);
_showSnack(msg); setState(() {
_registerCaptchaCtrl.clear();
});
} finally { } finally {
if (mounted) setState(() => _loadingRegister = false); if (mounted) setState(() => _loadingRegister = false);
_refreshCaptcha('register'); _refreshCaptcha('register');
@ -238,9 +249,16 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
} }
Future<void> _onForgot() async { Future<void> _onForgot() async {
final form = _forgotKey.currentState;
final t = AppLocalizations.of(context); final t = AppLocalizations.of(context);
if (form == null || !form.validate()) return; // اعتبارسنجی دستی و نمایش فقط Snackbar
if (_forgotIdentifierCtrl.text.trim().isEmpty) {
_showSnack('${t.identifier} ${t.requiredField}');
return;
}
if (_forgotCaptchaId == null || _forgotCaptchaCtrl.text.trim().isEmpty) {
_showSnack(t.captchaRequired);
return;
}
setState(() => _loadingForgot = true); setState(() => _loadingForgot = true);
try { try {
@ -259,8 +277,10 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final msg = _extractErrorMessage(e, AppLocalizations.of(context)); final msg = _extractErrorMessage(e, AppLocalizations.of(context));
setState(() { _forgotErrorText = msg; });
_showSnack(msg); _showSnack(msg);
setState(() {
_forgotCaptchaCtrl.clear();
});
} finally { } finally {
if (mounted) setState(() => _loadingForgot = false); if (mounted) setState(() => _loadingForgot = false);
_refreshCaptcha('forgot'); _refreshCaptcha('forgot');
@ -277,279 +297,331 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
return DefaultTabController( return DefaultTabController(
length: 3, length: 3,
child: Scaffold( child: Scaffold(
body: Center( resizeToAvoidBottomInset: true,
child: ConstrainedBox( body: SafeArea(
constraints: const BoxConstraints(maxWidth: 520), child: LayoutBuilder(
child: Card( builder: (context, constraints) {
elevation: 2, final bottomInset = MediaQuery.of(context).viewInsets.bottom;
margin: const EdgeInsets.all(16), return SingleChildScrollView(
child: Padding( padding: EdgeInsets.only(bottom: bottomInset + 16),
padding: const EdgeInsets.all(24.0), child: Center(
child: Column( child: ConstrainedBox(
mainAxisSize: MainAxisSize.min, constraints: BoxConstraints(
crossAxisAlignment: CrossAxisAlignment.stretch, maxWidth: 520,
children: [ minHeight: constraints.maxHeight - 32, // to keep card vertically centered when possible
Row(
children: [
Image.asset(logoAsset, height: 28),
const SizedBox(width: 8),
Text(t.welcomeTitle, style: Theme.of(context).textTheme.titleMedium),
],
), ),
const SizedBox(height: 8), child: Card(
Text(t.welcomeSubtitle, style: Theme.of(context).textTheme.bodySmall), elevation: 2,
const SizedBox(height: 12), margin: const EdgeInsets.all(16),
TabBar(tabs: [Tab(text: t.login), Tab(text: t.register), Tab(text: t.forgotPassword)]), child: Padding(
const SizedBox(height: 16), padding: const EdgeInsets.all(24.0),
Builder(builder: (innerContext) { child: Column(
final tabController = DefaultTabController.maybeOf(innerContext); mainAxisSize: MainAxisSize.min,
if (tabController == null) { crossAxisAlignment: CrossAxisAlignment.stretch,
return const SizedBox.shrink(); children: [
} Row(
return AnimatedBuilder( children: [
animation: tabController, Image.asset(logoAsset, height: 28),
builder: (context, _) { const SizedBox(width: 8),
final idx = tabController.index; Text(t.welcomeTitle, style: Theme.of(context).textTheme.titleMedium),
Widget body; ],
if (idx == 0) {
body = Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _identifierCtrl,
decoration: InputDecoration(labelText: t.identifier),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.identifier} ${t.requiredField}' : null,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextFormField(
controller: _passwordCtrl,
decoration: InputDecoration(labelText: t.password),
obscureText: true,
validator: (v) => (v == null || v.isEmpty) ? '${t.password} ${t.requiredField}' : null,
onFieldSubmitted: (_) => _onSubmit(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _loginCaptchaCtrl,
decoration: InputDecoration(labelText: t.captcha),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 8),
if (_loginCaptchaImage != null)
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.memory(
_loginCaptchaImage!,
height: 40,
width: 120,
fit: BoxFit.contain,
),
)
else
const SizedBox(height: 40, width: 120),
const SizedBox(width: 8),
IconButton(
onPressed: () => _refreshCaptcha('login'),
icon: const Icon(Icons.refresh),
tooltip: t.refresh,
),
],
),
const SizedBox(height: 16),
if (_errorText != null)
ErrorNotice(message: _errorText!, onClose: () => setState(() => _errorText = null)),
const SizedBox(height: 12),
FilledButton(
onPressed: _loadingLogin ? null : _onSubmit,
child: _loadingLogin
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(t.submit),
),
],
),
), ),
); const SizedBox(height: 8),
} else if (idx == 1) { Text(t.welcomeSubtitle, style: Theme.of(context).textTheme.bodySmall),
body = Padding( const SizedBox(height: 12),
padding: const EdgeInsets.symmetric(vertical: 16), TabBar(tabs: [Tab(text: t.login), Tab(text: t.register), Tab(text: t.forgotPassword)]),
child: Form( const SizedBox(height: 16),
key: _registerKey, Builder(builder: (innerContext) {
child: Column( final tabController = DefaultTabController.maybeOf(innerContext);
crossAxisAlignment: CrossAxisAlignment.stretch, if (tabController == null) {
children: [ return const SizedBox.shrink();
TextFormField( }
controller: _firstNameCtrl, return AnimatedBuilder(
decoration: InputDecoration(labelText: t.firstName), animation: tabController,
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.firstName} ${t.requiredField}' : null, builder: (context, _) {
textInputAction: TextInputAction.next, final idx = tabController.index;
), Widget body;
const SizedBox(height: 12), if (idx == 0) {
TextFormField( body = Padding(
controller: _lastNameCtrl, padding: const EdgeInsets.symmetric(vertical: 16),
decoration: InputDecoration(labelText: t.lastName), child: Stack(
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.lastName} ${t.requiredField}' : null, children: [
textInputAction: TextInputAction.next, AbsorbPointer(
), absorbing: _loadingLogin,
const SizedBox(height: 12), child: Form(
TextFormField( key: _formKey,
controller: _emailCtrl, child: Column(
decoration: InputDecoration(labelText: t.email), crossAxisAlignment: CrossAxisAlignment.stretch,
keyboardType: TextInputType.emailAddress, children: [
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.email} ${t.requiredField}' : null, TextFormField(
textInputAction: TextInputAction.next, controller: _identifierCtrl,
), decoration: InputDecoration(labelText: t.identifier),
const SizedBox(height: 12), validator: (v) => (v == null || v.trim().isEmpty) ? '${t.identifier} ${t.requiredField}' : null,
TextFormField( textInputAction: TextInputAction.next,
controller: _mobileCtrl, ),
decoration: InputDecoration(labelText: t.mobile), const SizedBox(height: 12),
keyboardType: TextInputType.phone, TextFormField(
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.mobile} ${t.requiredField}' : null, controller: _passwordCtrl,
textInputAction: TextInputAction.next, decoration: InputDecoration(labelText: t.password),
), obscureText: true,
const SizedBox(height: 12), validator: (v) => (v == null || v.isEmpty) ? '${t.password} ${t.requiredField}' : null,
TextFormField( onFieldSubmitted: (_) => _onSubmit(),
controller: _registerPasswordCtrl, ),
decoration: InputDecoration(labelText: t.password), const SizedBox(height: 12),
obscureText: true, Row(
validator: (v) => (v == null || v.isEmpty) ? '${t.password} ${t.requiredField}' : null, children: [
onFieldSubmitted: (_) => _onRegister(), Expanded(
), child: TextFormField(
const SizedBox(height: 12), controller: _loginCaptchaCtrl,
Row( decoration: InputDecoration(labelText: t.captcha),
children: [ validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null,
Expanded( keyboardType: TextInputType.number,
child: TextFormField( inputFormatters: [FilteringTextInputFormatter.digitsOnly],
controller: _registerCaptchaCtrl, ),
decoration: InputDecoration(labelText: t.captcha), ),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null, const SizedBox(width: 8),
keyboardType: TextInputType.number, if (_loginCaptchaImage != null)
inputFormatters: [FilteringTextInputFormatter.digitsOnly], ClipRRect(
), borderRadius: BorderRadius.circular(4),
), child: Image.memory(
const SizedBox(width: 8), _loginCaptchaImage!,
if (_registerCaptchaImage != null) height: 40,
ClipRRect( width: 120,
borderRadius: BorderRadius.circular(4), fit: BoxFit.contain,
child: Image.memory( ),
_registerCaptchaImage!, )
height: 40, else
width: 120, const SizedBox(height: 40, width: 120),
fit: BoxFit.contain, const SizedBox(width: 8),
IconButton(
onPressed: _loadingLogin ? null : () => _refreshCaptcha('login'),
icon: const Icon(Icons.refresh),
tooltip: t.refresh,
),
],
),
const SizedBox(height: 16),
// در تب ورود، فقط Snackbar نمایش داده میشود (بدون ویجت خطا)
const SizedBox(height: 12),
FilledButton(
onPressed: _loadingLogin ? null : _onSubmit,
child: _loadingLogin
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(t.login),
),
],
),
), ),
)
else
const SizedBox(height: 40, width: 120),
const SizedBox(width: 8),
IconButton(
onPressed: () => _refreshCaptcha('register'),
icon: const Icon(Icons.refresh),
tooltip: t.refresh,
),
],
),
const SizedBox(height: 16),
if (_registerErrorText != null)
ErrorNotice(message: _registerErrorText!, onClose: () => setState(() => _registerErrorText = null)),
const SizedBox(height: 12),
FilledButton(
onPressed: _loadingRegister ? null : _onRegister,
child: _loadingRegister
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(t.submit),
),
],
),
),
);
} else {
body = Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Form(
key: _forgotKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _forgotIdentifierCtrl,
decoration: InputDecoration(labelText: t.identifier),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.identifier} ${t.requiredField}' : null,
onFieldSubmitted: (_) => _onForgot(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _forgotCaptchaCtrl,
decoration: InputDecoration(labelText: t.captcha),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
), ),
), if (_loadingLogin)
const SizedBox(width: 8), Positioned.fill(
if (_forgotCaptchaImage != null) child: Container(
ClipRRect( color: Colors.black26,
borderRadius: BorderRadius.circular(4), alignment: Alignment.center,
child: Image.memory( child: const CircularProgressIndicator(),
_forgotCaptchaImage!, ),
height: 40,
width: 120,
fit: BoxFit.contain,
), ),
) ],
else ),
const SizedBox(height: 40, width: 120), );
const SizedBox(width: 8), } else if (idx == 1) {
IconButton( body = Padding(
onPressed: () => _refreshCaptcha('forgot'), padding: const EdgeInsets.symmetric(vertical: 16),
icon: const Icon(Icons.refresh), child: Stack(
tooltip: t.refresh, children: [
), AbsorbPointer(
], absorbing: _loadingRegister,
), child: Form(
const SizedBox(height: 12), key: _registerKey,
if (_forgotErrorText != null) child: Column(
ErrorNotice(message: _forgotErrorText!, onClose: () => setState(() => _forgotErrorText = null)), crossAxisAlignment: CrossAxisAlignment.stretch,
const SizedBox(height: 12), children: [
FilledButton( TextFormField(
onPressed: _loadingForgot ? null : _onForgot, controller: _firstNameCtrl,
child: _loadingForgot decoration: InputDecoration(labelText: t.firstName),
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) validator: (v) => (v == null || v.trim().isEmpty) ? '${t.firstName} ${t.requiredField}' : null,
: Text(t.submit), textInputAction: TextInputAction.next,
), ),
], const SizedBox(height: 12),
), TextFormField(
), controller: _lastNameCtrl,
); decoration: InputDecoration(labelText: t.lastName),
} validator: (v) => (v == null || v.trim().isEmpty) ? '${t.lastName} ${t.requiredField}' : null,
return AnimatedSize( textInputAction: TextInputAction.next,
duration: const Duration(milliseconds: 200), ),
curve: Curves.easeInOut, const SizedBox(height: 12),
alignment: Alignment.topCenter, TextFormField(
child: body, controller: _emailCtrl,
); decoration: InputDecoration(labelText: t.email),
}); keyboardType: TextInputType.emailAddress,
}), validator: (v) => (v == null || v.trim().isEmpty) ? '${t.email} ${t.requiredField}' : null,
const SizedBox(height: 8), textInputAction: TextInputAction.next,
Text(t.brandTagline, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall), ),
const SizedBox(height: 12), const SizedBox(height: 12),
AuthFooter(localeController: widget.localeController, themeController: widget.themeController), TextFormField(
], controller: _mobileCtrl,
decoration: InputDecoration(labelText: t.mobile),
keyboardType: TextInputType.phone,
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.mobile} ${t.requiredField}' : null,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 12),
TextFormField(
controller: _registerPasswordCtrl,
decoration: InputDecoration(labelText: t.password),
obscureText: true,
validator: (v) => (v == null || v.isEmpty) ? '${t.password} ${t.requiredField}' : null,
onFieldSubmitted: (_) => _onRegister(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _registerCaptchaCtrl,
decoration: InputDecoration(labelText: t.captcha),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 8),
if (_registerCaptchaImage != null)
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.memory(
_registerCaptchaImage!,
height: 40,
width: 120,
fit: BoxFit.contain,
),
)
else
const SizedBox(height: 40, width: 120),
const SizedBox(width: 8),
IconButton(
onPressed: _loadingRegister ? null : () => _refreshCaptcha('register'),
icon: const Icon(Icons.refresh),
tooltip: t.refresh,
),
],
),
const SizedBox(height: 16),
FilledButton(
onPressed: _loadingRegister ? null : _onRegister,
child: _loadingRegister
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(t.register),
),
],
),
),
),
if (_loadingRegister)
Positioned.fill(
child: Container(
color: Colors.black26,
alignment: Alignment.center,
child: const CircularProgressIndicator(),
),
),
],
),
);
} else {
body = Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Stack(
children: [
AbsorbPointer(
absorbing: _loadingForgot,
child: Form(
key: _forgotKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _forgotIdentifierCtrl,
decoration: InputDecoration(labelText: t.identifier),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.identifier} ${t.requiredField}' : null,
onFieldSubmitted: (_) => _onForgot(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextFormField(
controller: _forgotCaptchaCtrl,
decoration: InputDecoration(labelText: t.captcha),
validator: (v) => (v == null || v.trim().isEmpty) ? '${t.captcha} ${t.requiredField}' : null,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 8),
if (_forgotCaptchaImage != null)
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.memory(
_forgotCaptchaImage!,
height: 40,
width: 120,
fit: BoxFit.contain,
),
)
else
const SizedBox(height: 40, width: 120),
const SizedBox(width: 8),
IconButton(
onPressed: _loadingForgot ? null : () => _refreshCaptcha('forgot'),
icon: const Icon(Icons.refresh),
tooltip: t.refresh,
),
],
),
const SizedBox(height: 12),
FilledButton(
onPressed: _loadingForgot ? null : _onForgot,
child: _loadingForgot
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(t.sendReset),
),
],
),
),
),
if (_loadingForgot)
Positioned.fill(
child: Container(
color: Colors.black26,
alignment: Alignment.center,
child: const CircularProgressIndicator(),
),
),
],
),
);
}
return AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: body,
);
});
}),
const SizedBox(height: 8),
Text(t.brandTagline, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 12),
AuthFooter(localeController: widget.localeController, themeController: widget.themeController),
],
),
),
),
),
), ),
), );
), },
), ),
), ),
), ),