diff --git a/CORP-MAUI/App.xaml.cs b/CORP-MAUI/App.xaml.cs index aa83c80..6179d15 100644 --- a/CORP-MAUI/App.xaml.cs +++ b/CORP-MAUI/App.xaml.cs @@ -1,5 +1,6 @@ -using CORP_MAUI.Pages; +using CORP_MAUI.Pages; using CORP_MAUI.Models; +using CORP_MAUI.Services; using System.Diagnostics; namespace CORP_MAUI; @@ -20,6 +21,9 @@ public partial class App : Application InitializeComponent(); + // Очищаем устаревший кэш изображений + ImageCacheService.Instance.CleanExpiredCache(); + Trace.WriteLine("[App] ✅ Application initialized"); } diff --git a/CORP-MAUI/Models/User.cs b/CORP-MAUI/Models/User.cs index 8bbb1f4..2077725 100644 --- a/CORP-MAUI/Models/User.cs +++ b/CORP-MAUI/Models/User.cs @@ -49,7 +49,24 @@ public class User { get { - if (!string.IsNullOrEmpty(Avatar)) return Avatar; + // Сначала проверяем Avatar (может содержать полный URL или data:image) + if (!string.IsNullOrEmpty(Avatar)) + { + // Если это data:image - не возвращаем как URL + if (Avatar.StartsWith("data:image")) + return null; + + // Если это полный URL + if (Avatar.StartsWith("http", StringComparison.OrdinalIgnoreCase)) + return Avatar; + + // Если это относительный путь + if (!Avatar.StartsWith("data:")) + { + string cleanUrl = Avatar.TrimStart('/'); + return $"https://corp.robotail.pro/{cleanUrl}"; + } + } if (string.IsNullOrEmpty(AvatarUrl)) return null; @@ -60,40 +77,93 @@ public class User if (AvatarUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return AvatarUrl; - string cleanUrl = AvatarUrl.TrimStart('/'); - return $"https://corp.robotail.pro/{cleanUrl}"; + string cleanPath = AvatarUrl.TrimStart('/'); + return $"https://corp.robotail.pro/{cleanPath}"; } } /// - /// Универсальное свойство для отображения аватара (поддерживает Base64 и URL) + /// Кэшированный ImageSource аватара (не создаётся каждый раз) + /// + [JsonIgnore] + private ImageSource? _cachedImageSource; + + /// + /// Путь к файлу кэшированного аватара + /// + [JsonIgnore] + public string? CachedAvatarFilePath { get; set; } + + /// + /// Флаг, что аватар был успешно загружен + /// + [JsonIgnore] + public bool IsAvatarLoaded { get; private set; } + + /// + /// Установить путь к файлу кэшированного аватара + /// + public void SetCachedAvatarFilePath(string filePath) + { + CachedAvatarFilePath = filePath; + _cachedImageSource = ImageSource.FromFile(filePath); + IsAvatarLoaded = true; + } + + /// + /// Сбросить кэш ImageSource + /// + public void ResetImageSourceCache() + { + _cachedImageSource = null; + IsAvatarLoaded = false; + CachedAvatarFilePath = null; + } + + /// + /// Универсальное свойство для отображения аватара /// [JsonIgnore] public ImageSource? DisplayAvatarSource { get { + // Если аватар уже загружен - возвращаем кэшированный + if (IsAvatarLoaded && _cachedImageSource != null) + { + return _cachedImageSource; + } + + // Проверяем путь к файлу + if (!string.IsNullOrEmpty(CachedAvatarFilePath) && File.Exists(CachedAvatarFilePath)) + { + _cachedImageSource = ImageSource.FromFile(CachedAvatarFilePath); + IsAvatarLoaded = true; + return _cachedImageSource; + } + try { - // 1. Проверяем, есть ли Base64 изображение в Avatar - if (!string.IsNullOrEmpty(Avatar) && Avatar.StartsWith("data:image")) - { - // Извлекаем Base64 данные из строки "data:image/jpeg;base64,..." - string base64Data = Avatar; - int commaIndex = base64Data.IndexOf(','); - if (commaIndex > 0) - { - base64Data = base64Data.Substring(commaIndex + 1); - byte[] imageBytes = Convert.FromBase64String(base64Data); - return ImageSource.FromStream(() => new MemoryStream(imageBytes)); - } - } - - // 2. Если есть URL аватара + // Проверяем глобальный файловый кэш string? url = DisplayAvatarUrl; if (!string.IsNullOrEmpty(url)) { - return ImageSource.FromUri(new Uri(url)); + string? cachedPath = Services.ImageCacheService.Instance.GetCachedFilePath(url); + if (!string.IsNullOrEmpty(cachedPath) && File.Exists(cachedPath)) + { + CachedAvatarFilePath = cachedPath; + _cachedImageSource = ImageSource.FromFile(cachedPath); + IsAvatarLoaded = true; + return _cachedImageSource; + } + + // Если нет в кэше - используем UriImageSource + return new UriImageSource + { + Uri = new Uri(url), + CachingEnabled = true, + CacheValidity = TimeSpan.FromDays(7) + }; } } catch (Exception ex) @@ -101,7 +171,7 @@ public class User Trace.WriteLine($"[User] ⚠️ Ошибка загрузки аватара для {FullName}: {ex.Message}"); } - // 3. Возвращаем placeholder + // Возвращаем placeholder return ImageSource.FromFile("dotnet_bot.png"); } } diff --git a/CORP-MAUI/Pages/ChatPage.xaml.cs b/CORP-MAUI/Pages/ChatPage.xaml.cs index a7b8c04..b125492 100644 --- a/CORP-MAUI/Pages/ChatPage.xaml.cs +++ b/CORP-MAUI/Pages/ChatPage.xaml.cs @@ -36,6 +36,13 @@ public partial class ChatPage : ContentPage { try { + // Загружаем аватар получателя если еще не загружен + if (!_recipient.IsAvatarLoaded) + { + await ServiceProvider.ApiService.LoadUserAvatarAsync(_recipient); + RecipientAvatar.Source = _recipient.DisplayAvatarSource; + } + // Получаем текущего пользователя User? user = await ServiceProvider.ApiService.GetCurrentUserAsync(); if (user != null) diff --git a/CORP-MAUI/Pages/DepartmentDetailsPage.xaml.cs b/CORP-MAUI/Pages/DepartmentDetailsPage.xaml.cs index 4e00c35..3b6e4a5 100644 --- a/CORP-MAUI/Pages/DepartmentDetailsPage.xaml.cs +++ b/CORP-MAUI/Pages/DepartmentDetailsPage.xaml.cs @@ -1,4 +1,4 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Diagnostics; using CORP_MAUI.Models; namespace CORP_MAUI.Pages; @@ -73,6 +73,9 @@ public partial class DepartmentDetailsPage : ContentPage HeadSection.IsVisible = true; HeadNameLabel.Text = _department.Head.FullName; HeadPositionLabel.Text = _department.Head.PositionName ?? "Должность не указана"; + + // Загружаем аватар руководителя + await ServiceProvider.ApiService.LoadUserAvatarAsync(_department.Head); HeadAvatarImage.Source = _department.Head.DisplayAvatarSource; } Trace.WriteLine($"[DepartmentDetailsPage] >>> Вызов GetDepartmentEmployeesAsync({_departmentId})"); @@ -96,6 +99,9 @@ public partial class DepartmentDetailsPage : ContentPage } } Trace.WriteLine($"[DepartmentDetailsPage] ✅ Данные загружены успешно. Команда: {_teamMembers.Count} человек"); + + // Предзагружаем аватары в фоне + _ = PreloadAvatarsAsync(); } catch (Exception ex) { @@ -153,4 +159,39 @@ public partial class DepartmentDetailsPage : ContentPage } } } + + private async Task PreloadAvatarsAsync() + { + try + { + Trace.WriteLine("[DepartmentDetailsPage] >>> Начинаем предзагрузку аватаров..."); + + // Загружаем аватар руководителя + if (_department?.Head != null) + { + await ServiceProvider.ApiService.LoadUserAvatarAsync(_department.Head); + } + + // Загружаем аватары команды + await ServiceProvider.ApiService.PreloadUserAvatarsAsync(_teamMembers); + + // Обновляем UI + await MainThread.InvokeOnMainThreadAsync(() => + { + TeamCollectionView.ItemsSource = null; + TeamCollectionView.ItemsSource = _teamMembers; + + if (_department?.Head != null) + { + HeadAvatarImage.Source = _department.Head.DisplayAvatarSource; + } + }); + + Trace.WriteLine("[DepartmentDetailsPage] ✅ Аватары предзагружены"); + } + catch (Exception ex) + { + Trace.WriteLine($"[DepartmentDetailsPage] ⚠️ Ошибка предзагрузки аватаров: {ex.Message}"); + } + } } diff --git a/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs b/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs index 4c70003..548ebc4 100644 --- a/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs +++ b/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs @@ -81,6 +81,9 @@ public partial class EmployeeProfilePage : ContentPage Trace.WriteLine($"[EmployeeProfilePage] ✅ Профиль загружен: {_user.FullName}"); + // Предзагружаем аватар пользователя + await ServiceProvider.ApiService.LoadUserAvatarAsync(_user); + // Заполняем основную информацию FullNameLabel.Text = _user.FullName; PositionLabel.Text = _user.PositionName ?? "Должность не указана"; diff --git a/CORP-MAUI/Pages/EmployeesPage.xaml.cs b/CORP-MAUI/Pages/EmployeesPage.xaml.cs index 309ea2c..291a924 100644 --- a/CORP-MAUI/Pages/EmployeesPage.xaml.cs +++ b/CORP-MAUI/Pages/EmployeesPage.xaml.cs @@ -77,6 +77,9 @@ public partial class EmployeesPage : ContentPage } Trace.WriteLine($"[EmployeesPage] ✅ Сотрудники загружены успешно"); + + // Предзагружаем аватары в фоне + _ = PreloadAvatarsAsync(); } catch (Exception ex) { @@ -198,5 +201,28 @@ public partial class EmployeesPage : ContentPage base.OnDisappearing(); ServiceProvider.WebSocketService.UserStatusChanged -= OnUserStatusChanged; } + + private async Task PreloadAvatarsAsync() + { + try + { + Trace.WriteLine("[EmployeesPage] >>> Начинаем предзагрузку аватаров..."); + await ServiceProvider.ApiService.PreloadUserAvatarsAsync(_allEmployees); + + // Обновляем UI - уведомляем об изменении коллекции + await MainThread.InvokeOnMainThreadAsync(() => + { + // CollectionView автоматически обновится, т.к. ImageSource уже установлен в объектах + EmployeesCollectionView.ItemsSource = null; + EmployeesCollectionView.ItemsSource = _employees; + }); + + Trace.WriteLine("[EmployeesPage] ✅ Аватары предзагружены"); + } + catch (Exception ex) + { + Trace.WriteLine($"[EmployeesPage] ⚠️ Ошибка предзагрузки аватаров: {ex.Message}"); + } + } } diff --git a/CORP-MAUI/Pages/PostDetailsPage.xaml.cs b/CORP-MAUI/Pages/PostDetailsPage.xaml.cs index d06ed46..c9b0c06 100644 --- a/CORP-MAUI/Pages/PostDetailsPage.xaml.cs +++ b/CORP-MAUI/Pages/PostDetailsPage.xaml.cs @@ -13,6 +13,9 @@ public partial class PostDetailsPage : ContentPage InitializeComponent(); _viewModel = new PostDetailsViewModel(postViewModel); BindingContext = _viewModel; + + // Предзагружаем аватар автора поста + _ = PreloadPostAuthorAvatarAsync(); } protected override async void OnAppearing() @@ -38,6 +41,9 @@ public partial class PostDetailsPage : ContentPage { _viewModel.Comments.Add(comment); } + + // Предзагружаем аватары авторов комментариев + _ = PreloadCommentAuthorsAvatarsAsync(); } } catch (Exception ex) @@ -49,6 +55,50 @@ public partial class PostDetailsPage : ContentPage _viewModel.IsLoadingComments = false; } } + + private async Task PreloadPostAuthorAvatarAsync() + { + try + { + var author = _viewModel.PostViewModel.Post.Author; + if (author != null) + { + await ServiceProvider.ApiService.LoadUserAvatarAsync(author); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[PostDetailsPage] Ошибка предзагрузки аватара автора: {ex.Message}"); + } + } + + private async Task PreloadCommentAuthorsAvatarsAsync() + { + try + { + var authors = _viewModel.Comments + .Where(c => c.Author != null) + .Select(c => c.Author!) + .Distinct() + .ToList(); + + if (authors.Count > 0) + { + await ServiceProvider.ApiService.PreloadUserAvatarsAsync(authors); + + // Обновляем UI - переустанавливаем ItemsSource + await MainThread.InvokeOnMainThreadAsync(() => + { + CommentsCollectionView.ItemsSource = null; + CommentsCollectionView.ItemsSource = _viewModel.Comments; + }); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[PostDetailsPage] Ошибка предзагрузки аватаров комментаторов: {ex.Message}"); + } + } private async void OnCloseClicked(object sender, EventArgs e) { diff --git a/CORP-MAUI/Pages/PostsPage.xaml.cs b/CORP-MAUI/Pages/PostsPage.xaml.cs index 51a1256..115aaa4 100644 --- a/CORP-MAUI/Pages/PostsPage.xaml.cs +++ b/CORP-MAUI/Pages/PostsPage.xaml.cs @@ -40,8 +40,9 @@ public partial class PostsPage try { var user = await ServiceProvider.ApiService.GetCurrentUserAsync(); - if (user != null && !string.IsNullOrEmpty(user.Avatar)) + if (user != null) { + await ServiceProvider.ApiService.LoadUserAvatarAsync(user); UserAvatarImage.Source = user.DisplayAvatarSource; } } @@ -217,6 +218,9 @@ public partial class PostsPage // Применяем фильтр поиска ApplyFilter(); + + // Предзагружаем аватары авторов постов в фоне + _ = PreloadPostAuthorsAvatarsAsync(viewModels); } } catch (Exception ex) @@ -232,6 +236,34 @@ public partial class PostsPage PostsRefreshView.IsRefreshing = false; } } + + private async Task PreloadPostAuthorsAvatarsAsync(IEnumerable posts) + { + try + { + var authors = posts + .Where(p => p.Post.Author != null) + .Select(p => p.Post.Author!) + .Distinct() + .ToList(); + + if (authors.Count > 0) + { + await ServiceProvider.ApiService.PreloadUserAvatarsAsync(authors); + + // Обновляем UI - переустанавливаем ItemsSource + await MainThread.InvokeOnMainThreadAsync(() => + { + PostsCollectionView.ItemsSource = null; + PostsCollectionView.ItemsSource = _posts; + }); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[PostsPage] Ошибка предзагрузки аватаров авторов: {ex.Message}"); + } + } private async void OnRefreshing(object sender, EventArgs e) { diff --git a/CORP-MAUI/Services/ApiService.cs b/CORP-MAUI/Services/ApiService.cs index f221e64..24dbb92 100644 --- a/CORP-MAUI/Services/ApiService.cs +++ b/CORP-MAUI/Services/ApiService.cs @@ -1393,6 +1393,77 @@ public class ApiService : IDisposable } } + /// + /// Предзагрузить аватары для списка пользователей + /// + public async Task PreloadUserAvatarsAsync(IEnumerable users) + { + var userList = users.ToList(); + var tasks = new List(); + + foreach (var user in userList) + { + // Пропускаем, если аватар уже загружен + if (user.IsAvatarLoaded) + { + continue; + } + + string? url = user.DisplayAvatarUrl; + if (string.IsNullOrEmpty(url)) + { + continue; + } + + // Проверяем файловый кэш + string? cachedPath = ImageCacheService.Instance.GetCachedFilePath(url); + if (!string.IsNullOrEmpty(cachedPath) && File.Exists(cachedPath)) + { + // Устанавливаем путь к файлу + user.SetCachedAvatarFilePath(cachedPath); + } + else + { + // Добавляем в очередь загрузки + tasks.Add(LoadUserAvatarAsync(user)); + } + } + + if (tasks.Count > 0) + { + Trace.WriteLine($"[ApiService] Загрузка {tasks.Count} аватаров..."); + await Task.WhenAll(tasks); + Trace.WriteLine($"[ApiService] ✅ Аватары загружены"); + } + } + + /// + /// Загрузить аватар для одного пользователя (файловый кэш) + /// + public async Task LoadUserAvatarAsync(User user) + { + try + { + string? url = user.DisplayAvatarUrl; + if (string.IsNullOrEmpty(url)) + { + return; + } + + // Загружаем через файловый кэш + string? filePath = await ImageCacheService.Instance.GetOrLoadImageFileAsync(url, GetImageBytesAsync); + if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) + { + user.SetCachedAvatarFilePath(filePath); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[ApiService] Ошибка предзагрузки аватара для {user.FullName}: {ex.Message}"); + } + } + + #endregion #region IDisposable diff --git a/CORP-MAUI/Services/ImageCacheService.cs b/CORP-MAUI/Services/ImageCacheService.cs new file mode 100644 index 0000000..d0d8436 --- /dev/null +++ b/CORP-MAUI/Services/ImageCacheService.cs @@ -0,0 +1,185 @@ +using System.Collections.Concurrent; +using System.Diagnostics; + +namespace CORP_MAUI.Services; + +/// +/// Сервис для кэширования изображений на диске (файловый кэш) +/// +public class ImageCacheService +{ + private static ImageCacheService? _instance; + public static ImageCacheService Instance => _instance ??= new ImageCacheService(); + + // Кэш путей к файлам (URL -> FilePath) + private readonly ConcurrentDictionary _pathCache = new(); + + // Путь к папке кэша на диске + private readonly string _diskCachePath; + + // Максимальное время жизни кэша + private readonly TimeSpan _cacheExpiry = TimeSpan.FromDays(7); + + private ImageCacheService() + { + _diskCachePath = Path.Combine(FileSystem.CacheDirectory, "avatars"); + + // Создаём папку кэша, если не существует + if (!Directory.Exists(_diskCachePath)) + { + Directory.CreateDirectory(_diskCachePath); + } + + Trace.WriteLine($"[ImageCacheService] Initialized. Cache path: {_diskCachePath}"); + } + + /// + /// Получить путь к файлу из кэша или загрузить изображение + /// + public async Task GetOrLoadImageFileAsync(string? url, Func>? loader = null) + { + if (string.IsNullOrEmpty(url)) return null; + + // 1. Проверяем кэш путей + if (_pathCache.TryGetValue(url, out string? cachedPath) && File.Exists(cachedPath)) + { + return cachedPath; + } + + // 2. Проверяем файл на диске + string filePath = GetCacheFilePath(url); + if (File.Exists(filePath)) + { + var fileInfo = new FileInfo(filePath); + if (fileInfo.Length > 0 && fileInfo.LastWriteTime > DateTime.Now - _cacheExpiry) + { + _pathCache[url] = filePath; + return filePath; + } + try { File.Delete(filePath); } catch { } + } + + // 3. Загружаем изображение + if (loader != null) + { + try + { + byte[]? imageBytes = await loader(url); + if (imageBytes != null && imageBytes.Length > 0) + { + await File.WriteAllBytesAsync(filePath, imageBytes); + _pathCache[url] = filePath; + return filePath; + } + } + catch (Exception ex) + { + Trace.WriteLine($"[ImageCacheService] Ошибка загрузки {url}: {ex.Message}"); + } + } + + return null; + } + + /// + /// Получить путь к кэшированному файлу (если существует) + /// + public string? GetCachedFilePath(string? url) + { + if (string.IsNullOrEmpty(url)) return null; + + if (_pathCache.TryGetValue(url, out string? cachedPath) && File.Exists(cachedPath)) + { + return cachedPath; + } + + string filePath = GetCacheFilePath(url); + if (File.Exists(filePath)) + { + var fileInfo = new FileInfo(filePath); + if (fileInfo.Length > 0) + { + _pathCache[url] = filePath; + return filePath; + } + } + + return null; + } + + /// + /// Проверить, есть ли изображение в кэше + /// + public bool IsCached(string? url) + { + if (string.IsNullOrEmpty(url)) return false; + + if (_pathCache.TryGetValue(url, out string? path) && File.Exists(path)) + return true; + + string filePath = GetCacheFilePath(url); + return File.Exists(filePath); + } + + /// + /// Получить путь к файлу кэша для URL + /// + private string GetCacheFilePath(string url) + { + using var md5 = System.Security.Cryptography.MD5.Create(); + byte[] hashBytes = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(url)); + string hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + return Path.Combine(_diskCachePath, $"{hash}.jpg"); + } + + // Для обратной совместимости + public string? GetFromCache(string? url) => GetCachedFilePath(url); + + /// + /// Очистить устаревший кэш + /// + public void CleanExpiredCache() + { + try + { + if (!Directory.Exists(_diskCachePath)) return; + + var expiredFiles = Directory.GetFiles(_diskCachePath) + .Where(f => File.GetLastWriteTime(f) < DateTime.Now - _cacheExpiry) + .ToList(); + + foreach (var file in expiredFiles) + { + try { File.Delete(file); } catch { } + } + + if (expiredFiles.Count > 0) + { + Trace.WriteLine($"[ImageCacheService] Удалено {expiredFiles.Count} устаревших файлов"); + } + } + catch (Exception ex) + { + Trace.WriteLine($"[ImageCacheService] Ошибка очистки: {ex.Message}"); + } + } + + /// + /// Очистить весь кэш + /// + public void ClearCache() + { + _pathCache.Clear(); + try + { + if (Directory.Exists(_diskCachePath)) + { + foreach (var file in Directory.GetFiles(_diskCachePath)) + { + try { File.Delete(file); } catch { } + } + } + } + catch { } + } +} diff --git a/CORP-MAUI/Services/WebSocketService.cs b/CORP-MAUI/Services/WebSocketService.cs index 9bbfc18..fe6b710 100644 --- a/CORP-MAUI/Services/WebSocketService.cs +++ b/CORP-MAUI/Services/WebSocketService.cs @@ -62,6 +62,9 @@ public class WebSocketService _webSocket = new ClientWebSocket(); + // Настройка для Android - увеличиваем таймаут и буферы + _webSocket.Options.KeepAliveInterval = TimeSpan.FromSeconds(30); + // 🔑 Устанавливаем Authorization заголовок Trace.WriteLine($"[WebSocket] 🔑 Setting Authorization header..."); Trace.WriteLine($"[WebSocket] Token (first 30 chars): {authToken.Substring(0, Math.Min(30, authToken.Length))}..."); @@ -70,6 +73,7 @@ public class WebSocketService Trace.WriteLine($"[WebSocket] ✅ Authorization header set: Bearer [token]"); _cancellationTokenSource = new CancellationTokenSource(); + _cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(15)); // Таймаут подключения Uri uri = new Uri(url); Trace.WriteLine($"[WebSocket] Connecting to: {uri}"); diff --git a/CORP-MAUI/ViewModels/ProfileViewModel.cs b/CORP-MAUI/ViewModels/ProfileViewModel.cs index 44f3a6c..f767c19 100644 --- a/CORP-MAUI/ViewModels/ProfileViewModel.cs +++ b/CORP-MAUI/ViewModels/ProfileViewModel.cs @@ -12,7 +12,6 @@ public class ProfileViewModel : INotifyPropertyChanged { private User? _user; private bool _isLoading; - private string? _localImagePath; public ProfileViewModel() { @@ -67,53 +66,10 @@ public class ProfileViewModel : INotifyPropertyChanged { get { - // Сначала проверяем, есть ли Base64 изображение в User.Avatar - if (_user != null && !string.IsNullOrEmpty(_user.Avatar)) + // Используем DisplayAvatarSource из модели User (работает с файловым кэшем) + if (_user != null) { - try - { - // Проверяем, это Base64 строка? - if (_user.Avatar.StartsWith("data:image")) - { - // Извлекаем Base64 данные из строки "data:image/jpeg;base64,..." - string base64Data = _user.Avatar; - int commaIndex = base64Data.IndexOf(','); - if (commaIndex > 0) - { - base64Data = base64Data.Substring(commaIndex + 1); - byte[] imageBytes = Convert.FromBase64String(base64Data); - return ImageSource.FromStream(() => new MemoryStream(imageBytes)); - } - } - } - catch (Exception ex) - { - Trace.WriteLine($"[ProfileViewModel] ⚠️ Ошибка декодирования Base64 аватара: {ex.Message}"); - } - } - - // Если есть локальный файл кеша - if (!string.IsNullOrEmpty(_localImagePath) && File.Exists(_localImagePath)) - { - try - { - FileInfo fileInfo = new FileInfo(_localImagePath); - if (fileInfo.Length > 0) - { - return ImageSource.FromStream(() => - { - try - { - return new FileStream(_localImagePath, FileMode.Open, FileAccess.Read, FileShare.Read); - } - catch - { - return null; - } - }); - } - } - catch { } + return _user.DisplayAvatarSource; } // Default placeholder @@ -179,112 +135,23 @@ public class ProfileViewModel : INotifyPropertyChanged private async Task LoadAvatarAsync() { - // Если аватар уже в виде Base64 в User.Avatar, ничего не делаем - if (_user != null && !string.IsNullOrEmpty(_user.Avatar) && _user.Avatar.StartsWith("data:image")) + if (_user == null) { - Trace.WriteLine("[ProfileViewModel] ✅ Аватар уже загружен как Base64, пропускаем загрузку"); - return; - } - - if (_user == null || string.IsNullOrEmpty(_user.DisplayAvatarUrl)) - { - Trace.WriteLine("[ProfileViewModel] Нет URL аватара для загрузки"); + Trace.WriteLine("[ProfileViewModel] Нет пользователя для загрузки аватара"); return; } try { - string url = _user.DisplayAvatarUrl; - Trace.WriteLine($"[ProfileViewModel] Загрузка аватара с URL: {url}"); - - // Generate cache filename - безопасное создание Uri - Uri? uri = null; - try - { - uri = new Uri(url); - } - catch (UriFormatException uriEx) - { - Trace.WriteLine($"[ProfileViewModel] ⚠️ Некорректный URL аватара: {uriEx.Message}"); - return; - } - - string extension = Path.GetExtension(uri.LocalPath); - if (string.IsNullOrEmpty(extension)) extension = ".jpg"; - - string fileName = Path.GetFileNameWithoutExtension(uri.LocalPath); - if (string.IsNullOrEmpty(fileName)) fileName = $"avatar_{_user.Id}"; - - // Заменяем все недопустимые символы - fileName = Path.GetInvalidFileNameChars().Aggregate(fileName, (current, c) => current.Replace(c, '_')); - - string cacheFileName = $"user_{_user.Id}_{fileName}{extension}"; - string cacheFilePath = Path.Combine(FileSystem.CacheDirectory, cacheFileName); - - Trace.WriteLine($"[ProfileViewModel] Путь кеша: {cacheFilePath}"); - - // Check cache - if (File.Exists(cacheFilePath)) - { - FileInfo fileInfo = new FileInfo(cacheFilePath); - if (fileInfo.Length > 0) - { - Trace.WriteLine($"[ProfileViewModel] ✅ Аватар найден в кеше ({fileInfo.Length} bytes)"); - _localImagePath = cacheFilePath; - OnPropertyChanged(nameof(DisplayImageSource)); - return; - } - else - { - Trace.WriteLine($"[ProfileViewModel] ⚠️ Файл кеша пуст, удаляем"); - try { File.Delete(cacheFilePath); } catch { } - } - } - - // Download - Trace.WriteLine($"[ProfileViewModel] Загрузка аватара с сервера..."); - byte[]? bytes = await ServiceProvider.ApiService.GetImageBytesAsync(url); - - if (bytes is { Length: > 0 }) - { - Trace.WriteLine($"[ProfileViewModel] Получено {bytes.Length} bytes"); - - // Simple signature check - bool isImage = false; - if (bytes.Length > 2) - { - if (bytes[0] == 0xFF && bytes[1] == 0xD8) isImage = true; // JPEG - else if (bytes[0] == 0x89 && bytes[1] == 0x50) isImage = true; // PNG - else isImage = true; // Relaxed check - - if (bytes[0] == '<') isImage = false; // HTML check - } - - if (isImage) - { - Trace.WriteLine($"[ProfileViewModel] ✅ Данные похожи на изображение, сохраняем в кеш"); - await File.WriteAllBytesAsync(cacheFilePath, bytes); - _localImagePath = cacheFilePath; - OnPropertyChanged(nameof(DisplayImageSource)); - Trace.WriteLine($"[ProfileViewModel] ✅ Аватар сохранён и отображён"); - } - else - { - Trace.WriteLine($"[ProfileViewModel] ⚠️ Данные не похожи на изображение"); - } - } - else - { - Trace.WriteLine($"[ProfileViewModel] ⚠️ Не удалось загрузить данные аватара (пустой ответ)"); - } + // Используем централизованный метод загрузки аватара + await ServiceProvider.ApiService.LoadUserAvatarAsync(_user); + OnPropertyChanged(nameof(DisplayImageSource)); + Trace.WriteLine("[ProfileViewModel] ✅ Аватар загружен"); } catch (Exception ex) { - Trace.WriteLine($"[ProfileViewModel] ❌ Ошибка загрузки аватара:"); - Trace.WriteLine($" Type: {ex.GetType().Name}"); - Trace.WriteLine($" Message: {ex.Message}"); - Trace.WriteLine($" StackTrace: {ex.StackTrace}"); - // Не пробрасываем исключение дальше - ошибка загрузки аватара не критична + Trace.WriteLine($"[ProfileViewModel] ❌ Ошибка загрузки аватара: {ex.Message}"); + // Ошибка загрузки аватара не критична } }