552 lines
19 KiB
Dart
552 lines
19 KiB
Dart
// ignore: depend_on_referenced_packages
|
|
// ignore_for_file: avoid_print
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:didvan/config/auth_config.dart';
|
|
import 'package:didvan/main.dart';
|
|
import 'package:didvan/models/enums.dart';
|
|
import 'package:didvan/models/user.dart';
|
|
import 'package:didvan/models/view/alert_data.dart';
|
|
import 'package:didvan/providers/core.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/token_storage.dart';
|
|
import 'package:didvan/services/network/request.dart';
|
|
import 'package:didvan/services/network/request_helper.dart';
|
|
import 'package:didvan/services/notification/firebase_api.dart';
|
|
import 'package:didvan/services/notification/notification_service.dart';
|
|
import 'package:didvan/services/storage/storage.dart';
|
|
import 'package:didvan/utils/action_sheet.dart';
|
|
|
|
class UserProvider extends CoreProvier {
|
|
/// قبلاً `late User user` بود — اگر صفحهای قبل از کامل شدن
|
|
/// `getUserInfo()` به `user` دسترسی میگرفت، `LateInitializationError`
|
|
/// میداد. حالا با یک User تهی initialize میشود تا UI placeholder
|
|
/// نشان دهد بدون کرش.
|
|
User user = const User(
|
|
id: 0,
|
|
username: null,
|
|
phoneNumber: '',
|
|
photo: null,
|
|
fullName: '',
|
|
email: null,
|
|
);
|
|
bool isAuthenticated = false;
|
|
int _unreadMessageCount = 0;
|
|
|
|
String? _welcomeMessage;
|
|
bool _isLoadingWelcome = true;
|
|
|
|
String? get welcomeMessage => _welcomeMessage;
|
|
bool get isLoadingWelcome => _isLoadingWelcome;
|
|
|
|
UserProvider() {
|
|
// greeting فقط به ساعت بستگی دارد، نه به اطلاعات کاربر — پس
|
|
// سریعترین کار این است که موقع ساخت provider همان لحظه «صبح/ظهر/...
|
|
// بخیر 👋» را seed کنیم (بدون نام). وقتی user info اومد،
|
|
// fetchWelcomeMessage آن را با نام refine میکند.
|
|
_seedWelcomeMessage();
|
|
}
|
|
|
|
void _seedWelcomeMessage() {
|
|
final hour = DateTime.now().hour;
|
|
String period;
|
|
if (hour < 12) {
|
|
period = 'صبح';
|
|
} else if (hour < 17) {
|
|
period = 'ظهر';
|
|
} else if (hour < 20) {
|
|
period = 'عصر';
|
|
} else {
|
|
period = 'شب';
|
|
}
|
|
_welcomeMessage = '$period بخیر 👋';
|
|
_isLoadingWelcome = false;
|
|
}
|
|
|
|
set unreadMessageCount(int value) {
|
|
if (value < 0) {
|
|
return;
|
|
}
|
|
_unreadMessageCount = value;
|
|
notifyListeners();
|
|
}
|
|
|
|
int get unreadMessageCount => _unreadMessageCount;
|
|
|
|
static final List<MapEntry> _statisticMarkQueue = [];
|
|
static final List<Map> _itemMarkQueue = [];
|
|
|
|
Future<void> fetchWelcomeMessage() async {
|
|
// ⚠️ دیگر _isLoadingWelcome = true نمیگذاریم چون constructor قبلاً
|
|
// یک greeting بدون نام seed کرده. اگر دوباره loading=true بشود،
|
|
// UI خالی flicker میکند تا fetch بشود. بهجای آن مستقیم نتیجه را
|
|
// refine میکنیم.
|
|
|
|
if (!AuthConfig.useLegacyApi) {
|
|
final hour = DateTime.now().hour;
|
|
String period;
|
|
if (hour < 12) {
|
|
period = 'صبح';
|
|
} else if (hour < 17) {
|
|
period = 'ظهر';
|
|
} else if (hour < 20) {
|
|
period = 'عصر';
|
|
} else {
|
|
period = 'شب';
|
|
}
|
|
String name = '';
|
|
try {
|
|
name = user.fullName;
|
|
} catch (_) {}
|
|
_welcomeMessage =
|
|
name.isEmpty ? '$period بخیر 👋' : '$period بخیر $name 👋';
|
|
_isLoadingWelcome = false;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
if (!_isLoadingWelcome) {
|
|
_isLoadingWelcome = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
try {
|
|
const String url = 'https://api.didvan.app/ai/aiwellcom';
|
|
if (RequestService.token == null) {
|
|
print("UserProvider: fetchWelcomeMessage skipped, token is null.");
|
|
_isLoadingWelcome = false;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
print("UserProvider: Fetching welcome message...");
|
|
|
|
final service = RequestService(url, useAutherization: true);
|
|
await service.post();
|
|
|
|
if (service.isSuccess) {
|
|
print("UserProvider: Welcome message API success.");
|
|
final period = service.data('period');
|
|
final userData = service.data('user');
|
|
if (period != null &&
|
|
userData is Map &&
|
|
userData.containsKey('fullName')) {
|
|
final fullName = userData['fullName'];
|
|
_welcomeMessage = '$period بخیر $fullName 👋';
|
|
print("UserProvider: Welcome message set: $_welcomeMessage");
|
|
} else {
|
|
print(
|
|
"UserProvider: Welcome message API success but data format unexpected.");
|
|
_welcomeMessage = null;
|
|
}
|
|
} else {
|
|
print(
|
|
"UserProvider: Welcome message API failed. Status: ${service.statusCode}, Error: ${service.errorMessage}");
|
|
_welcomeMessage = null;
|
|
}
|
|
} catch (e) {
|
|
print("UserProvider: Exception fetching welcome message: $e");
|
|
_welcomeMessage = null;
|
|
} finally {
|
|
_isLoadingWelcome = false;
|
|
print(
|
|
"UserProvider: fetchWelcomeMessage finished. isLoadingWelcome: $_isLoadingWelcome");
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<String?> setAndGetToken({String? newToken}) async {
|
|
try {
|
|
if (newToken == null) {
|
|
// اگر access token معتبر است، همان را برمیگردانیم.
|
|
if (TokenStorage.isAccessTokenValid) {
|
|
final cached = TokenStorage.accessToken;
|
|
if (cached != null && cached.isNotEmpty) {
|
|
RequestService.token = cached;
|
|
return cached;
|
|
}
|
|
}
|
|
// access token منقضی است؛ اگر refresh token داریم، تلاش به
|
|
// refresh قبل از ادامه. اگر refresh هم fail شد، session را پاک
|
|
// میکنیم تا splash کاربر را به login بفرستد.
|
|
if (TokenStorage.refreshToken != null &&
|
|
TokenStorage.refreshToken!.isNotEmpty) {
|
|
final ok = await KeycloakAuthService.instance.refresh();
|
|
if (ok) {
|
|
final fresh = TokenStorage.accessToken;
|
|
if (fresh != null && fresh.isNotEmpty) {
|
|
RequestService.token = fresh;
|
|
await StorageService.setValue(key: 'token', value: fresh);
|
|
return fresh;
|
|
}
|
|
}
|
|
// refresh fail کرد → session تمام شده، کاربر باید مجدد login کند.
|
|
print(
|
|
'🔐 setAndGetToken: refresh failed, clearing session for re-login');
|
|
await TokenStorage.clear();
|
|
await StorageService.delete(key: 'token');
|
|
RequestService.token = null;
|
|
return null;
|
|
}
|
|
// نه access معتبر داریم نه refresh — هیچ session فعالی نیست.
|
|
final token = await StorageService.getValue(key: 'token');
|
|
if (token != null && token.isNotEmpty) {
|
|
RequestService.token = token;
|
|
return token;
|
|
}
|
|
return null;
|
|
}
|
|
RequestService.token = newToken;
|
|
await StorageService.setValue(key: 'token', value: newToken);
|
|
return null;
|
|
} catch (e) {
|
|
print('🔐 setAndGetToken error: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<bool> getUserInfo() async {
|
|
isAuthenticated = true;
|
|
print("UserProvider: Getting user info...");
|
|
|
|
final keycloakId = TokenStorage.userId;
|
|
if (keycloakId == null || keycloakId.isEmpty) {
|
|
print("UserProvider: getUserInfo failed - no user id in token.");
|
|
isAuthenticated = false;
|
|
return false;
|
|
}
|
|
|
|
// 1) Local profile from the JWT (no network — always available).
|
|
// Keycloak includes `name/given_name/family_name/preferred_username/email`
|
|
// in the access token, so we can populate the UI even if BOTH the OIDC
|
|
// userinfo endpoint AND api2/auth/users/:id are unreachable.
|
|
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);
|
|
|
|
// 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;
|
|
if (result.isSuccess && result.data != null) {
|
|
// The auth-service response can carry null/empty values for fields the
|
|
// 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 {
|
|
print("UserProvider: auth-service ${result.statusCode ?? 'n/a'} -> "
|
|
"fallback to userinfo only.");
|
|
mergedData = _userinfoToAppShape(userinfo);
|
|
}
|
|
|
|
try {
|
|
user = User.fromJson(mergedData);
|
|
|
|
if (mergedData['start'] != null) {
|
|
await StorageService.setValue(
|
|
key: 'notificationTimeRangeStart',
|
|
value: mergedData['start'],
|
|
);
|
|
}
|
|
if (mergedData['end'] != null) {
|
|
await StorageService.setValue(
|
|
key: 'notificationTimeRangeEnd',
|
|
value: mergedData['end'],
|
|
);
|
|
}
|
|
|
|
// ignore: discarded_futures
|
|
_registerFirebaseToken();
|
|
|
|
notifyListeners();
|
|
|
|
// ignore: discarded_futures
|
|
fetchWelcomeMessage();
|
|
|
|
// If the user reached us via a notification tap while logged out,
|
|
// splash stashed the payload. Now that we're authenticated, replay
|
|
// it so they land on the right detail screen instead of staying on
|
|
// the home page.
|
|
// ignore: discarded_futures
|
|
NotificationService.consumePendingNotificationTap();
|
|
|
|
return true;
|
|
} catch (e) {
|
|
print("UserProvider: Exception processing user info: $e");
|
|
isAuthenticated = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 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) {
|
|
return {
|
|
'id': uinfo['sub'],
|
|
'keycloakId': uinfo['sub'],
|
|
'username': uinfo['preferred_username'],
|
|
'firstName': uinfo['given_name'],
|
|
'lastName': uinfo['family_name'],
|
|
'fullName': uinfo['name'],
|
|
'email': uinfo['email'],
|
|
// attribute هایی که فقط در صورت تنظیم Protocol Mapper در client میآیند:
|
|
'phone': uinfo['phone'] ?? uinfo['phone_number'],
|
|
'avatar': uinfo['avatar'] ?? uinfo['picture'],
|
|
};
|
|
}
|
|
|
|
Future<void> _registerFirebaseToken() async {
|
|
if (FirebaseApi.fcmToken == null) return;
|
|
final id = TokenStorage.userId ?? user.keycloakId;
|
|
if (id == null || id.isEmpty) return;
|
|
|
|
// کلید DTO سمت backend `firebaseToken` (camelCase) است — backend خودش
|
|
// در users.service.ts آن را به `firebase_token` در Keycloak attribute
|
|
// تبدیل میکند (پس از fix snake_case در update path).
|
|
await AuthApiService.instance.updateUser(
|
|
id: id,
|
|
data: {'firebaseToken': FirebaseApi.fcmToken},
|
|
);
|
|
}
|
|
|
|
Future<void> logout() async {
|
|
print('UserProvider: logout() called - clearing local state first');
|
|
|
|
final refreshToken = TokenStorage.refreshToken;
|
|
|
|
RequestService.token = null;
|
|
await TokenStorage.clear();
|
|
await StorageService.delete(key: 'token');
|
|
isAuthenticated = false;
|
|
notifyListeners();
|
|
print('UserProvider: local state cleared, '
|
|
'accessToken=${TokenStorage.accessToken}');
|
|
|
|
if (refreshToken != null) {
|
|
// ignore: discarded_futures
|
|
KeycloakAuthService.instance.revokeRefreshToken(refreshToken);
|
|
}
|
|
}
|
|
|
|
Future<bool> setProfilePhoto(dynamic file) async {
|
|
// TODO(auth-followup): پیادهسازی آپلود به s3-service و سپس فراخوانی
|
|
// AuthApiService.instance.setProfilePicture(id, key).
|
|
appState = AppState.failed;
|
|
return false;
|
|
}
|
|
|
|
Future<bool> deleteProfilePhoto() async {
|
|
appState = AppState.isolatedBusy;
|
|
final id = TokenStorage.userId ?? user.keycloakId;
|
|
if (id == null || id.isEmpty) {
|
|
appState = AppState.idle;
|
|
return false;
|
|
}
|
|
final result = await AuthApiService.instance.updateUser(
|
|
id: id,
|
|
data: {'avatar': null},
|
|
);
|
|
if (result.isSuccess) {
|
|
user = user.copyWith(photo: null);
|
|
appState = AppState.idle;
|
|
return true;
|
|
}
|
|
appState = AppState.idle;
|
|
return false;
|
|
}
|
|
|
|
Future<bool?> checkUsername(String username) async {
|
|
if (user.username == username) return true;
|
|
final result = await AuthApiService.instance.getRequiredActions(username);
|
|
if (result.statusCode == 404) return true; // موجود نیست => قابل استفاده
|
|
if (result.isSuccess) return false; // پیدا شد => تکراری
|
|
return null;
|
|
}
|
|
|
|
Future<void> editProfile(
|
|
String fullName,
|
|
String? username,
|
|
String? email,
|
|
) async {
|
|
appState = AppState.isolatedBusy;
|
|
final id = TokenStorage.userId ?? user.keycloakId;
|
|
if (id == null || id.isEmpty) {
|
|
appState = AppState.idle;
|
|
ActionSheetUtils(navigatorKey.currentContext!).showAlert(
|
|
AlertData(message: 'شناسه کاربر یافت نشد.'),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final parts = fullName.trim().split(RegExp(r'\s+'));
|
|
final firstName = parts.isNotEmpty ? parts.first : '';
|
|
final lastName = parts.length > 1 ? parts.sublist(1).join(' ') : '';
|
|
|
|
final data = <String, dynamic>{
|
|
'firstName': firstName,
|
|
'lastName': lastName,
|
|
if (email != null) 'email': email,
|
|
};
|
|
|
|
final result = await AuthApiService.instance.updateUser(id: id, data: data);
|
|
|
|
if (result.isSuccess) {
|
|
user = user.copyWith(
|
|
fullName: fullName,
|
|
firstName: firstName,
|
|
lastName: lastName,
|
|
email: email,
|
|
username: username,
|
|
photo: user.photo,
|
|
);
|
|
appState = AppState.idle;
|
|
ActionSheetUtils(navigatorKey.currentContext!).pop();
|
|
ActionSheetUtils(navigatorKey.currentContext!).showAlert(
|
|
AlertData(
|
|
message: 'پروفایل با موفقیت ویرایش شد',
|
|
aLertType: ALertType.success,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
appState = AppState.idle;
|
|
ActionSheetUtils(navigatorKey.currentContext!).showAlert(
|
|
AlertData(
|
|
message: result.errorMessage ?? 'خطا در ویرایش پروفایل.',
|
|
),
|
|
);
|
|
}
|
|
|
|
static Future<void> changeItemMark(String type, int id, bool? value,
|
|
{String? description}) async {
|
|
_itemMarkQueue.add({
|
|
'type': type,
|
|
'id': id,
|
|
'value': value,
|
|
'description': description,
|
|
});
|
|
Future.delayed(const Duration(milliseconds: 500), () async {
|
|
final lastChange =
|
|
_itemMarkQueue.lastWhereOrNull((item) => item['id'] == id);
|
|
if (lastChange == null) return;
|
|
final service = RequestService(
|
|
RequestHelper.editItemBookmark(type, id),
|
|
body: {'description': lastChange['description']},
|
|
);
|
|
if (lastChange['value'] == true) {
|
|
await service.post();
|
|
} else if (lastChange['value'] == false) {
|
|
await service.delete();
|
|
} else {
|
|
service.put();
|
|
}
|
|
_itemMarkQueue.removeWhere((element) => element['id'] == id);
|
|
});
|
|
}
|
|
|
|
static Future<void> changeItemLiked(
|
|
String type,
|
|
int id,
|
|
bool value,
|
|
) async {
|
|
if (type == 'infography') {
|
|
type = 'banner';
|
|
}
|
|
final service = RequestService(
|
|
RequestHelper.editItemLike(type, id),
|
|
);
|
|
|
|
if (value) {
|
|
await service.post();
|
|
} else {
|
|
await service.delete();
|
|
}
|
|
}
|
|
|
|
static Future<void> changeStatisticMark(int id, bool value) async {
|
|
_statisticMarkQueue.add(MapEntry(id, value));
|
|
Future.delayed(const Duration(milliseconds: 500), () async {
|
|
final MapEntry? lastChange =
|
|
_statisticMarkQueue.lastWhereOrNull((item) => item.key == id);
|
|
if (lastChange == null) return;
|
|
final service = RequestService(RequestHelper.mark(id, 'statistic'));
|
|
if (lastChange.value) {
|
|
await service.post();
|
|
} else {
|
|
await service.delete();
|
|
}
|
|
_statisticMarkQueue.removeWhere((element) => element.key == id);
|
|
});
|
|
}
|
|
|
|
Future<bool> changePassword({
|
|
required String currentPassword,
|
|
required String newPassword,
|
|
}) async {
|
|
try {
|
|
final username = user.username;
|
|
if (username == null || username.isEmpty) {
|
|
return false;
|
|
}
|
|
final loginResult = await KeycloakAuthService.instance.login(
|
|
username: username,
|
|
password: currentPassword,
|
|
);
|
|
if (!loginResult.success) {
|
|
return false;
|
|
}
|
|
|
|
final id = TokenStorage.userId ?? user.keycloakId;
|
|
if (id == null || id.isEmpty) return false;
|
|
final result = await AuthApiService.instance.setOwnPassword(
|
|
id: id,
|
|
newPassword: newPassword,
|
|
);
|
|
return result.isSuccess;
|
|
} catch (e) {
|
|
print('Error changing password: $e');
|
|
return false;
|
|
}
|
|
}
|
|
}
|