mirror of
https://github.com/claunia/marechai.git
synced 2026-07-08 17:57:08 +00:00
MarkdownTextBlock crashed with a NullReferenceException in ThemeListener under Uno, and the WebView2-based fallback used by description pages doesn't composite on Linux desktop. Render markdown directly to XAML elements (TextBlock/Span/Hyperlink/Image trees) via Markdig's AST instead, shared by MessageMarkdownView and all entity description pages, with no embedded browser dependency on any platform.
586 lines
20 KiB
C#
586 lines
20 KiB
C#
#nullable enable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Collections.ObjectModel;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using System.Windows.Input;
|
|
using Marechai.App.Navigation;
|
|
using Marechai.App.Presentation.Models;
|
|
using Marechai.App.Presentation.Views;
|
|
using Marechai.App.Services;
|
|
using Marechai.App.Services.Caching;
|
|
using Microsoft.UI.Xaml;
|
|
using Windows.System;
|
|
|
|
namespace Marechai.App.Presentation.ViewModels;
|
|
|
|
public partial class GpuDetailViewModel : ObservableObject, IRegionAware
|
|
{
|
|
private readonly CompaniesService _companiesService;
|
|
private readonly GpuPhotoCache _gpuPhotoCache;
|
|
private readonly GpusService _gpusService;
|
|
private readonly ImageSourceFactory _imageSourceFactory;
|
|
private readonly IStringLocalizer _localizer;
|
|
private readonly ILogger<GpuDetailViewModel> _logger;
|
|
private readonly IRegionManager _regionManager;
|
|
|
|
[ObservableProperty]
|
|
private ObservableCollection<MachineItem> _computers = [];
|
|
|
|
[ObservableProperty]
|
|
private string _computersFilterText = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private ObservableCollection<MachineItem> _consoles = [];
|
|
|
|
[ObservableProperty]
|
|
private string _consolesFilterText = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string _smartphonesFilterText = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private ObservableCollection<MachineItem> _smartphones = [];
|
|
|
|
[ObservableProperty]
|
|
private string _errorMessage = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private ObservableCollection<MachineItem> _filteredComputers = [];
|
|
|
|
[ObservableProperty]
|
|
private ObservableCollection<MachineItem> _filteredConsoles = [];
|
|
|
|
[ObservableProperty]
|
|
private ObservableCollection<MachineItem> _filteredSmartphones = [];
|
|
|
|
[ObservableProperty]
|
|
private GpuDto? _gpu;
|
|
|
|
[ObservableProperty]
|
|
private int _gpuId;
|
|
|
|
[ObservableProperty]
|
|
private string _displayName = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private bool _hasComputers;
|
|
|
|
[ObservableProperty]
|
|
private bool _hasConsoles;
|
|
|
|
[ObservableProperty]
|
|
private bool _hasSmartphones;
|
|
|
|
[ObservableProperty]
|
|
private string _descriptionMarkdown = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private Visibility _showDescription = Visibility.Collapsed;
|
|
|
|
[ObservableProperty]
|
|
private Visibility _showPhotos = Visibility.Collapsed;
|
|
|
|
[ObservableProperty]
|
|
private Visibility _showSpecifications = Visibility.Visible;
|
|
|
|
[ObservableProperty]
|
|
private Visibility _showVideos = Visibility.Collapsed;
|
|
|
|
public ObservableCollection<PhotoCarouselDisplayItem> Photos { get; } = [];
|
|
public ObservableCollection<MachineVideoDisplayItem> Videos { get; } = [];
|
|
|
|
/// <summary>
|
|
/// Gets whether a description is available
|
|
/// </summary>
|
|
public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionMarkdown);
|
|
|
|
partial void OnDescriptionMarkdownChanged(string value)
|
|
{
|
|
OnPropertyChanged(nameof(HasDescription));
|
|
}
|
|
|
|
[ObservableProperty]
|
|
private bool _hasError;
|
|
|
|
[ObservableProperty]
|
|
private bool _isDataLoaded;
|
|
|
|
[ObservableProperty]
|
|
private bool _isLoading;
|
|
|
|
[ObservableProperty]
|
|
private string _manufacturerName = string.Empty;
|
|
private object? _navigationSource;
|
|
|
|
[ObservableProperty]
|
|
private ObservableCollection<ResolutionItem> _resolutions = [];
|
|
|
|
public GpuDetailViewModel(GpusService gpusService, CompaniesService companiesService, IStringLocalizer localizer,
|
|
ILogger<GpuDetailViewModel> logger, IRegionManager regionManager,
|
|
GpuPhotoCache gpuPhotoCache, ImageSourceFactory imageSourceFactory)
|
|
{
|
|
_gpusService = gpusService;
|
|
_companiesService = companiesService;
|
|
_localizer = localizer;
|
|
_logger = logger;
|
|
_regionManager = regionManager;
|
|
_gpuPhotoCache = gpuPhotoCache;
|
|
_imageSourceFactory = imageSourceFactory;
|
|
LoadData = new AsyncRelayCommand(LoadDataAsync);
|
|
GoBackCommand = new AsyncRelayCommand(GoBackAsync);
|
|
SelectMachineCommand = new AsyncRelayCommand<int>(SelectMachineAsync);
|
|
SelectPhotoCommand = new AsyncRelayCommand<Guid>(SelectPhotoAsync);
|
|
OpenVideoCommand = new AsyncRelayCommand<MachineVideoDisplayItem>(OpenVideoAsync);
|
|
ComputersFilterCommand = new RelayCommand(() => FilterComputers());
|
|
ConsolesFilterCommand = new RelayCommand(() => FilterConsoles());
|
|
SmartphonesFilterCommand = new RelayCommand(() => FilterSmartphones());
|
|
Title = _localizer["GPU Details"];
|
|
}
|
|
|
|
public IAsyncRelayCommand LoadData { get; }
|
|
public ICommand GoBackCommand { get; }
|
|
public IAsyncRelayCommand SelectMachineCommand { get; }
|
|
public IAsyncRelayCommand SelectPhotoCommand { get; }
|
|
public IAsyncRelayCommand OpenVideoCommand { get; }
|
|
public ICommand ComputersFilterCommand { get; }
|
|
public ICommand ConsolesFilterCommand { get; }
|
|
public ICommand SmartphonesFilterCommand { get; }
|
|
|
|
public string Title { get; }
|
|
|
|
/// <summary>
|
|
/// Loads GPU details including resolutions, computers, and consoles
|
|
/// </summary>
|
|
private async Task LoadDataAsync()
|
|
{
|
|
try
|
|
{
|
|
IsLoading = true;
|
|
ErrorMessage = string.Empty;
|
|
HasError = false;
|
|
IsDataLoaded = false;
|
|
Resolutions.Clear();
|
|
Computers.Clear();
|
|
Consoles.Clear();
|
|
Smartphones.Clear();
|
|
FilteredComputers.Clear();
|
|
FilteredConsoles.Clear();
|
|
FilteredSmartphones.Clear();
|
|
Photos.Clear();
|
|
Videos.Clear();
|
|
ShowPhotos = Visibility.Collapsed;
|
|
ShowVideos = Visibility.Collapsed;
|
|
ShowDescription = Visibility.Collapsed;
|
|
ShowSpecifications = Visibility.Visible;
|
|
DisplayName = string.Empty;
|
|
|
|
if(GpuId == 0)
|
|
{
|
|
ErrorMessage = _localizer["Invalid GPU ID"].Value;
|
|
HasError = true;
|
|
|
|
return;
|
|
}
|
|
|
|
_logger.LogInformation("Loading GPU details for ID: {GpuId}", GpuId);
|
|
|
|
// Load GPU details
|
|
Gpu = await _gpusService.GetGpuByIdAsync(GpuId);
|
|
|
|
if(Gpu is null)
|
|
{
|
|
ErrorMessage = _localizer["Graphics processing unit not found"].Value;
|
|
HasError = true;
|
|
|
|
return;
|
|
}
|
|
|
|
// Set manufacturer name (from Company field or fetch by CompanyId if empty)
|
|
ManufacturerName = Gpu.Company ?? string.Empty;
|
|
|
|
if(string.IsNullOrEmpty(ManufacturerName) && Gpu.CompanyId.HasValue)
|
|
{
|
|
try
|
|
{
|
|
CompanyDto? company = await _companiesService.GetCompanyByIdAsync(Gpu.CompanyId.Value);
|
|
if(company != null) ManufacturerName = company.Name ?? string.Empty;
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to load company for GPU {GpuId}", GpuId);
|
|
}
|
|
}
|
|
|
|
// Format display name
|
|
string displayName = Gpu.Name ?? string.Empty;
|
|
|
|
if(displayName == "DB_FRAMEBUFFER")
|
|
displayName = _localizer["Framebuffer"];
|
|
else if(displayName == "DB_SOFTWARE")
|
|
displayName = _localizer["Software"];
|
|
else if(displayName == "DB_NONE") displayName = _localizer["None_female"];
|
|
|
|
DisplayName = displayName;
|
|
ShowSpecifications = IsSentinelGpu(Gpu) ? Visibility.Collapsed : Visibility.Visible;
|
|
|
|
_logger.LogInformation("GPU loaded: {Name}, Company: {Company}", displayName, ManufacturerName);
|
|
|
|
// Load resolutions
|
|
try
|
|
{
|
|
List<ResolutionByGpuDto>? resolutions = await _gpusService.GetResolutionsByGpuAsync(GpuId);
|
|
|
|
if(resolutions != null && resolutions.Count > 0)
|
|
{
|
|
Resolutions.Clear();
|
|
|
|
foreach(ResolutionByGpuDto res in resolutions)
|
|
{
|
|
// Get the full resolution DTO using the resolution ID
|
|
if(res.ResolutionId.HasValue)
|
|
{
|
|
ResolutionDto? resolutionDto =
|
|
await _gpusService.GetResolutionByIdAsync(res.ResolutionId.Value);
|
|
|
|
if(resolutionDto != null)
|
|
{
|
|
Resolutions.Add(new ResolutionItem
|
|
{
|
|
Id = resolutionDto.Id ?? 0,
|
|
Name = $"{resolutionDto.Width}x{resolutionDto.Height}",
|
|
Width = resolutionDto.Width ?? 0,
|
|
Height = resolutionDto.Height ?? 0,
|
|
Colors = resolutionDto.Colors ?? 0,
|
|
Palette = resolutionDto.Palette ?? 0,
|
|
Chars = resolutionDto.Chars ?? false,
|
|
Grayscale = resolutionDto.Grayscale ?? false
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Loaded {Count} resolutions for GPU {GpuId}", Resolutions.Count, GpuId);
|
|
}
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to load resolutions for GPU {GpuId}", GpuId);
|
|
}
|
|
|
|
// Load machines and separate into computers and consoles
|
|
try
|
|
{
|
|
List<MachineDto>? machines = await _gpusService.GetMachinesByGpuAsync(GpuId);
|
|
|
|
if(machines != null && machines.Count > 0)
|
|
{
|
|
Computers.Clear();
|
|
Consoles.Clear();
|
|
Smartphones.Clear(); foreach(MachineDto machine in machines)
|
|
{
|
|
var machineItem = new MachineItem
|
|
{
|
|
Id = machine.Id ?? 0,
|
|
Name = machine.Name ?? string.Empty,
|
|
Manufacturer = machine.Company ?? string.Empty,
|
|
Year = machine.Introduced?.Year ?? 0
|
|
};
|
|
|
|
// Distinguish between computers and consoles based on Type
|
|
if(machine.Type == 2) // MachineType.Console
|
|
Consoles.Add(machineItem);
|
|
else if(machine.Type == 3) // MachineType.Smartphone
|
|
Smartphones.Add(machineItem);
|
|
else // MachineType.Computer or Unknown
|
|
Computers.Add(machineItem);
|
|
}
|
|
|
|
HasComputers = Computers.Count > 0;
|
|
HasConsoles = Consoles.Count > 0;
|
|
HasSmartphones = Smartphones.Count > 0;
|
|
|
|
// Initialize filtered collections
|
|
FilterComputers();
|
|
FilterConsoles();
|
|
FilterSmartphones();
|
|
}
|
|
else
|
|
{
|
|
HasComputers = false;
|
|
HasConsoles = false;
|
|
HasSmartphones = false;
|
|
}
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to load machines for GPU {GpuId}", GpuId);
|
|
HasComputers = false;
|
|
HasConsoles = false;
|
|
HasSmartphones = false;
|
|
}
|
|
|
|
// Load localized description
|
|
try
|
|
{
|
|
string langCode = GetIso639CodeFromCulture();
|
|
GpuDescriptionDto? desc = await _gpusService.GetDescriptionAsync(GpuId, langCode);
|
|
|
|
DescriptionMarkdown = desc?.Markdown ?? string.Empty;
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
_logger.LogError("Failed to load GPU description: {Exception}", ex.Message);
|
|
}
|
|
|
|
ShowDescription = HasDescription ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
// Load photos
|
|
Photos.Clear();
|
|
List<Guid> photoIds = await _gpusService.GetGpuPhotosAsync(GpuId);
|
|
|
|
if(photoIds.Count > 0)
|
|
{
|
|
foreach(Guid photoId in photoIds)
|
|
{
|
|
var photoItem = new PhotoCarouselDisplayItem
|
|
{
|
|
PhotoId = photoId
|
|
};
|
|
|
|
_ = LoadPhotoThumbnailAsync(photoItem);
|
|
|
|
Photos.Add(photoItem);
|
|
}
|
|
}
|
|
|
|
ShowPhotos = Photos.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
// Load videos
|
|
List<GpuVideoDto> videoList = await _gpusService.GetVideosByGpuAsync(GpuId);
|
|
|
|
foreach(GpuVideoDto video in videoList)
|
|
{
|
|
if(string.IsNullOrWhiteSpace(video.VideoId)) continue;
|
|
|
|
string provider = string.IsNullOrWhiteSpace(video.Provider)
|
|
? _localizer["Video"]
|
|
: video.Provider;
|
|
|
|
bool isYouTube = string.Equals(provider, "YouTube", StringComparison.OrdinalIgnoreCase);
|
|
|
|
Videos.Add(new MachineVideoDisplayItem
|
|
{
|
|
Title = string.IsNullOrWhiteSpace(video.Title)
|
|
? _localizer["Video"]
|
|
: video.Title,
|
|
Provider = provider,
|
|
VideoId = video.VideoId,
|
|
ThumbnailUrl = isYouTube
|
|
? $"https://img.youtube.com/vi/{video.VideoId}/hqdefault.jpg"
|
|
: null,
|
|
LaunchUri = new Uri($"https://www.youtube.com/watch?v={Uri.EscapeDataString(video.VideoId)}")
|
|
});
|
|
}
|
|
|
|
ShowVideos = Videos.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
|
|
|
IsDataLoaded = true;
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading GPU details: {Exception}", ex.Message);
|
|
ErrorMessage = _localizer["Failed to load graphics processing unit details. Please try again later."].Value;
|
|
HasError = true;
|
|
}
|
|
finally
|
|
{
|
|
IsLoading = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Filters computers based on search text
|
|
/// </summary>
|
|
private void FilterComputers()
|
|
{
|
|
if(string.IsNullOrWhiteSpace(ComputersFilterText))
|
|
{
|
|
FilteredComputers.Clear();
|
|
foreach(MachineItem computer in Computers) FilteredComputers.Add(computer);
|
|
}
|
|
else
|
|
{
|
|
var filtered = Computers
|
|
.Where(c => c.Name.Contains(ComputersFilterText, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
FilteredComputers.Clear();
|
|
foreach(MachineItem computer in filtered) FilteredComputers.Add(computer);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Filters consoles based on search text
|
|
/// </summary>
|
|
private void FilterConsoles()
|
|
{
|
|
if(string.IsNullOrWhiteSpace(ConsolesFilterText))
|
|
{
|
|
FilteredConsoles.Clear();
|
|
foreach(MachineItem console in Consoles) FilteredConsoles.Add(console);
|
|
}
|
|
else
|
|
{
|
|
var filtered = Consoles.Where(c => c.Name.Contains(ConsolesFilterText, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
FilteredConsoles.Clear();
|
|
foreach(MachineItem console in filtered) FilteredConsoles.Add(console);
|
|
}
|
|
}
|
|
|
|
private void FilterSmartphones()
|
|
{
|
|
if(string.IsNullOrWhiteSpace(SmartphonesFilterText))
|
|
{
|
|
FilteredSmartphones.Clear();
|
|
foreach(MachineItem smartphone in Smartphones) FilteredSmartphones.Add(smartphone);
|
|
}
|
|
else
|
|
{
|
|
var filtered = Smartphones.Where(c => c.Name.Contains(SmartphonesFilterText, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
|
|
FilteredSmartphones.Clear();
|
|
foreach(MachineItem smartphone in filtered) FilteredSmartphones.Add(smartphone);
|
|
}
|
|
}
|
|
|
|
private async Task LoadPhotoThumbnailAsync(PhotoCarouselDisplayItem photoItem)
|
|
{
|
|
try
|
|
{
|
|
Stream stream = await _gpuPhotoCache.GetThumbnailAsync(photoItem.PhotoId);
|
|
|
|
photoItem.ThumbnailImageSource = await _imageSourceFactory.CreateBitmapImageSourceAsync(stream);
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error loading GPU photo thumbnail {PhotoId}", photoItem.PhotoId);
|
|
}
|
|
}
|
|
|
|
static bool IsSentinelGpu(GpuDto? gpu) =>
|
|
gpu?.Name is "DB_FRAMEBUFFER" or "DB_SOFTWARE" or "DB_NONE";
|
|
|
|
/// <summary>
|
|
/// Navigates back to the GPU list
|
|
/// </summary>
|
|
private Task GoBackAsync()
|
|
{
|
|
if(_regionManager.TryGoBack(RegionNames.Content))
|
|
return Task.CompletedTask;
|
|
|
|
_regionManager.RequestNavigate(RegionNames.Content, nameof(GpuListPage));
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Navigates to machine detail view
|
|
/// </summary>
|
|
private Task SelectMachineAsync(int machineId)
|
|
{
|
|
if(machineId <= 0) return Task.CompletedTask;
|
|
|
|
var parameters = new NavigationParameters
|
|
{
|
|
{ NavParamKeys.MachineId, machineId },
|
|
{ NavParamKeys.NavigationSource, nameof(GpuDetailViewModel) },
|
|
{ NavParamKeys.GpuId, GpuId }
|
|
};
|
|
|
|
_regionManager.RequestNavigate(RegionNames.Content, nameof(MachineViewPage), parameters);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Navigates to the full photo detail view
|
|
/// </summary>
|
|
private Task SelectPhotoAsync(Guid photoId)
|
|
{
|
|
if(photoId == Guid.Empty) return Task.CompletedTask;
|
|
|
|
var parameters = new NavigationParameters
|
|
{
|
|
{ NavParamKeys.PhotoId, photoId },
|
|
{ NavParamKeys.NavigationSource, nameof(GpuDetailViewModel) }
|
|
};
|
|
|
|
_regionManager.RequestNavigate(RegionNames.Content, nameof(GpuPhotoDetailPage), parameters);
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
private async Task OpenVideoAsync(MachineVideoDisplayItem? video)
|
|
{
|
|
if(video?.LaunchUri is null) return;
|
|
|
|
try
|
|
{
|
|
await Launcher.LaunchUriAsync(video.LaunchUri);
|
|
}
|
|
catch(Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error launching video for GPU {GpuId}", GpuId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sets the navigation source (where we came from).
|
|
/// </summary>
|
|
public void SetNavigationSource(string? source)
|
|
{
|
|
_navigationSource = source;
|
|
}
|
|
|
|
public bool IsNavigationTarget(NavigationContext navigationContext) => false;
|
|
|
|
public void OnNavigatedFrom(NavigationContext navigationContext) { }
|
|
|
|
public void OnNavigatedTo(NavigationContext navigationContext)
|
|
{
|
|
if(navigationContext.Parameters.TryGetValue<int>(NavParamKeys.GpuId, out int gpuId))
|
|
{
|
|
GpuId = gpuId;
|
|
|
|
if(navigationContext.Parameters.TryGetValue<string>(NavParamKeys.NavigationSource, out string source))
|
|
SetNavigationSource(source);
|
|
|
|
_ = LoadData.ExecuteAsync(null);
|
|
}
|
|
}
|
|
|
|
private static string GetIso639CodeFromCulture()
|
|
{
|
|
string twoLetter = CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;
|
|
|
|
return twoLetter switch
|
|
{
|
|
"en" => "eng",
|
|
"es" => "spa",
|
|
"de" => "deu",
|
|
"fr" => "fra",
|
|
"la" => "lat",
|
|
"pt" => "por",
|
|
_ => "eng"
|
|
};
|
|
}
|
|
}
|