From 388b440c2838472757ccc832cedd68b4e6850b6d Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Mon, 19 Jan 2026 10:40:40 +0300 Subject: [PATCH] Refactor user avatar handling: Implement Base64 avatar support, enhance image caching, and improve UI updates for avatar loading --- CORP-MAUI/App.xaml.cs | 2 +- CORP-MAUI/AppShell.xaml | 92 ++++++++++++++----- CORP-MAUI/AppShell.xaml.cs | 61 +++++++++++- CORP-MAUI/CORP-MAUI.csproj | 1 - CORP-MAUI/Models/User.cs | 70 ++++++++++++-- CORP-MAUI/Pages/DepartmentDetailsPage.xaml.cs | 32 +++---- CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs | 3 + CORP-MAUI/Pages/EmployeesPage.xaml.cs | 9 -- CORP-MAUI/Pages/LoginPage.xaml | 2 +- CORP-MAUI/Pages/PostsPage.xaml.cs | 20 ++-- CORP-MAUI/Pages/ProfilePage.xaml | 1 + CORP-MAUI/Pages/ProfilePage.xaml.cs | 16 +++- CORP-MAUI/Resources/AppIcon/appicon.svg | 2 +- CORP-MAUI/Resources/AppIcon/appiconfg.svg | 38 ++++++-- CORP-MAUI/Resources/Splash/Logo black.svg | 30 ++++++ .../Resources/Splash/Logo full black.svg | 78 ++++++++++++++++ .../Resources/Splash/Logo full white.svg | 78 ++++++++++++++++ .../Resources/Splash/Logo text black.svg | 54 +++++++++++ .../Resources/Splash/Logo text white.svg | 54 +++++++++++ CORP-MAUI/Resources/Splash/Logo white.svg | 30 ++++++ CORP-MAUI/Resources/Splash/splash.svg | 38 ++++++-- CORP-MAUI/Services/ApiService.cs | 59 +++++++++++- CORP-MAUI/Services/ImageCacheService.cs | 28 +++++- CORP-MAUI/ViewModels/ProfileViewModel.cs | 14 ++- 24 files changed, 712 insertions(+), 100 deletions(-) create mode 100644 CORP-MAUI/Resources/Splash/Logo black.svg create mode 100644 CORP-MAUI/Resources/Splash/Logo full black.svg create mode 100644 CORP-MAUI/Resources/Splash/Logo full white.svg create mode 100644 CORP-MAUI/Resources/Splash/Logo text black.svg create mode 100644 CORP-MAUI/Resources/Splash/Logo text white.svg create mode 100644 CORP-MAUI/Resources/Splash/Logo white.svg diff --git a/CORP-MAUI/App.xaml.cs b/CORP-MAUI/App.xaml.cs index 6179d15..2646859 100644 --- a/CORP-MAUI/App.xaml.cs +++ b/CORP-MAUI/App.xaml.cs @@ -1,4 +1,4 @@ -using CORP_MAUI.Pages; +using CORP_MAUI.Pages; using CORP_MAUI.Models; using CORP_MAUI.Services; using System.Diagnostics; diff --git a/CORP-MAUI/AppShell.xaml b/CORP-MAUI/AppShell.xaml index 088f691..4581b27 100644 --- a/CORP-MAUI/AppShell.xaml +++ b/CORP-MAUI/AppShell.xaml @@ -4,33 +4,79 @@ xmlns="http://schemas.microsoft.com/dotnet/2021/maui" xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml" xmlns:pages="clr-namespace:CORP_MAUI.Pages" + xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit" Shell.TabBarBackgroundColor="#0D6EFD" Shell.TabBarForegroundColor="White" Shell.TabBarTitleColor="White" Shell.TabBarUnselectedColor="#FFFFFF99" - Shell.NavBarIsVisible="False"> + FlyoutBackgroundColor="#0D6EFD" + FlyoutWidth="220" + Shell.NavBarIsVisible="False" + x:Name="MainShell" + FlyoutBehavior="Flyout"> + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/CORP-MAUI/AppShell.xaml.cs b/CORP-MAUI/AppShell.xaml.cs index fb24165..b02da94 100644 --- a/CORP-MAUI/AppShell.xaml.cs +++ b/CORP-MAUI/AppShell.xaml.cs @@ -5,6 +5,9 @@ namespace CORP_MAUI; public partial class AppShell : Shell { + // Порог ширины для переключения на вертикальное меню (в пикселях) + private const double WideScreenThreshold = 768; + public AppShell() { try @@ -25,6 +28,9 @@ public partial class AppShell : Shell Trace.WriteLine("[AppShell] ✅ AppShell инициализирован"); Trace.WriteLine("=== APPSHELL CONSTRUCTOR END ==="); + // Устанавливаем начальный режим меню + UpdateFlyoutBehavior(); + // Инициализация без ожидания (так await нельзя из синхронного конструктора) _ = InitializeAsync(); } @@ -46,7 +52,7 @@ public partial class AppShell : Shell await Task.Delay(100); await MainThread.InvokeOnMainThreadAsync(async () => { - Page? mainPage = Application.Current?.Windows[0]?.Page; + Page? mainPage = Application.Current?.Windows.FirstOrDefault()?.Page; if (mainPage != null) { await mainPage.DisplayAlert("Ошибка AppShell", @@ -106,6 +112,55 @@ public partial class AppShell : Shell await DisplayAlert("Ошибка", $"Ошибка инициализации: {ex.Message}", "OK"); } } + + protected override void OnSizeAllocated(double width, double height) + { + base.OnSizeAllocated(width, height); + UpdateFlyoutBehavior(width); + } + + /// + /// Обновляет режим меню в зависимости от ширины экрана + /// + private void UpdateFlyoutBehavior(double? width = null) + { + try + { + // Получаем текущую ширину окна + double currentWidth = width ?? (Application.Current?.Windows.FirstOrDefault()?.Width ?? 0); + + if (currentWidth <= 0) + { + // Если ширина не определена, используем DeviceDisplay + currentWidth = DeviceDisplay.MainDisplayInfo.Width / DeviceDisplay.MainDisplayInfo.Density; + } + + Trace.WriteLine($"[AppShell] UpdateFlyoutBehavior: width={currentWidth}, threshold={WideScreenThreshold}"); + + if (currentWidth >= WideScreenThreshold) + { + // Широкий экран - Flyout всегда открыт (Locked) + if (FlyoutBehavior != FlyoutBehavior.Locked) + { + FlyoutBehavior = FlyoutBehavior.Locked; + FlyoutIsPresented = true; + Trace.WriteLine("[AppShell] ✅ Режим: Locked (вертикальное меню слева)"); + } + } + else + { + // Узкий экран - Flyout выдвигается по кнопке/свайпу + if (FlyoutBehavior != FlyoutBehavior.Flyout) + { + FlyoutBehavior = FlyoutBehavior.Flyout; + FlyoutIsPresented = false; + Trace.WriteLine("[AppShell] ✅ Режим: Flyout (выдвижное меню)"); + } + } + } + catch (Exception ex) + { + Trace.WriteLine($"[AppShell] ⚠️ Ошибка UpdateFlyoutBehavior: {ex.Message}"); + } + } } - - diff --git a/CORP-MAUI/CORP-MAUI.csproj b/CORP-MAUI/CORP-MAUI.csproj index e8e6dda..789c68e 100644 --- a/CORP-MAUI/CORP-MAUI.csproj +++ b/CORP-MAUI/CORP-MAUI.csproj @@ -53,7 +53,6 @@ - diff --git a/CORP-MAUI/Models/User.cs b/CORP-MAUI/Models/User.cs index 2077725..34fa239 100644 --- a/CORP-MAUI/Models/User.cs +++ b/CORP-MAUI/Models/User.cs @@ -1,11 +1,13 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Diagnostics; +using System.ComponentModel; +using System.Runtime.CompilerServices; using Microsoft.Maui.Controls; namespace CORP_MAUI.Models; -public class User +public class User : INotifyPropertyChanged { public int Id { get; set; } public string Username { get; set; } = string.Empty; @@ -40,10 +42,47 @@ public class User [JsonPropertyName("avatar")] public string? Avatar { get; set; } + // INotifyPropertyChanged implementation + public event PropertyChangedEventHandler? PropertyChanged; + + protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + // Вспомогательные свойства для удобства [JsonIgnore] public string FullName => $"{FirstName} {LastName}".Trim(); + /// + /// Проверяет, является ли аватар Base64 строкой + /// + [JsonIgnore] + public bool IsAvatarBase64 => !string.IsNullOrEmpty(Avatar) && Avatar.StartsWith("data:image"); + + /// + /// Получить байты изображения из Base64 строки + /// + public byte[]? GetAvatarBytesFromBase64() + { + if (!IsAvatarBase64) return null; + + try + { + // Формат: data:image/jpeg;base64, + int commaIndex = Avatar!.IndexOf(','); + if (commaIndex < 0) return null; + + string base64Data = Avatar.Substring(commaIndex + 1); + return Convert.FromBase64String(base64Data); + } + catch (Exception ex) + { + Trace.WriteLine($"[User] Ошибка декодирования Base64 аватара: {ex.Message}"); + return null; + } + } + [JsonIgnore] public string? DisplayAvatarUrl { @@ -52,7 +91,7 @@ public class User // Сначала проверяем Avatar (может содержать полный URL или data:image) if (!string.IsNullOrEmpty(Avatar)) { - // Если это data:image - не возвращаем как URL + // Если это data:image - не возвращаем как URL (будет обработано отдельно) if (Avatar.StartsWith("data:image")) return null; @@ -105,9 +144,20 @@ public class User /// public void SetCachedAvatarFilePath(string filePath) { + Trace.WriteLine($"[User.SetCachedAvatarFilePath] {FullName}: Setting path to {filePath}"); + CachedAvatarFilePath = filePath; _cachedImageSource = ImageSource.FromFile(filePath); IsAvatarLoaded = true; + + Trace.WriteLine($"[User.SetCachedAvatarFilePath] {FullName}: ImageSource created, notifying UI..."); + + // Уведомляем UI об изменении аватара + MainThread.BeginInvokeOnMainThread(() => + { + Trace.WriteLine($"[User.SetCachedAvatarFilePath] {FullName}: OnPropertyChanged(DisplayAvatarSource)"); + OnPropertyChanged(nameof(DisplayAvatarSource)); + }); } /// @@ -118,6 +168,7 @@ public class User _cachedImageSource = null; IsAvatarLoaded = false; CachedAvatarFilePath = null; + OnPropertyChanged(nameof(DisplayAvatarSource)); } /// @@ -128,15 +179,19 @@ public class User { get { + Trace.WriteLine($"[User.DisplayAvatarSource] {FullName}: IsAvatarLoaded={IsAvatarLoaded}, CachedPath={CachedAvatarFilePath}"); + // Если аватар уже загружен - возвращаем кэшированный if (IsAvatarLoaded && _cachedImageSource != null) { + Trace.WriteLine($"[User.DisplayAvatarSource] {FullName}: Returning cached ImageSource"); return _cachedImageSource; } // Проверяем путь к файлу if (!string.IsNullOrEmpty(CachedAvatarFilePath) && File.Exists(CachedAvatarFilePath)) { + Trace.WriteLine($"[User.DisplayAvatarSource] {FullName}: Loading from file: {CachedAvatarFilePath}"); _cachedImageSource = ImageSource.FromFile(CachedAvatarFilePath); IsAvatarLoaded = true; return _cachedImageSource; @@ -151,19 +206,16 @@ public class User string? cachedPath = Services.ImageCacheService.Instance.GetCachedFilePath(url); if (!string.IsNullOrEmpty(cachedPath) && File.Exists(cachedPath)) { + Trace.WriteLine($"[User.DisplayAvatarSource] {FullName}: Found in global cache: {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) - }; + // Если нет в кэше - возвращаем placeholder + // Аватар будет загружен асинхронно через LoadUserAvatarAsync + Trace.WriteLine($"[User.DisplayAvatarSource] {FullName}: Not in cache, returning placeholder"); } } catch (Exception ex) diff --git a/CORP-MAUI/Pages/DepartmentDetailsPage.xaml.cs b/CORP-MAUI/Pages/DepartmentDetailsPage.xaml.cs index 3b6e4a5..b3a35ea 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; @@ -11,17 +11,17 @@ public partial class DepartmentDetailsPage : ContentPage { try { - Trace.WriteLine($"=== DEPARTMENTDETAILSPAGE CONSTRUCTOR START (ID: {departmentId}) ==="); + Trace.WriteLine($"=== DEPARTMENT DETAILS PAGE CONSTRUCTOR START (ID: {departmentId}) ==="); InitializeComponent(); _departmentId = departmentId; _teamMembers = new ObservableCollection(); TeamCollectionView.ItemsSource = _teamMembers; _ = LoadDepartmentDataAsync(); - Trace.WriteLine("=== DEPARTMENTDETAILSPAGE CONSTRUCTOR END ==="); + Trace.WriteLine("=== DEPARTMENT DETAILS PAGE CONSTRUCTOR END ==="); } catch (Exception ex) { - Trace.WriteLine("=== DEPARTMENTDETAILSPAGE CONSTRUCTOR ERROR ==="); + Trace.WriteLine("=== DEPARTMENT DETAILS PAGE CONSTRUCTOR ERROR ==="); Trace.WriteLine($"[DepartmentDetailsPage] ❌ Тип ошибки: {ex.GetType().FullName}"); Trace.WriteLine($"[DepartmentDetailsPage] ❌ Сообщение: {ex.Message}"); Trace.WriteLine($"[DepartmentDetailsPage] ❌ StackTrace: {ex.StackTrace}"); @@ -75,14 +75,20 @@ public partial class DepartmentDetailsPage : ContentPage HeadPositionLabel.Text = _department.Head.PositionName ?? "Должность не указана"; // Загружаем аватар руководителя + Trace.WriteLine($"[DepartmentDetailsPage] >>> Загрузка аватара руководителя..."); await ServiceProvider.ApiService.LoadUserAvatarAsync(_department.Head); - HeadAvatarImage.Source = _department.Head.DisplayAvatarSource; + Trace.WriteLine($"[DepartmentDetailsPage] >>> IsAvatarLoaded: {_department.Head.IsAvatarLoaded}"); + Trace.WriteLine($"[DepartmentDetailsPage] >>> CachedAvatarFilePath: {_department.Head.CachedAvatarFilePath}"); + + var avatarSource = _department.Head.DisplayAvatarSource; + Trace.WriteLine($"[DepartmentDetailsPage] >>> AvatarSource type: {avatarSource?.GetType().Name ?? "null"}"); + HeadAvatarImage.Source = avatarSource; } Trace.WriteLine($"[DepartmentDetailsPage] >>> Вызов GetDepartmentEmployeesAsync({_departmentId})"); var employees = await ServiceProvider.ApiService.GetDepartmentEmployeesAsync(_departmentId); Trace.WriteLine($"[DepartmentDetailsPage] >>> Получено сотрудников: {employees.Count}"); _teamMembers.Clear(); - if (_department.Head != null && !employees.Any(e => e.Id == _department.Head.Id)) + if (_department.Head != null && employees.All(e => e.Id != _department.Head.Id)) { _teamMembers.Add(_department.Head); Trace.WriteLine($"[DepartmentDetailsPage] >>> Руководитель добавлен в команду"); @@ -172,21 +178,9 @@ public partial class DepartmentDetailsPage : ContentPage await ServiceProvider.ApiService.LoadUserAvatarAsync(_department.Head); } - // Загружаем аватары команды + // Загружаем аватары команды - UI обновится автоматически через INotifyPropertyChanged 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) diff --git a/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs b/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs index 548ebc4..0fc63cf 100644 --- a/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs +++ b/CORP-MAUI/Pages/EmployeeProfilePage.xaml.cs @@ -82,12 +82,15 @@ public partial class EmployeeProfilePage : ContentPage Trace.WriteLine($"[EmployeeProfilePage] ✅ Профиль загружен: {_user.FullName}"); // Предзагружаем аватар пользователя + Trace.WriteLine($"[EmployeeProfilePage] >>> Загрузка аватара, URL: {_user.DisplayAvatarUrl}"); await ServiceProvider.ApiService.LoadUserAvatarAsync(_user); + Trace.WriteLine($"[EmployeeProfilePage] IsAvatarLoaded: {_user.IsAvatarLoaded}, CachedPath: {_user.CachedAvatarFilePath}"); // Заполняем основную информацию FullNameLabel.Text = _user.FullName; PositionLabel.Text = _user.PositionName ?? "Должность не указана"; AvatarImage.Source = _user.DisplayAvatarSource; + Trace.WriteLine($"[EmployeeProfilePage] ✅ Аватар установлен"); OnlineIndicator.IsVisible = _user.IsOnline; // Email diff --git a/CORP-MAUI/Pages/EmployeesPage.xaml.cs b/CORP-MAUI/Pages/EmployeesPage.xaml.cs index 291a924..28cf7e7 100644 --- a/CORP-MAUI/Pages/EmployeesPage.xaml.cs +++ b/CORP-MAUI/Pages/EmployeesPage.xaml.cs @@ -208,15 +208,6 @@ public partial class EmployeesPage : ContentPage { 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) diff --git a/CORP-MAUI/Pages/LoginPage.xaml b/CORP-MAUI/Pages/LoginPage.xaml index 2f14349..abac6a2 100644 --- a/CORP-MAUI/Pages/LoginPage.xaml +++ b/CORP-MAUI/Pages/LoginPage.xaml @@ -21,7 +21,7 @@ public async Task GetOrLoadImageFileAsync(string? url, Func>? loader = null) { - if (string.IsNullOrEmpty(url)) return null; + if (string.IsNullOrEmpty(url)) + { + Trace.WriteLine($"[ImageCacheService] URL пустой, пропускаем"); + return null; + } + + Trace.WriteLine($"[ImageCacheService] >>> GetOrLoadImageFileAsync: {url}"); // 1. Проверяем кэш путей if (_pathCache.TryGetValue(url, out string? cachedPath) && File.Exists(cachedPath)) { + Trace.WriteLine($"[ImageCacheService] ✅ Найден в кэше путей: {cachedPath}"); return cachedPath; } // 2. Проверяем файл на диске string filePath = GetCacheFilePath(url); + Trace.WriteLine($"[ImageCacheService] Ожидаемый путь: {filePath}"); + if (File.Exists(filePath)) { var fileInfo = new FileInfo(filePath); + Trace.WriteLine($"[ImageCacheService] Файл существует, размер: {fileInfo.Length}, время: {fileInfo.LastWriteTime}"); + if (fileInfo.Length > 0 && fileInfo.LastWriteTime > DateTime.Now - _cacheExpiry) { _pathCache[url] = filePath; + Trace.WriteLine($"[ImageCacheService] ✅ Загружен с диска: {filePath}"); return filePath; } try { File.Delete(filePath); } catch { } @@ -64,19 +76,31 @@ public class ImageCacheService { try { + Trace.WriteLine($"[ImageCacheService] >>> Загрузка изображения через loader..."); byte[]? imageBytes = await loader(url); + if (imageBytes != null && imageBytes.Length > 0) { + Trace.WriteLine($"[ImageCacheService] Получено {imageBytes.Length} байт, сохраняем в {filePath}"); await File.WriteAllBytesAsync(filePath, imageBytes); _pathCache[url] = filePath; + Trace.WriteLine($"[ImageCacheService] ✅ Сохранено и добавлено в кэш"); return filePath; } + else + { + Trace.WriteLine($"[ImageCacheService] ⚠️ Loader вернул пустые данные"); + } } catch (Exception ex) { - Trace.WriteLine($"[ImageCacheService] Ошибка загрузки {url}: {ex.Message}"); + Trace.WriteLine($"[ImageCacheService] ❌ Ошибка загрузки {url}: {ex.Message}"); } } + else + { + Trace.WriteLine($"[ImageCacheService] ⚠️ Loader не задан"); + } return null; } diff --git a/CORP-MAUI/ViewModels/ProfileViewModel.cs b/CORP-MAUI/ViewModels/ProfileViewModel.cs index f767c19..893921c 100644 --- a/CORP-MAUI/ViewModels/ProfileViewModel.cs +++ b/CORP-MAUI/ViewModels/ProfileViewModel.cs @@ -143,10 +143,20 @@ public class ProfileViewModel : INotifyPropertyChanged try { + Trace.WriteLine($"[ProfileViewModel] >>> Загрузка аватара для {_user.FullName}, URL: {_user.DisplayAvatarUrl}"); + // Используем централизованный метод загрузки аватара await ServiceProvider.ApiService.LoadUserAvatarAsync(_user); - OnPropertyChanged(nameof(DisplayImageSource)); - Trace.WriteLine("[ProfileViewModel] ✅ Аватар загружен"); + + Trace.WriteLine($"[ProfileViewModel] IsAvatarLoaded: {_user.IsAvatarLoaded}, CachedPath: {_user.CachedAvatarFilePath}"); + + // Обновляем UI на главном потоке + await MainThread.InvokeOnMainThreadAsync(() => + { + OnPropertyChanged(nameof(DisplayImageSource)); + }); + + Trace.WriteLine("[ProfileViewModel] ✅ Аватар загружен и UI обновлен"); } catch (Exception ex) {