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")
|
||||
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(
|
||||
db=db,
|
||||
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_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")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ class RegisterRequest(CaptchaSolve):
|
|||
email: EmailStr | None = None
|
||||
mobile: str | None = Field(default=None, max_length=32)
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
device_id: str | None = Field(default=None, max_length=100)
|
||||
|
||||
|
||||
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()}},
|
||||
)
|
||||
|
||||
# translated details
|
||||
details: list[dict[str, Any]] = []
|
||||
for err in exc.errors():
|
||||
type_ = err.get("type")
|
||||
|
|
@ -23,6 +24,13 @@ def _translate_validation_error(request: Request, exc: RequestValidationError) -
|
|||
ctx = err.get("ctx", {}) or {}
|
||||
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":
|
||||
msg = translator.t("STRING_TOO_SHORT")
|
||||
min_len = ctx.get("min_length")
|
||||
|
|
@ -35,7 +43,12 @@ def _translate_validation_error(request: Request, exc: RequestValidationError) -
|
|||
msg = f"{msg} (حداکثر {max_len})"
|
||||
elif type_ in {"missing", "value_error.missing"}:
|
||||
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")
|
||||
|
||||
details.append({"loc": loc, "msg": msg, "type": type_})
|
||||
|
|
|
|||
|
|
@ -35,13 +35,21 @@ class AuthStore with ChangeNotifier {
|
|||
final prefs = await SharedPreferences.getInstance();
|
||||
_apiKey = key;
|
||||
if (key == null) {
|
||||
await _secure.delete(key: _kApiKey);
|
||||
if (kIsWeb) {
|
||||
await prefs.remove(_kApiKey);
|
||||
} else {
|
||||
try {
|
||||
await _secure.delete(key: _kApiKey);
|
||||
} catch (_) {}
|
||||
await prefs.remove(_kApiKey);
|
||||
}
|
||||
} else {
|
||||
if (kIsWeb) {
|
||||
await prefs.setString(_kApiKey, key);
|
||||
} else {
|
||||
try {
|
||||
await _secure.write(key: _kApiKey, value: key);
|
||||
} catch (_) {}
|
||||
await prefs.setString(_kApiKey, key);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,5 +36,17 @@
|
|||
"resetFailed": "Request failed. Please try again."
|
||||
,
|
||||
"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": "ارسال کد بازیابی ناموفق بود. لطفاً دوباره تلاش کنید."
|
||||
,
|
||||
"fixFormErrors": "لطفاً خطاهای فرم را برطرف کنید."
|
||||
,
|
||||
"dashboard": "داشبورد",
|
||||
"profile": "پروفایل",
|
||||
"settings": "تنظیمات",
|
||||
"logout": "خروج",
|
||||
"logoutDone": "خروج انجام شد",
|
||||
"logoutConfirmTitle": "تایید خروج",
|
||||
"logoutConfirmMessage": "آیا برای خروج مطمئن هستید؟",
|
||||
"menu": "منو"
|
||||
,
|
||||
"ok": "تایید",
|
||||
"cancel": "انصراف"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -289,6 +289,60 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Please fix the form errors.'**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -105,4 +105,34 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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
|
||||
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(
|
||||
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>[
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
|
|
@ -99,7 +106,7 @@ class _MyAppState extends State<MyApp> {
|
|||
),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => ProfileShell(child: child),
|
||||
builder: (context, state, child) => ProfileShell(child: child, authStore: _authStore!, localeController: controller, themeController: themeController),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/user/profile/dashboard',
|
||||
|
|
|
|||
|
|
@ -76,13 +76,24 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||
}
|
||||
|
||||
Future<void> _refreshCaptcha(String scope) async {
|
||||
try {
|
||||
final api = ApiClient();
|
||||
final res = await api.post<Map<String, dynamic>>('/api/v1/auth/captcha');
|
||||
final data = res.data!['data'] as Map<String, dynamic>;
|
||||
final id = data['captcha_id'] as String;
|
||||
final imgB64 = data['image_base64'] as String;
|
||||
final bytes = base64Decode(imgB64);
|
||||
final ttl = (data['ttl_seconds'] as num?)?.toInt();
|
||||
final body = res.data;
|
||||
if (body is! Map<String, dynamic>) return;
|
||||
final data = body['data'];
|
||||
if (data is! Map<String, dynamic>) return;
|
||||
final String? id = data['captcha_id'] as String?;
|
||||
final String? imgB64 = data['image_base64'] as String?;
|
||||
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;
|
||||
|
|
@ -104,6 +115,9 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||
_forgotCaptchaTimer = Timer(delay, () => _refreshCaptcha('forgot'));
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// سکوت: خطای شبکه/شکل پاسخ نباید باعث کرش شود
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -119,18 +133,63 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||
try {
|
||||
if (e is DioException) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map && data['error'] is Map && data['error']['message'] is String) {
|
||||
return data['error']['message'] as String;
|
||||
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 (data is Map && data['detail'] is List) {
|
||||
final details = data['detail'] as List;
|
||||
if (details.isNotEmpty && details.first is Map && (details.first as Map)['msg'] is String) {
|
||||
return (details.first as Map)['msg'] as String;
|
||||
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 (_) {}
|
||||
return t.loginFailed;
|
||||
return '';
|
||||
}
|
||||
|
||||
void _showSnack(String message) {
|
||||
|
|
@ -165,15 +224,20 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||
'device_id': widget.authStore.deviceId,
|
||||
},
|
||||
);
|
||||
final data = res.data?['data'] as Map<String, dynamic>?;
|
||||
final apiKey = data?['api_key'] as String?;
|
||||
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;
|
||||
}
|
||||
final apiKey = data != null ? data['api_key'] as String? : null;
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
await widget.authStore.saveApiKey(apiKey);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_showSnack(t.homeWelcome);
|
||||
context.go('/');
|
||||
context.go('/user/profile/dashboard');
|
||||
} catch (e) {
|
||||
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
|
||||
_showSnack(msg);
|
||||
|
|
@ -219,7 +283,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||
setState(() => _loadingRegister = true);
|
||||
try {
|
||||
final api = ApiClient();
|
||||
await api.post<Map<String, dynamic>>(
|
||||
final res = await api.post<Map<String, dynamic>>(
|
||||
'/api/v1/auth/register',
|
||||
data: {
|
||||
'first_name': _firstNameCtrl.text.trim(),
|
||||
|
|
@ -229,12 +293,26 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
|||
'password': _registerPasswordCtrl.text,
|
||||
'captcha_id': _registerCaptchaId,
|
||||
'captcha_code': _registerCaptchaCtrl.text.trim(),
|
||||
'device_id': widget.authStore.deviceId,
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
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;
|
||||
}
|
||||
final apiKey = data != null ? data['api_key'] as String? : null;
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
await widget.authStore.saveApiKey(apiKey);
|
||||
_showSnack(t.registerSuccess);
|
||||
DefaultTabController.of(context).animateTo(0);
|
||||
context.go('/user/profile/dashboard');
|
||||
} else {
|
||||
_showSnack(t.registerSuccess);
|
||||
context.go('/user/profile/dashboard');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final msg = _extractErrorMessage(e, AppLocalizations.of(context));
|
||||
|
|
|
|||
|
|
@ -1,27 +1,189 @@
|
|||
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 {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final bool useRail = width >= 700;
|
||||
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: [
|
||||
const SizedBox(width: 12),
|
||||
// Logo placeholder (can replace with AssetImage)
|
||||
CircleAvatar(backgroundColor: appBarFg.withOpacity(0.15), child: Icon(Icons.account_balance, color: appBarFg)),
|
||||
const SizedBox(width: 12),
|
||||
Text(t.appTitle, style: TextStyle(color: appBarFg, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(width: 8),
|
||||
Text(destinations[selectedIndex].label, style: TextStyle(color: appBarFg.withOpacity(0.85))),
|
||||
],
|
||||
),
|
||||
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(title: const Text('Profile')),
|
||||
appBar: appBar,
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: 0,
|
||||
destinations: const [
|
||||
NavigationRailDestination(icon: Icon(Icons.dashboard_outlined), label: Text('Dashboard')),
|
||||
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: child),
|
||||
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