2025-09-15 21:50:09 +03:30
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
2025-09-15 13:53:54 +03:30
|
|
|
import 'package:flutter/material.dart';
|
2025-09-15 21:50:09 +03:30
|
|
|
import 'package:flutter/services.dart';
|
2025-09-15 13:53:54 +03:30
|
|
|
import 'package:go_router/go_router.dart';
|
2025-09-15 21:50:09 +03:30
|
|
|
import 'package:dio/dio.dart';
|
2025-09-15 13:53:54 +03:30
|
|
|
|
|
|
|
|
import '../core/api_client.dart';
|
|
|
|
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
|
|
|
|
import '../core/locale_controller.dart';
|
2025-09-18 10:44:23 +03:30
|
|
|
import '../core/calendar_controller.dart';
|
2025-09-15 13:53:54 +03:30
|
|
|
import '../theme/theme_controller.dart';
|
|
|
|
|
import '../widgets/auth_footer.dart';
|
2025-09-15 21:50:09 +03:30
|
|
|
import '../core/auth_store.dart';
|
2025-09-18 10:44:23 +03:30
|
|
|
import '../core/referral_store.dart';
|
2025-09-15 13:53:54 +03:30
|
|
|
|
|
|
|
|
class LoginPage extends StatefulWidget {
|
|
|
|
|
final LocaleController localeController;
|
2025-09-18 10:44:23 +03:30
|
|
|
final CalendarController calendarController;
|
2025-09-15 13:53:54 +03:30
|
|
|
final ThemeController? themeController;
|
2025-09-15 21:50:09 +03:30
|
|
|
final AuthStore authStore;
|
2025-09-18 10:44:23 +03:30
|
|
|
const LoginPage({super.key, required this.localeController, required this.calendarController, this.themeController, required this.authStore});
|
2025-09-15 13:53:54 +03:30
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
State<LoginPage> createState() => _LoginPageState();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
|
|
|
|
|
// Login
|
|
|
|
|
final _formKey = GlobalKey<FormState>();
|
2025-09-15 21:50:09 +03:30
|
|
|
final _identifierCtrl = TextEditingController();
|
2025-09-15 13:53:54 +03:30
|
|
|
final _passwordCtrl = TextEditingController();
|
2025-09-15 21:50:09 +03:30
|
|
|
final _loginCaptchaCtrl = TextEditingController();
|
|
|
|
|
String? _loginCaptchaId;
|
|
|
|
|
Uint8List? _loginCaptchaImage;
|
|
|
|
|
Timer? _loginCaptchaTimer;
|
2025-09-15 13:53:54 +03:30
|
|
|
bool _loadingLogin = false;
|
|
|
|
|
|
|
|
|
|
// Register
|
|
|
|
|
final _registerKey = GlobalKey<FormState>();
|
|
|
|
|
final _firstNameCtrl = TextEditingController();
|
|
|
|
|
final _lastNameCtrl = TextEditingController();
|
|
|
|
|
final _emailCtrl = TextEditingController();
|
|
|
|
|
final _mobileCtrl = TextEditingController();
|
|
|
|
|
final _registerPasswordCtrl = TextEditingController();
|
2025-09-15 21:50:09 +03:30
|
|
|
final _registerCaptchaCtrl = TextEditingController();
|
|
|
|
|
String? _registerCaptchaId;
|
|
|
|
|
Uint8List? _registerCaptchaImage;
|
2025-09-15 13:53:54 +03:30
|
|
|
bool _loadingRegister = false;
|
2025-09-15 21:50:09 +03:30
|
|
|
Timer? _registerCaptchaTimer;
|
2025-09-15 13:53:54 +03:30
|
|
|
|
|
|
|
|
// Forgot password
|
|
|
|
|
final _forgotKey = GlobalKey<FormState>();
|
2025-09-15 21:50:09 +03:30
|
|
|
final _forgotIdentifierCtrl = TextEditingController();
|
|
|
|
|
final _forgotCaptchaCtrl = TextEditingController();
|
|
|
|
|
String? _forgotCaptchaId;
|
|
|
|
|
Uint8List? _forgotCaptchaImage;
|
2025-09-15 13:53:54 +03:30
|
|
|
bool _loadingForgot = false;
|
2025-09-15 21:50:09 +03:30
|
|
|
Timer? _forgotCaptchaTimer;
|
2025-09-15 13:53:54 +03:30
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
2025-09-15 21:50:09 +03:30
|
|
|
_identifierCtrl.dispose();
|
2025-09-15 13:53:54 +03:30
|
|
|
_passwordCtrl.dispose();
|
|
|
|
|
_firstNameCtrl.dispose();
|
|
|
|
|
_lastNameCtrl.dispose();
|
|
|
|
|
_emailCtrl.dispose();
|
|
|
|
|
_mobileCtrl.dispose();
|
|
|
|
|
_registerPasswordCtrl.dispose();
|
2025-09-15 21:50:09 +03:30
|
|
|
_registerCaptchaCtrl.dispose();
|
|
|
|
|
_forgotIdentifierCtrl.dispose();
|
|
|
|
|
_loginCaptchaCtrl.dispose();
|
|
|
|
|
_forgotCaptchaCtrl.dispose();
|
|
|
|
|
_loginCaptchaTimer?.cancel();
|
|
|
|
|
_registerCaptchaTimer?.cancel();
|
|
|
|
|
_forgotCaptchaTimer?.cancel();
|
2025-09-15 13:53:54 +03:30
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-15 21:50:09 +03:30
|
|
|
Future<void> _refreshCaptcha(String scope) async {
|
2025-09-16 00:10:20 +03:30
|
|
|
try {
|
|
|
|
|
final api = ApiClient();
|
|
|
|
|
final res = await api.post<Map<String, dynamic>>('/api/v1/auth/captcha');
|
|
|
|
|
final body = res.data;
|
|
|
|
|
if (body is! Map<String, dynamic>) return;
|
|
|
|
|
final data = body['data'];
|
|
|
|
|
if (data is! Map<String, dynamic>) return;
|
2025-09-19 04:35:13 +03:30
|
|
|
final String? id = data['captcha_id']?.toString();
|
|
|
|
|
final String? imgB64 = data['image_base64']?.toString();
|
2025-09-16 00:10:20 +03:30
|
|
|
final int? ttl = (data['ttl_seconds'] as num?)?.toInt();
|
|
|
|
|
if (id == null || imgB64 == null) return;
|
|
|
|
|
Uint8List bytes;
|
|
|
|
|
try {
|
|
|
|
|
bytes = base64Decode(imgB64);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setState(() {
|
|
|
|
|
if (scope == 'login') _loginCaptchaId = id;
|
|
|
|
|
if (scope == 'register') _registerCaptchaId = id;
|
|
|
|
|
if (scope == 'forgot') _forgotCaptchaId = id;
|
|
|
|
|
if (scope == 'login') _loginCaptchaImage = bytes;
|
|
|
|
|
if (scope == 'register') _registerCaptchaImage = bytes;
|
|
|
|
|
if (scope == 'forgot') _forgotCaptchaImage = bytes;
|
|
|
|
|
});
|
|
|
|
|
if (ttl != null && ttl > 0) {
|
|
|
|
|
final delay = Duration(seconds: ttl);
|
|
|
|
|
if (scope == 'login') {
|
|
|
|
|
_loginCaptchaTimer?.cancel();
|
|
|
|
|
_loginCaptchaTimer = Timer(delay, () => _refreshCaptcha('login'));
|
|
|
|
|
} else if (scope == 'register') {
|
|
|
|
|
_registerCaptchaTimer?.cancel();
|
|
|
|
|
_registerCaptchaTimer = Timer(delay, () => _refreshCaptcha('register'));
|
|
|
|
|
} else if (scope == 'forgot') {
|
|
|
|
|
_forgotCaptchaTimer?.cancel();
|
|
|
|
|
_forgotCaptchaTimer = Timer(delay, () => _refreshCaptcha('forgot'));
|
|
|
|
|
}
|
2025-09-15 21:50:09 +03:30
|
|
|
}
|
2025-09-16 00:10:20 +03:30
|
|
|
} catch (_) {
|
|
|
|
|
// سکوت: خطای شبکه/شکل پاسخ نباید باعث کرش شود
|
2025-09-15 21:50:09 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
// پیشبارگذاری کپچا برای هر سه تب
|
|
|
|
|
_refreshCaptcha('login');
|
|
|
|
|
_refreshCaptcha('register');
|
|
|
|
|
_refreshCaptcha('forgot');
|
2025-09-18 10:44:23 +03:30
|
|
|
// ذخیره کد معرف از URL (اگر وجود داشت)
|
|
|
|
|
unawaited(ReferralStore.captureFromCurrentUrl());
|
2025-09-15 21:50:09 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String _extractErrorMessage(Object e, AppLocalizations t) {
|
|
|
|
|
try {
|
|
|
|
|
if (e is DioException) {
|
|
|
|
|
final data = e.response?.data;
|
2025-09-16 00:10:20 +03:30
|
|
|
if (data is Map) {
|
|
|
|
|
final err = data['error'] is Map ? data['error'] as Map : null;
|
|
|
|
|
List<dynamic>? details;
|
|
|
|
|
if (err != null && err['details'] is List) {
|
|
|
|
|
details = err['details'] as List;
|
|
|
|
|
} else if (data['detail'] is List) {
|
|
|
|
|
details = data['detail'] as List;
|
|
|
|
|
}
|
|
|
|
|
if (details != null && details.isNotEmpty) {
|
|
|
|
|
final parts = <String>[];
|
|
|
|
|
for (final item in details) {
|
|
|
|
|
if (item is Map) {
|
|
|
|
|
final fieldRaw = (item['field'] ?? (item['loc'] is List ? (item['loc'] as List).isNotEmpty ? (item['loc'] as List).last?.toString() : null : null))?.toString();
|
|
|
|
|
final String? message = (item['message'] ?? item['msg'])?.toString();
|
|
|
|
|
String label = '';
|
|
|
|
|
switch (fieldRaw) {
|
|
|
|
|
case 'password':
|
|
|
|
|
label = t.password;
|
|
|
|
|
break;
|
|
|
|
|
case 'email':
|
|
|
|
|
label = t.email;
|
|
|
|
|
break;
|
|
|
|
|
case 'mobile':
|
|
|
|
|
label = t.mobile;
|
|
|
|
|
break;
|
|
|
|
|
case 'first_name':
|
|
|
|
|
label = t.firstName;
|
|
|
|
|
break;
|
|
|
|
|
case 'last_name':
|
|
|
|
|
label = t.lastName;
|
|
|
|
|
break;
|
|
|
|
|
case 'captcha':
|
|
|
|
|
case 'captcha_code':
|
|
|
|
|
label = t.captcha;
|
|
|
|
|
break;
|
|
|
|
|
case 'identifier':
|
|
|
|
|
label = t.identifier;
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
label = fieldRaw ?? '';
|
|
|
|
|
}
|
|
|
|
|
if (message != null && message.isNotEmpty) {
|
|
|
|
|
parts.add(label.isNotEmpty ? '$label: $message' : message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (parts.isNotEmpty) {
|
|
|
|
|
return parts.join('\n');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (err != null && err['message'] is String) {
|
|
|
|
|
return err['message'] as String;
|
2025-09-15 21:50:09 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (_) {}
|
2025-09-16 00:10:20 +03:30
|
|
|
return '';
|
2025-09-15 21:50:09 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showSnack(String message) {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
ScaffoldMessenger.of(context)
|
|
|
|
|
..hideCurrentSnackBar()
|
|
|
|
|
..showSnackBar(SnackBar(content: Text(message)));
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-15 13:53:54 +03:30
|
|
|
Future<void> _onSubmit() async {
|
|
|
|
|
final form = _formKey.currentState;
|
2025-09-15 21:50:09 +03:30
|
|
|
final t = AppLocalizations.of(context);
|
2025-09-15 13:53:54 +03:30
|
|
|
if (form == null || !form.validate()) return;
|
2025-09-15 21:50:09 +03:30
|
|
|
if ((_loginCaptchaCtrl.text.trim().isEmpty) || (_loginCaptchaId == null)) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.captchaRequired)));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-09-15 13:53:54 +03:30
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
_loadingLogin = true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
final api = ApiClient();
|
2025-09-15 21:50:09 +03:30
|
|
|
final res = await api.post<Map<String, dynamic>>(
|
|
|
|
|
'/api/v1/auth/login',
|
2025-09-15 13:53:54 +03:30
|
|
|
data: {
|
2025-09-15 21:50:09 +03:30
|
|
|
'identifier': _identifierCtrl.text.trim(),
|
2025-09-15 13:53:54 +03:30
|
|
|
'password': _passwordCtrl.text,
|
2025-09-15 21:50:09 +03:30
|
|
|
'captcha_id': _loginCaptchaId,
|
|
|
|
|
'captcha_code': _loginCaptchaCtrl.text.trim(),
|
|
|
|
|
'device_id': widget.authStore.deviceId,
|
2025-09-18 10:44:23 +03:30
|
|
|
'referrer_code': await ReferralStore.getReferrerCode(),
|
2025-09-15 13:53:54 +03:30
|
|
|
},
|
|
|
|
|
);
|
2025-09-16 00:10:20 +03:30
|
|
|
Map<String, dynamic>? data;
|
|
|
|
|
final body = res.data;
|
|
|
|
|
if (body is Map<String, dynamic>) {
|
|
|
|
|
final inner = body['data'];
|
|
|
|
|
if (inner is Map<String, dynamic>) data = inner;
|
|
|
|
|
}
|
2025-09-19 04:35:13 +03:30
|
|
|
final apiKey = data != null ? data['api_key']?.toString() : null;
|
2025-09-15 21:50:09 +03:30
|
|
|
if (apiKey != null && apiKey.isNotEmpty) {
|
|
|
|
|
await widget.authStore.saveApiKey(apiKey);
|
2025-09-19 16:40:05 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ذخیره کد بازاریابی کاربر برای صفحه Marketing
|
|
|
|
|
final user = data?['user'] as Map<String, dynamic>?;
|
|
|
|
|
final String? myRef = user != null ? user['referral_code']?.toString() : null;
|
|
|
|
|
unawaited(ReferralStore.saveUserReferralCode(myRef));
|
|
|
|
|
|
|
|
|
|
// ذخیره دسترسیهای اپلیکیشن
|
|
|
|
|
final appPermissions = user?['app_permissions'] as Map<String, dynamic>?;
|
|
|
|
|
final isSuperAdmin = appPermissions?['superadmin'] == true;
|
2025-09-25 01:01:27 +03:30
|
|
|
final userId = user?['id'] as int?;
|
2025-09-19 16:40:05 +03:30
|
|
|
|
|
|
|
|
if (appPermissions != null) {
|
2025-09-25 01:01:27 +03:30
|
|
|
await widget.authStore.saveAppPermissions(appPermissions, isSuperAdmin, userId: userId);
|
2025-09-15 21:50:09 +03:30
|
|
|
}
|
2025-09-15 13:53:54 +03:30
|
|
|
|
|
|
|
|
if (!mounted) return;
|
2025-09-15 21:50:09 +03:30
|
|
|
_showSnack(t.homeWelcome);
|
2025-09-18 10:44:23 +03:30
|
|
|
// بعد از login موفق، به صفحه قبلی یا dashboard برود
|
2025-09-25 01:01:27 +03:30
|
|
|
try {
|
|
|
|
|
final currentPath = GoRouterState.of(context).uri.path;
|
|
|
|
|
if (currentPath.startsWith('/user/profile/') || currentPath.startsWith('/acc/') || currentPath.startsWith('/business/')) {
|
|
|
|
|
// اگر در صفحه محافظت شده بود، همان صفحه را refresh کند
|
|
|
|
|
context.go(currentPath);
|
|
|
|
|
} else {
|
|
|
|
|
// وگرنه به dashboard برود
|
|
|
|
|
context.go('/user/profile/dashboard');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// اگر GoRouterState در دسترس نیست، به dashboard برود
|
2025-09-18 10:44:23 +03:30
|
|
|
context.go('/user/profile/dashboard');
|
|
|
|
|
}
|
2025-09-15 13:53:54 +03:30
|
|
|
} catch (e) {
|
2025-09-15 21:50:09 +03:30
|
|
|
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
|
|
|
|
|
_showSnack(msg);
|
2025-09-15 13:53:54 +03:30
|
|
|
setState(() {
|
2025-09-15 22:50:54 +03:30
|
|
|
_loginCaptchaCtrl.clear();
|
2025-09-15 13:53:54 +03:30
|
|
|
});
|
2025-09-15 22:50:54 +03:30
|
|
|
// فقط اسنکبار نمایش داده میشود؛ وضعیت داخلی خطا ذخیره نمیشود
|
2025-09-15 13:53:54 +03:30
|
|
|
} finally {
|
|
|
|
|
if (mounted) {
|
|
|
|
|
setState(() {
|
|
|
|
|
_loadingLogin = false;
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-09-15 21:50:09 +03:30
|
|
|
_refreshCaptcha('login');
|
2025-09-15 13:53:54 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _onRegister() async {
|
|
|
|
|
final t = AppLocalizations.of(context);
|
2025-09-15 22:50:54 +03:30
|
|
|
// اعتبارسنجی دستی و نمایش فقط 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;
|
|
|
|
|
}
|
2025-09-15 13:53:54 +03:30
|
|
|
|
|
|
|
|
setState(() => _loadingRegister = true);
|
|
|
|
|
try {
|
|
|
|
|
final api = ApiClient();
|
2025-09-16 00:10:20 +03:30
|
|
|
final res = await api.post<Map<String, dynamic>>(
|
2025-09-15 21:50:09 +03:30
|
|
|
'/api/v1/auth/register',
|
2025-09-15 13:53:54 +03:30
|
|
|
data: {
|
|
|
|
|
'first_name': _firstNameCtrl.text.trim(),
|
|
|
|
|
'last_name': _lastNameCtrl.text.trim(),
|
2025-09-15 21:50:09 +03:30
|
|
|
'email': _emailCtrl.text.trim().isEmpty ? null : _emailCtrl.text.trim(),
|
|
|
|
|
'mobile': _mobileCtrl.text.trim().isEmpty ? null : _mobileCtrl.text.trim(),
|
2025-09-15 13:53:54 +03:30
|
|
|
'password': _registerPasswordCtrl.text,
|
2025-09-15 21:50:09 +03:30
|
|
|
'captcha_id': _registerCaptchaId,
|
|
|
|
|
'captcha_code': _registerCaptchaCtrl.text.trim(),
|
2025-09-16 00:10:20 +03:30
|
|
|
'device_id': widget.authStore.deviceId,
|
2025-09-18 10:44:23 +03:30
|
|
|
'referrer_code': await ReferralStore.getReferrerCode(),
|
2025-09-15 13:53:54 +03:30
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!mounted) return;
|
2025-09-16 00:10:20 +03:30
|
|
|
Map<String, dynamic>? data;
|
|
|
|
|
final body = res.data;
|
|
|
|
|
if (body is Map<String, dynamic>) {
|
|
|
|
|
final inner = body['data'];
|
|
|
|
|
if (inner is Map<String, dynamic>) data = inner;
|
|
|
|
|
}
|
2025-09-19 04:35:13 +03:30
|
|
|
final apiKey = data != null ? data['api_key']?.toString() : null;
|
2025-09-16 00:10:20 +03:30
|
|
|
if (apiKey != null && apiKey.isNotEmpty) {
|
|
|
|
|
await widget.authStore.saveApiKey(apiKey);
|
|
|
|
|
}
|
2025-09-19 16:40:05 +03:30
|
|
|
|
2025-09-18 10:44:23 +03:30
|
|
|
// ذخیره کد بازاریابی کاربر
|
|
|
|
|
final user = data?['user'] as Map<String, dynamic>?;
|
|
|
|
|
final String? myRef = user != null ? user['referral_code'] as String? : null;
|
|
|
|
|
unawaited(ReferralStore.saveUserReferralCode(myRef));
|
2025-09-19 16:40:05 +03:30
|
|
|
|
|
|
|
|
// ذخیره دسترسیهای اپلیکیشن
|
|
|
|
|
final appPermissions = user?['app_permissions'] as Map<String, dynamic>?;
|
|
|
|
|
final isSuperAdmin = appPermissions?['superadmin'] == true;
|
2025-09-25 01:01:27 +03:30
|
|
|
final userId = user?['id'] as int?;
|
2025-09-19 16:40:05 +03:30
|
|
|
|
|
|
|
|
if (appPermissions != null) {
|
2025-09-25 01:01:27 +03:30
|
|
|
await widget.authStore.saveAppPermissions(appPermissions, isSuperAdmin, userId: userId);
|
2025-09-19 16:40:05 +03:30
|
|
|
}
|
2025-09-18 10:44:23 +03:30
|
|
|
_showSnack(t.registerSuccess);
|
|
|
|
|
// پاکسازی کد معرف پس از ثبتنام موفق
|
|
|
|
|
unawaited(ReferralStore.clearReferrer());
|
2025-09-22 21:21:46 +03:30
|
|
|
if (mounted) {
|
|
|
|
|
context.go('/user/profile/dashboard');
|
|
|
|
|
}
|
2025-09-15 21:50:09 +03:30
|
|
|
} catch (e) {
|
2025-09-15 13:53:54 +03:30
|
|
|
if (!mounted) return;
|
2025-09-15 21:50:09 +03:30
|
|
|
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
|
2025-09-15 22:50:54 +03:30
|
|
|
_showSnack(msg.isEmpty ? t.registerFailed : msg);
|
|
|
|
|
setState(() {
|
|
|
|
|
_registerCaptchaCtrl.clear();
|
|
|
|
|
});
|
2025-09-15 13:53:54 +03:30
|
|
|
} finally {
|
|
|
|
|
if (mounted) setState(() => _loadingRegister = false);
|
2025-09-15 21:50:09 +03:30
|
|
|
_refreshCaptcha('register');
|
2025-09-15 13:53:54 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> _onForgot() async {
|
|
|
|
|
final t = AppLocalizations.of(context);
|
2025-09-15 22:50:54 +03:30
|
|
|
// اعتبارسنجی دستی و نمایش فقط Snackbar
|
|
|
|
|
if (_forgotIdentifierCtrl.text.trim().isEmpty) {
|
|
|
|
|
_showSnack('${t.identifier} ${t.requiredField}');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (_forgotCaptchaId == null || _forgotCaptchaCtrl.text.trim().isEmpty) {
|
|
|
|
|
_showSnack(t.captchaRequired);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-09-15 13:53:54 +03:30
|
|
|
|
|
|
|
|
setState(() => _loadingForgot = true);
|
|
|
|
|
try {
|
|
|
|
|
final api = ApiClient();
|
|
|
|
|
await api.post<Map<String, dynamic>>(
|
2025-09-15 21:50:09 +03:30
|
|
|
'/api/v1/auth/forgot-password',
|
2025-09-15 13:53:54 +03:30
|
|
|
data: {
|
2025-09-15 21:50:09 +03:30
|
|
|
'identifier': _forgotIdentifierCtrl.text.trim(),
|
|
|
|
|
'captcha_id': _forgotCaptchaId,
|
|
|
|
|
'captcha_code': _forgotCaptchaCtrl.text.trim(),
|
2025-09-18 10:44:23 +03:30
|
|
|
'referrer_code': await ReferralStore.getReferrerCode(),
|
2025-09-15 13:53:54 +03:30
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (!mounted) return;
|
2025-09-15 21:50:09 +03:30
|
|
|
_showSnack(t.forgotSent);
|
|
|
|
|
} catch (e) {
|
2025-09-15 13:53:54 +03:30
|
|
|
if (!mounted) return;
|
2025-09-15 21:50:09 +03:30
|
|
|
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
|
|
|
|
|
_showSnack(msg);
|
2025-09-15 22:50:54 +03:30
|
|
|
setState(() {
|
|
|
|
|
_forgotCaptchaCtrl.clear();
|
|
|
|
|
});
|
2025-09-15 13:53:54 +03:30
|
|
|
} finally {
|
|
|
|
|
if (mounted) setState(() => _loadingForgot = false);
|
2025-09-15 21:50:09 +03:30
|
|
|
_refreshCaptcha('forgot');
|
2025-09-15 13:53:54 +03:30
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final t = AppLocalizations.of(context);
|
|
|
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
|
final String logoAsset = isDark
|
|
|
|
|
? 'assets/images/logo-light.png'
|
|
|
|
|
: 'assets/images/logo-blue.png';
|
|
|
|
|
return DefaultTabController(
|
|
|
|
|
length: 3,
|
|
|
|
|
child: Scaffold(
|
2025-09-15 22:50:54 +03:30
|
|
|
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
|
2025-09-15 13:53:54 +03:30
|
|
|
),
|
2025-09-15 22:50:54 +03:30
|
|
|
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),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2025-09-15 21:50:09 +03:30
|
|
|
),
|
2025-09-15 22:50:54 +03:30
|
|
|
if (_loadingLogin)
|
|
|
|
|
Positioned.fill(
|
|
|
|
|
child: Container(
|
|
|
|
|
color: Colors.black26,
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: const CircularProgressIndicator(),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
} 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),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2025-09-15 21:50:09 +03:30
|
|
|
),
|
|
|
|
|
),
|
2025-09-15 22:50:54 +03:30
|
|
|
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),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2025-09-15 21:50:09 +03:30
|
|
|
),
|
|
|
|
|
),
|
2025-09-15 22:50:54 +03:30
|
|
|
if (_loadingForgot)
|
|
|
|
|
Positioned.fill(
|
|
|
|
|
child: Container(
|
|
|
|
|
color: Colors.black26,
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: const CircularProgressIndicator(),
|
|
|
|
|
),
|
2025-09-15 21:50:09 +03:30
|
|
|
),
|
2025-09-15 22:50:54 +03:30
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
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),
|
2025-09-18 10:44:23 +03:30
|
|
|
AuthFooter(
|
|
|
|
|
localeController: widget.localeController,
|
|
|
|
|
calendarController: widget.calendarController,
|
|
|
|
|
themeController: widget.themeController,
|
|
|
|
|
),
|
2025-09-15 22:50:54 +03:30
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
2025-09-15 13:53:54 +03:30
|
|
|
),
|
2025-09-15 22:50:54 +03:30
|
|
|
);
|
|
|
|
|
},
|
2025-09-15 13:53:54 +03:30
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|