some progress in dashboard
This commit is contained in:
parent
a67f84dee9
commit
cae7b40378
|
|
@ -26,7 +26,7 @@ def generate_captcha(db: Session = Depends(get_db)) -> dict:
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", summary="Register new user")
|
@router.post("/register", summary="Register new user")
|
||||||
def register(payload: RegisterRequest, db: Session = Depends(get_db)) -> dict:
|
def register(request: Request, payload: RegisterRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
user_id = register_user(
|
user_id = register_user(
|
||||||
db=db,
|
db=db,
|
||||||
first_name=payload.first_name,
|
first_name=payload.first_name,
|
||||||
|
|
@ -37,7 +37,16 @@ def register(payload: RegisterRequest, db: Session = Depends(get_db)) -> dict:
|
||||||
captcha_id=payload.captcha_id,
|
captcha_id=payload.captcha_id,
|
||||||
captcha_code=payload.captcha_code,
|
captcha_code=payload.captcha_code,
|
||||||
)
|
)
|
||||||
return success_response({"user_id": user_id})
|
# Create a session api key similar to login
|
||||||
|
user_agent = request.headers.get("User-Agent")
|
||||||
|
ip = request.client.host if request.client else None
|
||||||
|
from app.core.security import generate_api_key
|
||||||
|
from adapters.db.repositories.api_key_repo import ApiKeyRepository
|
||||||
|
api_key, key_hash = generate_api_key()
|
||||||
|
api_repo = ApiKeyRepository(db)
|
||||||
|
api_repo.create_session_key(user_id=user_id, key_hash=key_hash, device_id=payload.device_id, user_agent=user_agent, ip=ip, expires_at=None)
|
||||||
|
user = {"id": user_id, "first_name": payload.first_name, "last_name": payload.last_name, "email": payload.email, "mobile": payload.mobile}
|
||||||
|
return success_response({"api_key": api_key, "expires_at": None, "user": user})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", summary="Login with email or mobile")
|
@router.post("/login", summary="Login with email or mobile")
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ class RegisterRequest(CaptchaSolve):
|
||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
mobile: str | None = Field(default=None, max_length=32)
|
mobile: str | None = Field(default=None, max_length=32)
|
||||||
password: str = Field(..., min_length=8, max_length=128)
|
password: str = Field(..., min_length=8, max_length=128)
|
||||||
|
device_id: str | None = Field(default=None, max_length=100)
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(CaptchaSolve):
|
class LoginRequest(CaptchaSolve):
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ def _translate_validation_error(request: Request, exc: RequestValidationError) -
|
||||||
content={"success": False, "error": {"code": "VALIDATION_ERROR", "message": "Validation error", "details": exc.errors()}},
|
content={"success": False, "error": {"code": "VALIDATION_ERROR", "message": "Validation error", "details": exc.errors()}},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# translated details
|
||||||
details: list[dict[str, Any]] = []
|
details: list[dict[str, Any]] = []
|
||||||
for err in exc.errors():
|
for err in exc.errors():
|
||||||
type_ = err.get("type")
|
type_ = err.get("type")
|
||||||
|
|
@ -23,6 +24,13 @@ def _translate_validation_error(request: Request, exc: RequestValidationError) -
|
||||||
ctx = err.get("ctx", {}) or {}
|
ctx = err.get("ctx", {}) or {}
|
||||||
msg = err.get("msg", "")
|
msg = err.get("msg", "")
|
||||||
|
|
||||||
|
# extract field name (skip body/query/path)
|
||||||
|
field_name = None
|
||||||
|
if isinstance(loc, (list, tuple)):
|
||||||
|
for part in loc:
|
||||||
|
if str(part) not in ("body", "query", "path"):
|
||||||
|
field_name = str(part)
|
||||||
|
|
||||||
if type_ == "string_too_short":
|
if type_ == "string_too_short":
|
||||||
msg = translator.t("STRING_TOO_SHORT")
|
msg = translator.t("STRING_TOO_SHORT")
|
||||||
min_len = ctx.get("min_length")
|
min_len = ctx.get("min_length")
|
||||||
|
|
@ -35,7 +43,12 @@ def _translate_validation_error(request: Request, exc: RequestValidationError) -
|
||||||
msg = f"{msg} (حداکثر {max_len})"
|
msg = f"{msg} (حداکثر {max_len})"
|
||||||
elif type_ in {"missing", "value_error.missing"}:
|
elif type_ in {"missing", "value_error.missing"}:
|
||||||
msg = translator.t("FIELD_REQUIRED")
|
msg = translator.t("FIELD_REQUIRED")
|
||||||
elif type_ in {"value_error.email", "email", "value_error.email"}:
|
# broader email detection
|
||||||
|
elif (
|
||||||
|
type_ in {"value_error.email", "email"}
|
||||||
|
or (field_name == "email" and isinstance(type_, str) and type_.startswith("value_error"))
|
||||||
|
or (isinstance(msg, str) and "email address" in msg.lower())
|
||||||
|
):
|
||||||
msg = translator.t("INVALID_EMAIL")
|
msg = translator.t("INVALID_EMAIL")
|
||||||
|
|
||||||
details.append({"loc": loc, "msg": msg, "type": type_})
|
details.append({"loc": loc, "msg": msg, "type": type_})
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,21 @@ class AuthStore with ChangeNotifier {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_apiKey = key;
|
_apiKey = key;
|
||||||
if (key == null) {
|
if (key == null) {
|
||||||
await _secure.delete(key: _kApiKey);
|
if (kIsWeb) {
|
||||||
await prefs.remove(_kApiKey);
|
await prefs.remove(_kApiKey);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await _secure.delete(key: _kApiKey);
|
||||||
|
} catch (_) {}
|
||||||
|
await prefs.remove(_kApiKey);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (kIsWeb) {
|
if (kIsWeb) {
|
||||||
await prefs.setString(_kApiKey, key);
|
await prefs.setString(_kApiKey, key);
|
||||||
} else {
|
} else {
|
||||||
await _secure.write(key: _kApiKey, value: key);
|
try {
|
||||||
|
await _secure.write(key: _kApiKey, value: key);
|
||||||
|
} catch (_) {}
|
||||||
await prefs.setString(_kApiKey, key);
|
await prefs.setString(_kApiKey, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,5 +36,17 @@
|
||||||
"resetFailed": "Request failed. Please try again."
|
"resetFailed": "Request failed. Please try again."
|
||||||
,
|
,
|
||||||
"fixFormErrors": "Please fix the form errors."
|
"fixFormErrors": "Please fix the form errors."
|
||||||
|
,
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"profile": "Profile",
|
||||||
|
"settings": "Settings",
|
||||||
|
"logout": "Logout",
|
||||||
|
"logoutDone": "Signed out.",
|
||||||
|
"logoutConfirmTitle": "Sign out",
|
||||||
|
"logoutConfirmMessage": "Are you sure you want to sign out?",
|
||||||
|
"menu": "Menu"
|
||||||
|
,
|
||||||
|
"ok": "OK",
|
||||||
|
"cancel": "Cancel"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,5 +36,17 @@
|
||||||
"resetFailed": "ارسال کد بازیابی ناموفق بود. لطفاً دوباره تلاش کنید."
|
"resetFailed": "ارسال کد بازیابی ناموفق بود. لطفاً دوباره تلاش کنید."
|
||||||
,
|
,
|
||||||
"fixFormErrors": "لطفاً خطاهای فرم را برطرف کنید."
|
"fixFormErrors": "لطفاً خطاهای فرم را برطرف کنید."
|
||||||
|
,
|
||||||
|
"dashboard": "داشبورد",
|
||||||
|
"profile": "پروفایل",
|
||||||
|
"settings": "تنظیمات",
|
||||||
|
"logout": "خروج",
|
||||||
|
"logoutDone": "خروج انجام شد",
|
||||||
|
"logoutConfirmTitle": "تایید خروج",
|
||||||
|
"logoutConfirmMessage": "آیا برای خروج مطمئن هستید؟",
|
||||||
|
"menu": "منو"
|
||||||
|
,
|
||||||
|
"ok": "تایید",
|
||||||
|
"cancel": "انصراف"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,60 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Please fix the form errors.'**
|
/// **'Please fix the form errors.'**
|
||||||
String get fixFormErrors;
|
String get fixFormErrors;
|
||||||
|
|
||||||
|
/// No description provided for @dashboard.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Dashboard'**
|
||||||
|
String get dashboard;
|
||||||
|
|
||||||
|
/// No description provided for @profile.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Profile'**
|
||||||
|
String get profile;
|
||||||
|
|
||||||
|
/// No description provided for @settings.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Settings'**
|
||||||
|
String get settings;
|
||||||
|
|
||||||
|
/// No description provided for @logout.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Logout'**
|
||||||
|
String get logout;
|
||||||
|
|
||||||
|
/// No description provided for @logoutDone.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Signed out.'**
|
||||||
|
String get logoutDone;
|
||||||
|
|
||||||
|
/// No description provided for @logoutConfirmTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sign out'**
|
||||||
|
String get logoutConfirmTitle;
|
||||||
|
|
||||||
|
/// No description provided for @logoutConfirmMessage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Are you sure you want to sign out?'**
|
||||||
|
String get logoutConfirmMessage;
|
||||||
|
|
||||||
|
/// No description provided for @menu.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Menu'**
|
||||||
|
String get menu;
|
||||||
|
|
||||||
|
/// No description provided for @ok.
|
||||||
|
String get ok;
|
||||||
|
|
||||||
|
/// No description provided for @cancel.
|
||||||
|
String get cancel;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -105,4 +105,34 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fixFormErrors => 'Please fix the form errors.';
|
String get fixFormErrors => 'Please fix the form errors.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dashboard => 'Dashboard';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get profile => 'Profile';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings => 'Settings';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logout => 'Logout';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logoutDone => 'Signed out.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logoutConfirmTitle => 'Sign out';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logoutConfirmMessage => 'Are you sure you want to sign out?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get menu => 'Menu';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ok => 'OK';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancel => 'Cancel';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -105,4 +105,34 @@ class AppLocalizationsFa extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fixFormErrors => 'لطفاً خطاهای فرم را برطرف کنید.';
|
String get fixFormErrors => 'لطفاً خطاهای فرم را برطرف کنید.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dashboard => 'داشبورد';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get profile => 'پروفایل';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settings => 'تنظیمات';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logout => 'خروج';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logoutDone => 'خروج انجام شد';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logoutConfirmTitle => 'تایید خروج';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get logoutConfirmMessage => 'آیا برای خروج مطمئن هستید؟';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get menu => 'منو';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ok => 'تایید';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cancel => 'انصراف';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,13 @@ class _MyAppState extends State<MyApp> {
|
||||||
|
|
||||||
final router = GoRouter(
|
final router = GoRouter(
|
||||||
initialLocation: '/login',
|
initialLocation: '/login',
|
||||||
|
redirect: (context, state) {
|
||||||
|
final hasKey = _authStore!.apiKey != null && _authStore!.apiKey!.isNotEmpty;
|
||||||
|
final loggingIn = state.matchedLocation == '/login';
|
||||||
|
if (!hasKey && !loggingIn) return '/login';
|
||||||
|
if (hasKey && loggingIn) return '/user/profile/dashboard';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
routes: <RouteBase>[
|
routes: <RouteBase>[
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: '/login',
|
||||||
|
|
@ -99,7 +106,7 @@ class _MyAppState extends State<MyApp> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (context, state, child) => ProfileShell(child: child),
|
builder: (context, state, child) => ProfileShell(child: child, authStore: _authStore!, localeController: controller, themeController: themeController),
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/user/profile/dashboard',
|
path: '/user/profile/dashboard',
|
||||||
|
|
|
||||||
|
|
@ -76,33 +76,47 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshCaptcha(String scope) async {
|
Future<void> _refreshCaptcha(String scope) async {
|
||||||
final api = ApiClient();
|
try {
|
||||||
final res = await api.post<Map<String, dynamic>>('/api/v1/auth/captcha');
|
final api = ApiClient();
|
||||||
final data = res.data!['data'] as Map<String, dynamic>;
|
final res = await api.post<Map<String, dynamic>>('/api/v1/auth/captcha');
|
||||||
final id = data['captcha_id'] as String;
|
final body = res.data;
|
||||||
final imgB64 = data['image_base64'] as String;
|
if (body is! Map<String, dynamic>) return;
|
||||||
final bytes = base64Decode(imgB64);
|
final data = body['data'];
|
||||||
final ttl = (data['ttl_seconds'] as num?)?.toInt();
|
if (data is! Map<String, dynamic>) return;
|
||||||
setState(() {
|
final String? id = data['captcha_id'] as String?;
|
||||||
if (scope == 'login') _loginCaptchaId = id;
|
final String? imgB64 = data['image_base64'] as String?;
|
||||||
if (scope == 'register') _registerCaptchaId = id;
|
final int? ttl = (data['ttl_seconds'] as num?)?.toInt();
|
||||||
if (scope == 'forgot') _forgotCaptchaId = id;
|
if (id == null || imgB64 == null) return;
|
||||||
if (scope == 'login') _loginCaptchaImage = bytes;
|
Uint8List bytes;
|
||||||
if (scope == 'register') _registerCaptchaImage = bytes;
|
try {
|
||||||
if (scope == 'forgot') _forgotCaptchaImage = bytes;
|
bytes = base64Decode(imgB64);
|
||||||
});
|
} catch (_) {
|
||||||
if (ttl != null && ttl > 0) {
|
return;
|
||||||
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'));
|
|
||||||
}
|
}
|
||||||
|
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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// سکوت: خطای شبکه/شکل پاسخ نباید باعث کرش شود
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,18 +133,63 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||||
try {
|
try {
|
||||||
if (e is DioException) {
|
if (e is DioException) {
|
||||||
final data = e.response?.data;
|
final data = e.response?.data;
|
||||||
if (data is Map && data['error'] is Map && data['error']['message'] is String) {
|
if (data is Map) {
|
||||||
return data['error']['message'] as String;
|
final err = data['error'] is Map ? data['error'] as Map : null;
|
||||||
}
|
List<dynamic>? details;
|
||||||
if (data is Map && data['detail'] is List) {
|
if (err != null && err['details'] is List) {
|
||||||
final details = data['detail'] as List;
|
details = err['details'] as List;
|
||||||
if (details.isNotEmpty && details.first is Map && (details.first as Map)['msg'] is String) {
|
} else if (data['detail'] is List) {
|
||||||
return (details.first as Map)['msg'] as String;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return t.loginFailed;
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSnack(String message) {
|
void _showSnack(String message) {
|
||||||
|
|
@ -165,15 +224,20 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||||
'device_id': widget.authStore.deviceId,
|
'device_id': widget.authStore.deviceId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final data = res.data?['data'] as Map<String, dynamic>?;
|
Map<String, dynamic>? data;
|
||||||
final apiKey = data?['api_key'] as String?;
|
final body = res.data;
|
||||||
|
if (body is Map<String, dynamic>) {
|
||||||
|
final inner = body['data'];
|
||||||
|
if (inner is Map<String, dynamic>) data = inner;
|
||||||
|
}
|
||||||
|
final apiKey = data != null ? data['api_key'] as String? : null;
|
||||||
if (apiKey != null && apiKey.isNotEmpty) {
|
if (apiKey != null && apiKey.isNotEmpty) {
|
||||||
await widget.authStore.saveApiKey(apiKey);
|
await widget.authStore.saveApiKey(apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_showSnack(t.homeWelcome);
|
_showSnack(t.homeWelcome);
|
||||||
context.go('/');
|
context.go('/user/profile/dashboard');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
|
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
|
||||||
_showSnack(msg);
|
_showSnack(msg);
|
||||||
|
|
@ -219,7 +283,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||||
setState(() => _loadingRegister = true);
|
setState(() => _loadingRegister = true);
|
||||||
try {
|
try {
|
||||||
final api = ApiClient();
|
final api = ApiClient();
|
||||||
await api.post<Map<String, dynamic>>(
|
final res = await api.post<Map<String, dynamic>>(
|
||||||
'/api/v1/auth/register',
|
'/api/v1/auth/register',
|
||||||
data: {
|
data: {
|
||||||
'first_name': _firstNameCtrl.text.trim(),
|
'first_name': _firstNameCtrl.text.trim(),
|
||||||
|
|
@ -229,12 +293,26 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||||
'password': _registerPasswordCtrl.text,
|
'password': _registerPasswordCtrl.text,
|
||||||
'captcha_id': _registerCaptchaId,
|
'captcha_id': _registerCaptchaId,
|
||||||
'captcha_code': _registerCaptchaCtrl.text.trim(),
|
'captcha_code': _registerCaptchaCtrl.text.trim(),
|
||||||
|
'device_id': widget.authStore.deviceId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_showSnack(t.registerSuccess);
|
Map<String, dynamic>? data;
|
||||||
DefaultTabController.of(context).animateTo(0);
|
final body = res.data;
|
||||||
|
if (body is Map<String, dynamic>) {
|
||||||
|
final inner = body['data'];
|
||||||
|
if (inner is Map<String, dynamic>) data = inner;
|
||||||
|
}
|
||||||
|
final apiKey = data != null ? data['api_key'] as String? : null;
|
||||||
|
if (apiKey != null && apiKey.isNotEmpty) {
|
||||||
|
await widget.authStore.saveApiKey(apiKey);
|
||||||
|
_showSnack(t.registerSuccess);
|
||||||
|
context.go('/user/profile/dashboard');
|
||||||
|
} else {
|
||||||
|
_showSnack(t.registerSuccess);
|
||||||
|
context.go('/user/profile/dashboard');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
|
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,189 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import '../../core/auth_store.dart';
|
||||||
|
import '../../core/locale_controller.dart';
|
||||||
|
import '../../theme/theme_controller.dart';
|
||||||
|
import '../../widgets/language_switcher.dart';
|
||||||
|
import '../../widgets/theme_mode_switcher.dart';
|
||||||
|
import '../../widgets/logout_button.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
|
||||||
class ProfileShell extends StatelessWidget {
|
class ProfileShell extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
const ProfileShell({super.key, required this.child});
|
final AuthStore authStore;
|
||||||
|
final LocaleController? localeController;
|
||||||
|
final ThemeController? themeController;
|
||||||
|
const ProfileShell({super.key, required this.child, required this.authStore, this.localeController, this.themeController});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
final width = MediaQuery.of(context).size.width;
|
||||||
appBar: AppBar(title: const Text('Profile')),
|
final bool useRail = width >= 700;
|
||||||
body: Row(
|
final bool railExtended = width >= 1100;
|
||||||
|
final ColorScheme scheme = Theme.of(context).colorScheme;
|
||||||
|
final String location = GoRouterState.of(context).uri.toString();
|
||||||
|
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
final destinations = <_Dest>[
|
||||||
|
_Dest(t.dashboard, Icons.dashboard_outlined, Icons.dashboard, '/user/profile/dashboard'),
|
||||||
|
];
|
||||||
|
|
||||||
|
int selectedIndex = 0;
|
||||||
|
for (int i = 0; i < destinations.length; i++) {
|
||||||
|
if (location.startsWith(destinations[i].path)) {
|
||||||
|
selectedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onSelect(int index) async {
|
||||||
|
final path = destinations[index].path;
|
||||||
|
if (GoRouterState.of(context).uri.toString() != path) {
|
||||||
|
context.go(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onLogout() async {
|
||||||
|
await authStore.saveApiKey(null);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(const SnackBar(content: Text('خروج انجام شد')));
|
||||||
|
context.go('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brand top bar with contrast color
|
||||||
|
final Color appBarBg = Theme.of(context).brightness == Brightness.dark
|
||||||
|
? scheme.surfaceVariant
|
||||||
|
: scheme.primary;
|
||||||
|
final Color appBarFg = Theme.of(context).brightness == Brightness.dark
|
||||||
|
? scheme.onSurfaceVariant
|
||||||
|
: scheme.onPrimary;
|
||||||
|
|
||||||
|
final appBar = AppBar(
|
||||||
|
backgroundColor: appBarBg,
|
||||||
|
foregroundColor: appBarFg,
|
||||||
|
titleSpacing: 0,
|
||||||
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
NavigationRail(
|
const SizedBox(width: 12),
|
||||||
selectedIndex: 0,
|
// Logo placeholder (can replace with AssetImage)
|
||||||
destinations: const [
|
CircleAvatar(backgroundColor: appBarFg.withOpacity(0.15), child: Icon(Icons.account_balance, color: appBarFg)),
|
||||||
NavigationRailDestination(icon: Icon(Icons.dashboard_outlined), label: Text('Dashboard')),
|
const SizedBox(width: 12),
|
||||||
],
|
Text(t.appTitle, style: TextStyle(color: appBarFg, fontWeight: FontWeight.w700)),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const VerticalDivider(width: 1),
|
Text(destinations[selectedIndex].label, style: TextStyle(color: appBarFg.withOpacity(0.85))),
|
||||||
Expanded(child: child),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
leading: useRail
|
||||||
|
? null
|
||||||
|
: Builder(
|
||||||
|
builder: (ctx) => IconButton(
|
||||||
|
icon: Icon(Icons.menu, color: appBarFg),
|
||||||
|
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||||
|
tooltip: t.menu,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (themeController != null) ...[
|
||||||
|
ThemeModeSwitcher(controller: themeController!),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
if (localeController != null) ...[
|
||||||
|
LanguageSwitcher(controller: localeController!),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
LogoutButton(authStore: authStore),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final content = Container(
|
||||||
|
color: scheme.surface,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (useRail) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: appBar,
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
NavigationRail(
|
||||||
|
selectedIndex: selectedIndex,
|
||||||
|
extended: railExtended,
|
||||||
|
leading: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: CircleAvatar(
|
||||||
|
backgroundColor: scheme.primary,
|
||||||
|
child: const Icon(Icons.person, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
destinations: [
|
||||||
|
for (final d in destinations)
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(d.icon),
|
||||||
|
selectedIcon: Icon(d.selectedIcon),
|
||||||
|
label: Text(d.label),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onDestinationSelected: onSelect,
|
||||||
|
),
|
||||||
|
const VerticalDivider(width: 1),
|
||||||
|
Expanded(child: content),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: appBar,
|
||||||
|
drawer: Drawer(
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
UserAccountsDrawerHeader(
|
||||||
|
currentAccountPicture: CircleAvatar(
|
||||||
|
backgroundColor: scheme.primary,
|
||||||
|
child: const Icon(Icons.person, color: Colors.white),
|
||||||
|
),
|
||||||
|
accountName: const Text(''),
|
||||||
|
accountEmail: const Text(''),
|
||||||
|
),
|
||||||
|
for (int i = 0; i < destinations.length; i++)
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(destinations[i].selectedIcon),
|
||||||
|
title: Text(destinations[i].label),
|
||||||
|
selected: i == selectedIndex,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
onSelect(i);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.logout),
|
||||||
|
title: const Text('خروج'),
|
||||||
|
onTap: onLogout,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: content,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _Dest {
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final IconData selectedIcon;
|
||||||
|
final String path;
|
||||||
|
const _Dest(this.label, this.icon, this.selectedIcon, this.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
57
hesabixUI/hesabix_ui/lib/widgets/logout_button.dart
Normal file
57
hesabixUI/hesabix_ui/lib/widgets/logout_button.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../core/auth_store.dart';
|
||||||
|
import 'package:hesabix_ui/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class LogoutButton extends StatelessWidget {
|
||||||
|
final AuthStore authStore;
|
||||||
|
const LogoutButton({super.key, required this.authStore});
|
||||||
|
|
||||||
|
Future<void> _confirmAndLogout(BuildContext context) async {
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
final bool? ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(t.logoutConfirmTitle),
|
||||||
|
content: Text(t.logoutConfirmMessage),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: Text(t.cancel)),
|
||||||
|
FilledButton(onPressed: () => Navigator.of(ctx).pop(true), child: Text(t.logout)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ok != true) return;
|
||||||
|
|
||||||
|
await authStore.saveApiKey(null);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
..hideCurrentSnackBar()
|
||||||
|
..showSnackBar(SnackBar(content: Text(t.logoutDone)));
|
||||||
|
context.go('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final t = AppLocalizations.of(context);
|
||||||
|
return Tooltip(
|
||||||
|
message: t.logout,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => _confirmAndLogout(context),
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
backgroundColor: cs.surfaceContainerHighest,
|
||||||
|
foregroundColor: cs.onSurface,
|
||||||
|
child: const Icon(Icons.logout, size: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in a new issue