diff --git a/hesabixAPI/app/core/settings.py b/hesabixAPI/app/core/settings.py index 3ab1f37..54215a2 100644 --- a/hesabixAPI/app/core/settings.py +++ b/hesabixAPI/app/core/settings.py @@ -29,6 +29,10 @@ class Settings(BaseSettings): captcha_secret: str = "change_me_captcha" 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_allowed_origins: list[str] = ["*"] diff --git a/hesabixAPI/app/services/auth_service.py b/hesabixAPI/app/services/auth_service.py index d05afc6..486dce8 100644 --- a/hesabixAPI/app/services/auth_service.py +++ b/hesabixAPI/app/services/auth_service.py @@ -22,9 +22,14 @@ def _normalize_email(email: str | None) -> str | None: def _normalize_mobile(mobile: str | None) -> str | None: if not mobile: 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: - 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): return None return phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.E164) diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb index 03d4ae2..ff9a166 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_en.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_en.arb @@ -29,5 +29,12 @@ "refresh": "Refresh" , "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." } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb index 80a23e9..90da8fb 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb +++ b/hesabixUI/hesabix_ui/lib/l10n/app_fa.arb @@ -29,5 +29,12 @@ "refresh": "تازه‌سازی" , "captchaRequired": "کد امنیتی الزامی است." + , + "sendReset": "ارسال کد بازیابی" + , + "registerFailed": "عضویت ناموفق بود. لطفاً دوباره تلاش کنید.", + "resetFailed": "ارسال کد بازیابی ناموفق بود. لطفاً دوباره تلاش کنید." + , + "fixFormErrors": "لطفاً خطاهای فرم را برطرف کنید." } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart index 1ea9309..1e1f526 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations.dart @@ -265,6 +265,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Captcha is required.'** 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 diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart index a947a56..ad4d8c0 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_en.dart @@ -93,4 +93,16 @@ class AppLocalizationsEn extends AppLocalizations { @override 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.'; } diff --git a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart index 21e48da..e58acd4 100644 --- a/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart +++ b/hesabixUI/hesabix_ui/lib/l10n/app_localizations_fa.dart @@ -92,4 +92,16 @@ class AppLocalizationsFa extends AppLocalizations { @override String get captchaRequired => 'کد امنیتی الزامی است.'; + + @override + String get sendReset => 'ارسال کد بازیابی'; + + @override + String get registerFailed => 'عضویت ناموفق بود. لطفاً دوباره تلاش کنید.'; + + @override + String get resetFailed => 'ارسال کد بازیابی ناموفق بود. لطفاً دوباره تلاش کنید.'; + + @override + String get fixFormErrors => 'لطفاً خطاهای فرم را برطرف کنید.'; } diff --git a/hesabixUI/hesabix_ui/lib/pages/login_page.dart b/hesabixUI/hesabix_ui/lib/pages/login_page.dart index 6c39398..c5b9394 100644 --- a/hesabixUI/hesabix_ui/lib/pages/login_page.dart +++ b/hesabixUI/hesabix_ui/lib/pages/login_page.dart @@ -12,7 +12,6 @@ import '../core/locale_controller.dart'; import '../theme/theme_controller.dart'; import '../widgets/auth_footer.dart'; import '../core/auth_store.dart'; -import '../widgets/error_notice.dart'; class LoginPage extends StatefulWidget { final LocaleController localeController; @@ -34,7 +33,6 @@ class _LoginPageState extends State with SingleTickerProviderStateMix Uint8List? _loginCaptchaImage; Timer? _loginCaptchaTimer; bool _loadingLogin = false; - String? _errorText; // Register final _registerKey = GlobalKey(); @@ -48,7 +46,6 @@ class _LoginPageState extends State with SingleTickerProviderStateMix Uint8List? _registerCaptchaImage; bool _loadingRegister = false; Timer? _registerCaptchaTimer; - String? _registerErrorText; // Forgot password final _forgotKey = GlobalKey(); @@ -58,7 +55,6 @@ class _LoginPageState extends State with SingleTickerProviderStateMix Uint8List? _forgotCaptchaImage; bool _loadingForgot = false; Timer? _forgotCaptchaTimer; - String? _forgotErrorText; @override void dispose() { @@ -155,7 +151,6 @@ class _LoginPageState extends State with SingleTickerProviderStateMix setState(() { _loadingLogin = true; - _errorText = null; }); try { @@ -183,8 +178,9 @@ class _LoginPageState extends State with SingleTickerProviderStateMix final msg = _extractErrorMessage(e, AppLocalizations.of(context)); _showSnack(msg); setState(() { - _errorText = msg; + _loginCaptchaCtrl.clear(); }); + // فقط اسنک‌بار نمایش داده می‌شود؛ وضعیت داخلی خطا ذخیره نمی‌شود } finally { if (mounted) { setState(() { @@ -196,19 +192,33 @@ class _LoginPageState extends State with SingleTickerProviderStateMix } Future _onRegister() async { - final form = _registerKey.currentState; 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); try { 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>( '/api/v1/auth/register', data: { @@ -223,14 +233,15 @@ class _LoginPageState extends State with SingleTickerProviderStateMix ); if (!mounted) return; - setState(() { _registerErrorText = null; }); _showSnack(t.registerSuccess); DefaultTabController.of(context).animateTo(0); } catch (e) { if (!mounted) return; final msg = _extractErrorMessage(e, AppLocalizations.of(context)); - setState(() { _registerErrorText = msg; }); - _showSnack(msg); + _showSnack(msg.isEmpty ? t.registerFailed : msg); + setState(() { + _registerCaptchaCtrl.clear(); + }); } finally { if (mounted) setState(() => _loadingRegister = false); _refreshCaptcha('register'); @@ -238,9 +249,16 @@ class _LoginPageState extends State with SingleTickerProviderStateMix } Future _onForgot() async { - final form = _forgotKey.currentState; 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); try { @@ -259,8 +277,10 @@ class _LoginPageState extends State with SingleTickerProviderStateMix } catch (e) { if (!mounted) return; final msg = _extractErrorMessage(e, AppLocalizations.of(context)); - setState(() { _forgotErrorText = msg; }); _showSnack(msg); + setState(() { + _forgotCaptchaCtrl.clear(); + }); } finally { if (mounted) setState(() => _loadingForgot = false); _refreshCaptcha('forgot'); @@ -277,279 +297,331 @@ class _LoginPageState extends State with SingleTickerProviderStateMix return DefaultTabController( length: 3, child: Scaffold( - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 520), - child: Card( - elevation: 2, - margin: const EdgeInsets.all(16), - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Image.asset(logoAsset, height: 28), - const SizedBox(width: 8), - Text(t.welcomeTitle, style: Theme.of(context).textTheme.titleMedium), - ], + resizeToAvoidBottomInset: true, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + return SingleChildScrollView( + padding: EdgeInsets.only(bottom: bottomInset + 16), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 520, + minHeight: constraints.maxHeight - 32, // to keep card vertically centered when possible ), - const SizedBox(height: 8), - Text(t.welcomeSubtitle, style: Theme.of(context).textTheme.bodySmall), - const SizedBox(height: 12), - TabBar(tabs: [Tab(text: t.login), Tab(text: t.register), Tab(text: t.forgotPassword)]), - const SizedBox(height: 16), - Builder(builder: (innerContext) { - final tabController = DefaultTabController.maybeOf(innerContext); - if (tabController == null) { - return const SizedBox.shrink(); - } - return AnimatedBuilder( - animation: tabController, - builder: (context, _) { - final idx = tabController.index; - 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), - ), - ], - ), + child: Card( + elevation: 2, + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Image.asset(logoAsset, height: 28), + const SizedBox(width: 8), + Text(t.welcomeTitle, style: Theme.of(context).textTheme.titleMedium), + ], ), - ); - } else if (idx == 1) { - body = Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Form( - key: _registerKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextFormField( - controller: _firstNameCtrl, - decoration: InputDecoration(labelText: t.firstName), - validator: (v) => (v == null || v.trim().isEmpty) ? '${t.firstName} ${t.requiredField}' : null, - 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, - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 12), - TextFormField( - controller: _emailCtrl, - decoration: InputDecoration(labelText: t.email), - keyboardType: TextInputType.emailAddress, - validator: (v) => (v == null || v.trim().isEmpty) ? '${t.email} ${t.requiredField}' : null, - textInputAction: TextInputAction.next, - ), - const SizedBox(height: 12), - 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, + const SizedBox(height: 8), + Text(t.welcomeSubtitle, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 12), + TabBar(tabs: [Tab(text: t.login), Tab(text: t.register), Tab(text: t.forgotPassword)]), + const SizedBox(height: 16), + Builder(builder: (innerContext) { + final tabController = DefaultTabController.maybeOf(innerContext); + if (tabController == null) { + return const SizedBox.shrink(); + } + return AnimatedBuilder( + animation: tabController, + builder: (context, _) { + final idx = tabController.index; + Widget body; + if (idx == 0) { + body = Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Stack( + children: [ + AbsorbPointer( + absorbing: _loadingLogin, + 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: _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], ), - ), - const SizedBox(width: 8), - if (_forgotCaptchaImage != null) - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: Image.memory( - _forgotCaptchaImage!, - height: 40, - width: 120, - fit: BoxFit.contain, + if (_loadingLogin) + Positioned.fill( + child: Container( + color: Colors.black26, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ), ), - ) - else - const SizedBox(height: 40, width: 120), - const SizedBox(width: 8), - IconButton( - onPressed: () => _refreshCaptcha('forgot'), - icon: const Icon(Icons.refresh), - tooltip: t.refresh, - ), - ], - ), - const SizedBox(height: 12), - if (_forgotErrorText != null) - ErrorNotice(message: _forgotErrorText!, onClose: () => setState(() => _forgotErrorText = null)), - const SizedBox(height: 12), - FilledButton( - onPressed: _loadingForgot ? null : _onForgot, - child: _loadingForgot - ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2)) - : Text(t.submit), - ), - ], - ), - ), - ); - } - 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), - ], + ], + ), + ); + } else if (idx == 1) { + body = Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Stack( + children: [ + AbsorbPointer( + absorbing: _loadingRegister, + child: Form( + key: _registerKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _firstNameCtrl, + decoration: InputDecoration(labelText: t.firstName), + validator: (v) => (v == null || v.trim().isEmpty) ? '${t.firstName} ${t.requiredField}' : null, + 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, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + TextFormField( + controller: _emailCtrl, + decoration: InputDecoration(labelText: t.email), + keyboardType: TextInputType.emailAddress, + validator: (v) => (v == null || v.trim().isEmpty) ? '${t.email} ${t.requiredField}' : null, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + 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), + ], + ), + ), + ), + ), ), - ), - ), + ); + }, ), ), ),