some progress in dashboard

This commit is contained in:
Hesabix 2025-09-16 00:10:20 +03:30
parent a67f84dee9
commit cae7b40378
13 changed files with 532 additions and 59 deletions

View file

@ -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")

View file

@ -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):

View file

@ -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_})

View file

@ -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);
}
}

View file

@ -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"
}

View file

@ -36,5 +36,17 @@
"resetFailed": "ارسال کد بازیابی ناموفق بود. لطفاً دوباره تلاش کنید."
,
"fixFormErrors": "لطفاً خطاهای فرم را برطرف کنید."
,
"dashboard": "داشبورد",
"profile": "پروفایل",
"settings": "تنظیمات",
"logout": "خروج",
"logoutDone": "خروج انجام شد",
"logoutConfirmTitle": "تایید خروج",
"logoutConfirmMessage": "آیا برای خروج مطمئن هستید؟",
"menu": "منو"
,
"ok": "تایید",
"cancel": "انصراف"
}

View file

@ -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

View file

@ -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';
}

View file

@ -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 => 'انصراف';
}

View file

@ -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',

View file

@ -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));

View file

@ -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);
}

View 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),
),
),
);
}
}