Refactor user model and profile handling for new Keycloak backend support
This commit is contained in:
parent
5124e469f7
commit
d6825e9e5e
|
|
@ -6,7 +6,11 @@
|
||||||
// فیلد `id` نگه داشته شده است (0 در صورت نبود معادل عددی).
|
// فیلد `id` نگه داشته شده است (0 در صورت نبود معادل عددی).
|
||||||
// - فیلدهای جدید بکاند: firstName, lastName, phone, avatar
|
// - فیلدهای جدید بکاند: firstName, lastName, phone, avatar
|
||||||
// معادل قدیمی: fullName, phoneNumber, photo
|
// معادل قدیمی: fullName, phoneNumber, photo
|
||||||
// - متد `fromJson` هر دو شکل قدیمی و جدید را قبول میکند.
|
// - Keycloak custom attribute ها (مثل phone/avatar) معمولا داخل
|
||||||
|
// `attributes: { phone: ["..."], avatar: ["..."] }` به شکل آرایه
|
||||||
|
// برمیگردند، نه رشتهی ساده.
|
||||||
|
// - متد `fromJson` هر دو شکل قدیمی و جدید (و شکل آرایهای Keycloak) را
|
||||||
|
// بدون کرش قبول میکند.
|
||||||
|
|
||||||
class User {
|
class User {
|
||||||
/// Identifier عددی قدیمی (سازگاری). در بکاند جدید معمولا 0 خواهد بود.
|
/// Identifier عددی قدیمی (سازگاری). در بکاند جدید معمولا 0 خواهد بود.
|
||||||
|
|
@ -35,9 +39,43 @@ class User {
|
||||||
this.lastName,
|
this.lastName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// مقدار خام یک فیلد را به `String?` امن تبدیل میکند.
|
||||||
|
/// - اگر رشته باشد همان را (یا null در صورت خالی بودن) برمیگرداند.
|
||||||
|
/// - اگر آرایه باشد (قرارداد Keycloak برای attributes)، اولین عضو را
|
||||||
|
/// میگیرد، بهجای اینکه با یک `as String` نادرست کرش کند.
|
||||||
|
static String? _asString(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is String) return value.isEmpty ? null : value;
|
||||||
|
if (value is List) {
|
||||||
|
return value.isEmpty ? null : _asString(value.first);
|
||||||
|
}
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// یک کلید را ابتدا در سطح بالای json و در صورت نبود، داخل
|
||||||
|
/// `attributes` (قرارداد Keycloak: `attributes: { key: [...] }`) جستوجو
|
||||||
|
/// میکند.
|
||||||
|
static String? _lookup(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
List<String> keys,
|
||||||
|
) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = _asString(json[key]);
|
||||||
|
if (value != null) return value;
|
||||||
|
}
|
||||||
|
final attributes = json['attributes'];
|
||||||
|
if (attributes is Map) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = _asString(attributes[key]);
|
||||||
|
if (value != null) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// سازندهای که هم با شکل قدیمی (`{id, fullName, phoneNumber, photo, ...}`)
|
/// سازندهای که هم با شکل قدیمی (`{id, fullName, phoneNumber, photo, ...}`)
|
||||||
/// و هم با شکل جدید (`{id: <uuid>, firstName, lastName, phone, avatar, ...}`)
|
/// و هم با شکل جدید (`{id: <uuid>, firstName, lastName, phone, avatar, ...}`)
|
||||||
/// کار میکند.
|
/// و هم با شکل Keycloak (attributes آرایهای) کار میکند.
|
||||||
factory User.fromJson(Map<String, dynamic> json) {
|
factory User.fromJson(Map<String, dynamic> json) {
|
||||||
// id ممکن است int یا String (UUID) باشد.
|
// id ممکن است int یا String (UUID) باشد.
|
||||||
final dynamic rawId = json['id'];
|
final dynamic rawId = json['id'];
|
||||||
|
|
@ -51,39 +89,39 @@ class User {
|
||||||
}
|
}
|
||||||
|
|
||||||
// اولویت با فیلد جدید keycloakId اگر صریح ارسال شده باشد.
|
// اولویت با فیلد جدید keycloakId اگر صریح ارسال شده باشد.
|
||||||
if (json['keycloakId'] is String) {
|
final explicitKeycloakId = _asString(json['keycloakId']);
|
||||||
keycloakId = json['keycloakId'] as String;
|
if (explicitKeycloakId != null) {
|
||||||
|
keycloakId = explicitKeycloakId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ساخت fullName: اگر مستقیم آمده استفاده میکنیم،
|
// ساخت fullName: اگر مستقیم آمده استفاده میکنیم،
|
||||||
// وگرنه از firstName + lastName میسازیم.
|
// وگرنه از firstName + lastName میسازیم.
|
||||||
final firstName = json['firstName'] as String?;
|
final firstName = _lookup(json, const ['firstName']);
|
||||||
final lastName = json['lastName'] as String?;
|
final lastName = _lookup(json, const ['lastName']);
|
||||||
String fullName;
|
final directFullName = _lookup(json, const ['fullName', 'name']);
|
||||||
if (json['fullName'] is String &&
|
final fullName = directFullName ??
|
||||||
(json['fullName'] as String).isNotEmpty) {
|
[
|
||||||
fullName = json['fullName'] as String;
|
|
||||||
} else {
|
|
||||||
fullName = [
|
|
||||||
firstName ?? '',
|
firstName ?? '',
|
||||||
lastName ?? '',
|
lastName ?? '',
|
||||||
].where((s) => s.isNotEmpty).join(' ');
|
].where((s) => s.isNotEmpty).join(' ');
|
||||||
}
|
|
||||||
|
|
||||||
// phoneNumber: قدیم `phoneNumber`، جدید `phone`.
|
// phoneNumber: قدیم `phoneNumber`، جدید `phone` (احتمالا داخل attributes).
|
||||||
final phone = (json['phoneNumber'] ?? json['phone'] ?? '') as String;
|
final phone = _lookup(json, const ['phoneNumber', 'phone']) ?? '';
|
||||||
|
|
||||||
// photo: قدیم `photo`، جدید `avatar`.
|
// photo: قدیم `photo`، جدید `avatar` (احتمالا داخل attributes).
|
||||||
final photo = (json['photo'] ?? json['avatar']) as String?;
|
final photo = _lookup(json, const ['photo', 'avatar', 'picture']);
|
||||||
|
|
||||||
|
// username: معمولا سطح بالا است، اما بهعنوان fallback هم بررسی میشود.
|
||||||
|
final username = _lookup(json, const ['username', 'preferred_username']);
|
||||||
|
|
||||||
return User(
|
return User(
|
||||||
id: intId,
|
id: intId,
|
||||||
keycloakId: keycloakId,
|
keycloakId: keycloakId,
|
||||||
username: json['username'] as String?,
|
username: username,
|
||||||
phoneNumber: phone,
|
phoneNumber: phone,
|
||||||
photo: photo,
|
photo: photo,
|
||||||
fullName: fullName,
|
fullName: fullName,
|
||||||
email: json['email'] as String?,
|
email: _lookup(json, const ['email']),
|
||||||
firstName: firstName,
|
firstName: firstName,
|
||||||
lastName: lastName,
|
lastName: lastName,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import 'package:didvan/models/user.dart';
|
||||||
import 'package:didvan/models/view/alert_data.dart';
|
import 'package:didvan/models/view/alert_data.dart';
|
||||||
import 'package:didvan/providers/core.dart';
|
import 'package:didvan/providers/core.dart';
|
||||||
import 'package:didvan/services/auth/auth_api_service.dart';
|
import 'package:didvan/services/auth/auth_api_service.dart';
|
||||||
|
import 'package:didvan/services/auth/jwt_utils.dart';
|
||||||
import 'package:didvan/services/auth/keycloak_auth_service.dart';
|
import 'package:didvan/services/auth/keycloak_auth_service.dart';
|
||||||
import 'package:didvan/services/auth/token_storage.dart';
|
import 'package:didvan/services/auth/token_storage.dart';
|
||||||
import 'package:didvan/services/network/request.dart';
|
import 'package:didvan/services/network/request.dart';
|
||||||
|
|
@ -216,19 +217,45 @@ class UserProvider extends CoreProvier {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final userinfo = await KeycloakAuthService.instance.fetchUserInfo();
|
// 1) Local profile from the JWT (no network — always available).
|
||||||
if (userinfo == null) {
|
// Keycloak includes `name/given_name/family_name/preferred_username/email`
|
||||||
print("UserProvider: userinfo failed (likely 401).");
|
// in the access token, so we can populate the UI even if BOTH the OIDC
|
||||||
isAuthenticated = false;
|
// userinfo endpoint AND api2/auth/users/:id are unreachable.
|
||||||
return false;
|
Map<String, dynamic> userinfo = _profileFromToken();
|
||||||
}
|
|
||||||
|
// 2) Try the OIDC userinfo endpoint but do NOT block on it — some networks
|
||||||
|
// (grey-cloud vs ArvanCloud reachability) cause 30s hangs on this call.
|
||||||
|
// 3-second budget: if it comes back fast, we merge its data; if not, we
|
||||||
|
// just keep the JWT payload. Runs in parallel with the api2 call below.
|
||||||
|
final userinfoFuture = KeycloakAuthService.instance
|
||||||
|
.fetchUserInfo()
|
||||||
|
.timeout(const Duration(seconds: 3), onTimeout: () => null)
|
||||||
|
.catchError((_) => null);
|
||||||
|
|
||||||
final result = await AuthApiService.instance.getUserInfo(keycloakId);
|
final result = await AuthApiService.instance.getUserInfo(keycloakId);
|
||||||
|
|
||||||
|
// Fold in the userinfo response if it beat the 3s budget.
|
||||||
|
final remoteUserinfo = await userinfoFuture;
|
||||||
|
if (remoteUserinfo != null) {
|
||||||
|
userinfo = {...userinfo, ...remoteUserinfo};
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> mergedData;
|
Map<String, dynamic> mergedData;
|
||||||
if (result.isSuccess && result.data != null) {
|
if (result.isSuccess && result.data != null) {
|
||||||
mergedData = {..._userinfoToAppShape(userinfo), ...result.data!};
|
// The auth-service response can carry null/empty values for fields the
|
||||||
print("UserProvider: User info merged from userinfo + auth-service.");
|
// user hasn't filled in (fullName, firstName, lastName, phone, avatar).
|
||||||
|
// Merging naively would BLANK OUT the good values from the OIDC userinfo
|
||||||
|
// (which always has name/given_name/family_name for logged-in users).
|
||||||
|
// So keep only non-empty overrides.
|
||||||
|
final overrides = <String, dynamic>{};
|
||||||
|
result.data!.forEach((k, v) {
|
||||||
|
if (v == null) return;
|
||||||
|
if (v is String && v.trim().isEmpty) return;
|
||||||
|
if (v is List && v.isEmpty) return;
|
||||||
|
overrides[k] = v;
|
||||||
|
});
|
||||||
|
mergedData = {..._userinfoToAppShape(userinfo), ...overrides};
|
||||||
|
print("UserProvider: User info merged from userinfo + auth-service (${overrides.length} overrides).");
|
||||||
} else {
|
} else {
|
||||||
print("UserProvider: auth-service ${result.statusCode ?? 'n/a'} -> "
|
print("UserProvider: auth-service ${result.statusCode ?? 'n/a'} -> "
|
||||||
"fallback to userinfo only.");
|
"fallback to userinfo only.");
|
||||||
|
|
@ -274,6 +301,25 @@ class UserProvider extends CoreProvier {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract OIDC-style profile claims straight from the current access token.
|
||||||
|
/// Used as a network-free fallback when the userinfo endpoint is unreachable.
|
||||||
|
Map<String, dynamic> _profileFromToken() {
|
||||||
|
final token = TokenStorage.accessToken ?? '';
|
||||||
|
if (token.isEmpty) return {};
|
||||||
|
final claims = JwtUtils.decodePayload(token) ?? {};
|
||||||
|
return {
|
||||||
|
if (claims['sub'] != null) 'sub': claims['sub'],
|
||||||
|
if (claims['preferred_username'] != null)
|
||||||
|
'preferred_username': claims['preferred_username'],
|
||||||
|
if (claims['given_name'] != null) 'given_name': claims['given_name'],
|
||||||
|
if (claims['family_name'] != null) 'family_name': claims['family_name'],
|
||||||
|
if (claims['name'] != null) 'name': claims['name'],
|
||||||
|
if (claims['email'] != null) 'email': claims['email'],
|
||||||
|
if (claims['phone'] != null) 'phone': claims['phone'],
|
||||||
|
if (claims['picture'] != null) 'picture': claims['picture'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, dynamic> _userinfoToAppShape(Map<String, dynamic> uinfo) {
|
Map<String, dynamic> _userinfoToAppShape(Map<String, dynamic> uinfo) {
|
||||||
return {
|
return {
|
||||||
'id': uinfo['sub'],
|
'id': uinfo['sub'],
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,11 @@ class KeycloakAuthService {
|
||||||
'client_id': AuthConfig.keycloakClientId,
|
'client_id': AuthConfig.keycloakClientId,
|
||||||
'username': username,
|
'username': username,
|
||||||
'password': password,
|
'password': password,
|
||||||
'scope': 'openid',
|
// `offline_access` requests an OFFLINE refresh token that is NOT bound
|
||||||
|
// to the SSO session idle timeout. Result: the app effectively never
|
||||||
|
// logs the user out on its own — the offline session persists until
|
||||||
|
// the user or an admin explicitly revokes it.
|
||||||
|
'scope': 'openid offline_access',
|
||||||
};
|
};
|
||||||
final secret = AuthConfig.keycloakClientSecret;
|
final secret = AuthConfig.keycloakClientSecret;
|
||||||
if (secret != null && secret.isNotEmpty) {
|
if (secret != null && secret.isNotEmpty) {
|
||||||
|
|
@ -153,6 +157,10 @@ class KeycloakAuthService {
|
||||||
'grant_type': 'refresh_token',
|
'grant_type': 'refresh_token',
|
||||||
'client_id': AuthConfig.keycloakClientId,
|
'client_id': AuthConfig.keycloakClientId,
|
||||||
'refresh_token': refreshToken,
|
'refresh_token': refreshToken,
|
||||||
|
// Keep offline_access on rotation so the renewed refresh token is
|
||||||
|
// also an offline token — otherwise a normal refresh would downgrade
|
||||||
|
// us to a session-bound refresh that expires with SSO idle.
|
||||||
|
'scope': 'openid offline_access',
|
||||||
};
|
};
|
||||||
final secret = AuthConfig.keycloakClientSecret;
|
final secret = AuthConfig.keycloakClientSecret;
|
||||||
if (secret != null && secret.isNotEmpty) {
|
if (secret != null && secret.isNotEmpty) {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:didvan/config/design_config.dart';
|
|
||||||
import 'package:didvan/config/theme_data.dart';
|
import 'package:didvan/config/theme_data.dart';
|
||||||
import 'package:didvan/constants/assets.dart';
|
import 'package:didvan/constants/assets.dart';
|
||||||
import 'package:didvan/providers/user.dart';
|
import 'package:didvan/providers/user.dart';
|
||||||
import 'package:didvan/routes/routes.dart';
|
import 'package:didvan/routes/routes.dart';
|
||||||
import 'package:didvan/views/profile/edit_profile/widgets/profile_photo.dart';
|
import 'package:didvan/views/profile/edit_profile/widgets/profile_photo.dart';
|
||||||
import 'package:didvan/views/widgets/animated_visibility.dart';
|
|
||||||
import 'package:didvan/views/widgets/didvan/button.dart';
|
import 'package:didvan/views/widgets/didvan/button.dart';
|
||||||
import 'package:didvan/views/widgets/didvan/scaffold.dart';
|
import 'package:didvan/views/widgets/didvan/scaffold.dart';
|
||||||
import 'package:didvan/views/widgets/didvan/text.dart';
|
|
||||||
import 'package:didvan/views/widgets/didvan/text_field.dart';
|
import 'package:didvan/views/widgets/didvan/text_field.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_svg/svg.dart';
|
import 'package:flutter_svg/svg.dart';
|
||||||
|
|
@ -27,9 +24,7 @@ class _EditProfileState extends State<EditProfile>
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
late String fullName;
|
late String fullName;
|
||||||
String? username;
|
|
||||||
String? email;
|
String? email;
|
||||||
bool _usernameIsAvailible = true;
|
|
||||||
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
|
@ -55,7 +50,6 @@ class _EditProfileState extends State<EditProfile>
|
||||||
super.initState();
|
super.initState();
|
||||||
final user = context.read<UserProvider>().user;
|
final user = context.read<UserProvider>().user;
|
||||||
fullName = user.fullName;
|
fullName = user.fullName;
|
||||||
username = user.username;
|
|
||||||
email = user.email;
|
email = user.email;
|
||||||
|
|
||||||
_initializeAnimations();
|
_initializeAnimations();
|
||||||
|
|
@ -312,44 +306,12 @@ class _EditProfileState extends State<EditProfile>
|
||||||
position: _field3SlideAnimation,
|
position: _field3SlideAnimation,
|
||||||
child: FadeTransition(
|
child: FadeTransition(
|
||||||
opacity: _fadeAnimation,
|
opacity: _fadeAnimation,
|
||||||
child: Column(
|
child: DidvanTextField(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
DidvanTextField(
|
|
||||||
title: 'نام کاربری',
|
title: 'نام کاربری',
|
||||||
hintText: 'انتخاب نام کاربری (اختیاری)',
|
enabled: false,
|
||||||
onChanged: _onUsernameChanged,
|
hintText: state.user.username ?? '',
|
||||||
initialValue: state.user.username,
|
initialValue: state.user.username,
|
||||||
acceptSpace: false,
|
|
||||||
prefixSvgPath: 'lib/assets/icons/user.svg',
|
prefixSvgPath: 'lib/assets/icons/user.svg',
|
||||||
suffixSvgPath:
|
|
||||||
'lib/assets/icons/edit-3.svg',
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 8.0),
|
|
||||||
child: DidvanText(
|
|
||||||
'نام کاربری میتواند شامل کاراکترهای انگلیسی و اعداد باشد.',
|
|
||||||
style: Theme.of(context)
|
|
||||||
.textTheme
|
|
||||||
.labelMedium
|
|
||||||
?.copyWith(
|
|
||||||
color: const Color.fromARGB(
|
|
||||||
255, 184, 184, 184)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
AnimatedVisibility(
|
|
||||||
duration: DesignConfig.lowAnimationDuration,
|
|
||||||
isVisible: !_usernameIsAvailible,
|
|
||||||
child: DidvanText(
|
|
||||||
'نام کاربری در دسترس نمیباشد',
|
|
||||||
style:
|
|
||||||
Theme.of(context).textTheme.bodySmall,
|
|
||||||
color:
|
|
||||||
Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -400,9 +362,15 @@ class _EditProfileState extends State<EditProfile>
|
||||||
key: UniqueKey(),
|
key: UniqueKey(),
|
||||||
title: 'ذخیره تغییرات',
|
title: 'ذخیره تغییرات',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (_formKey.currentState!.validate() &&
|
if (_formKey.currentState!.validate()) {
|
||||||
_usernameIsAvailible) {
|
// Username is intentionally locked on the app; pass
|
||||||
state.editProfile(fullName, username, email);
|
// the current value unchanged so the server never
|
||||||
|
// sees a rename request from here.
|
||||||
|
state.editProfile(
|
||||||
|
fullName,
|
||||||
|
state.user.username,
|
||||||
|
email,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
imagepath: 'lib/assets/icons/verify.svg',
|
imagepath: 'lib/assets/icons/verify.svg',
|
||||||
|
|
@ -425,27 +393,6 @@ class _EditProfileState extends State<EditProfile>
|
||||||
|
|
||||||
bool get _buttonEnabled {
|
bool get _buttonEnabled {
|
||||||
final user = context.read<UserProvider>().user;
|
final user = context.read<UserProvider>().user;
|
||||||
return !(user.email == email &&
|
return !(user.email == email && user.fullName == fullName);
|
||||||
user.fullName == fullName &&
|
|
||||||
user.username == username);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onUsernameChanged(String value) {
|
|
||||||
_usernameIsAvailible = true;
|
|
||||||
_setButtonState();
|
|
||||||
username = value;
|
|
||||||
_timer?.cancel();
|
|
||||||
if (value.length < 5) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_timer = Timer(const Duration(seconds: 1), () {
|
|
||||||
context.read<UserProvider>().checkUsername(value).then((value) {
|
|
||||||
if (value == false) {
|
|
||||||
setState(() {
|
|
||||||
_usernameIsAvailible = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,6 @@ import 'package:didvan/providers/theme.dart';
|
||||||
import 'package:didvan/providers/user.dart';
|
import 'package:didvan/providers/user.dart';
|
||||||
import 'package:didvan/routes/routes.dart';
|
import 'package:didvan/routes/routes.dart';
|
||||||
import 'package:didvan/services/network/request.dart';
|
import 'package:didvan/services/network/request.dart';
|
||||||
import 'package:didvan/services/auth/jwt_utils.dart';
|
|
||||||
import 'package:didvan/services/auth/token_storage.dart';
|
|
||||||
import 'package:didvan/utils/action_sheet.dart';
|
import 'package:didvan/utils/action_sheet.dart';
|
||||||
import 'package:didvan/views/profile/general_settings/settings_state.dart';
|
import 'package:didvan/views/profile/general_settings/settings_state.dart';
|
||||||
import 'package:didvan/views/widgets/animated_visibility.dart';
|
import 'package:didvan/views/widgets/animated_visibility.dart';
|
||||||
|
|
@ -186,7 +184,11 @@ class _ProfilePageState extends State<ProfilePage>
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DidvanText(
|
DidvanText(
|
||||||
user.fullName,
|
// Fall back to username / phone if fullName is empty
|
||||||
|
// so the header never renders a blank Text widget.
|
||||||
|
user.fullName.trim().isNotEmpty
|
||||||
|
? user.fullName
|
||||||
|
: (user.username ?? user.phoneNumber),
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.titleLarge
|
.titleLarge
|
||||||
|
|
@ -195,42 +197,24 @@ class _ProfilePageState extends State<ProfilePage>
|
||||||
color:
|
color:
|
||||||
const Color.fromARGB(255, 0, 126, 167)),
|
const Color.fromARGB(255, 0, 126, 167)),
|
||||||
),
|
),
|
||||||
// Multi-tenant: نمایش نام گروه کاربر (فرتاک/مبارکه/...)
|
// Show a short secondary line (username / phone) if
|
||||||
Builder(
|
// it's different from the primary text — helpful for
|
||||||
builder: (context) {
|
// users whose fullName isn't set yet.
|
||||||
final token = TokenStorage.accessToken ?? '';
|
if (user.fullName.trim().isNotEmpty &&
|
||||||
if (token.isEmpty) return const SizedBox.shrink();
|
(user.username ?? user.phoneNumber).isNotEmpty)
|
||||||
final slug = JwtUtils.tenantSlugOf(token);
|
Padding(
|
||||||
const slugToLabel = {
|
padding: const EdgeInsets.only(top: 4),
|
||||||
'fartak': 'فرتاک',
|
|
||||||
'mobarakeh': 'فولاد مبارکه',
|
|
||||||
'gohar-zamin': 'گوهر زمین',
|
|
||||||
};
|
|
||||||
final label = slugToLabel[slug];
|
|
||||||
if (label == null) return const SizedBox.shrink();
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 6),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color.fromARGB(255, 0, 126, 167)
|
|
||||||
.withOpacity(0.1),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: DidvanText(
|
child: DidvanText(
|
||||||
label,
|
user.username ?? user.phoneNumber,
|
||||||
style: Theme.of(context)
|
style: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodySmall
|
.bodySmall
|
||||||
?.copyWith(
|
?.copyWith(
|
||||||
fontWeight: FontWeight.w600,
|
color: Theme.of(context)
|
||||||
color: const Color.fromARGB(
|
.colorScheme
|
||||||
255, 0, 126, 167)),
|
.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue