/****************************************************************************** // MARECHAI: Master repository of computing history artifacts information // ---------------------------------------------------------------------------- // // Author(s) : Natalia Portillo // // --[ License ] -------------------------------------------------------------- // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // ---------------------------------------------------------------------------- // Copyright © 2003-2026 Natalia Portillo *******************************************************************************/ #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 Windows.System; using Windows.Storage.Streams; using Humanizer; using Marechai.App.Navigation; using Marechai.App.Presentation.Models; using Marechai.App.Presentation.Views; using Marechai.App.Services; using Marechai.App.Services.Caching; using Marechai.Data; using Microsoft.Extensions.Configuration; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Media.Imaging; namespace Marechai.App.Presentation.ViewModels; public partial class MachineViewViewModel : ObservableObject, IRegionAware { private readonly ComputersService _computersService; private readonly IConfiguration _configuration; private readonly ImageSourceFactory _imageSourceFactory; private readonly IStringLocalizer _localizer; private readonly ILogger _logger; private readonly MachinePromoArtCache _machinePromoArtCache; private readonly MachinePromoArtService _machinePromoArtService; private readonly IRegionManager _regionManager; private readonly MachinePhotoCache _photoCache; [ObservableProperty] private string _companyName = string.Empty; [ObservableProperty] private string _descriptionHtml = string.Empty; [ObservableProperty] private string _errorMessage = string.Empty; [ObservableProperty] private int? _familyId; [ObservableProperty] private string? _familyName; [ObservableProperty] private bool _hasError; [ObservableProperty] private string? _introductionDateDisplay; [ObservableProperty] private bool _isDataLoaded; [ObservableProperty] private bool _isLoading; [ObservableProperty] private bool _isPrototype; [ObservableProperty] private string _machineName = string.Empty; [ObservableProperty] private string? _modelName; private string? _navigationSource; private int _currentMachineId; private int _sourceCompanyId; private int _sourceGpuId; private int _sourceProcessorId; private int _sourceSoundSynthId; [ObservableProperty] private Visibility _showFamily = Visibility.Collapsed; [ObservableProperty] private Visibility _showFamilyOrModel = Visibility.Collapsed; [ObservableProperty] private Visibility _showGpus = Visibility.Collapsed; [ObservableProperty] private Visibility _showIntroductionDate = Visibility.Collapsed; [ObservableProperty] private Visibility _showMemory = Visibility.Collapsed; [ObservableProperty] private Visibility _showModel = Visibility.Collapsed; [ObservableProperty] private Visibility _showPhotos = Visibility.Collapsed; [ObservableProperty] private Visibility _showPromoArt = Visibility.Collapsed; [ObservableProperty] private Visibility _showProcessors = Visibility.Collapsed; [ObservableProperty] private Visibility _showSoundSynthesizers = Visibility.Collapsed; [ObservableProperty] private Visibility _showSoftware = Visibility.Collapsed; [ObservableProperty] private Visibility _showStorage = Visibility.Collapsed; [ObservableProperty] private Visibility _showDescription = Visibility.Collapsed; [ObservableProperty] private Visibility _showVideos = Visibility.Collapsed; /// /// Gets whether a description is available /// public bool HasDescription => !string.IsNullOrWhiteSpace(DescriptionHtml); partial void OnDescriptionHtmlChanged(string value) { OnPropertyChanged(nameof(HasDescription)); } public MachineViewViewModel(ILogger logger, IRegionManager regionManager, ComputersService computersService, MachinePhotoCache photoCache, IStringLocalizer localizer, ImageSourceFactory imageSourceFactory, IConfiguration configuration, MachinePromoArtService machinePromoArtService, MachinePromoArtCache machinePromoArtCache) { _logger = logger; _regionManager = regionManager; _computersService = computersService; _photoCache = photoCache; _localizer = localizer; _imageSourceFactory = imageSourceFactory; _configuration = configuration; _machinePromoArtService = machinePromoArtService; _machinePromoArtCache = machinePromoArtCache; } public ObservableCollection Processors { get; } = []; public ObservableCollection Memory { get; } = []; public ObservableCollection Gpus { get; } = []; public ObservableCollection SoundSynthesizers { get; } = []; public ObservableCollection Storage { get; } = []; public ObservableCollection Software { get; } = []; public ObservableCollection Videos { get; } = []; public ObservableCollection Photos { get; } = []; public ObservableCollection PromoArtGroups { get; } = []; public int PromoArtCount => PromoArtGroups.Sum(group => group.Items.Count); public bool HasPromoArt => PromoArtCount > 0; public bool IsNavigationTarget(NavigationContext navigationContext) => false; public void OnNavigatedFrom(NavigationContext navigationContext) { } public void OnNavigatedTo(NavigationContext navigationContext) { SetNavigationSource(navigationContext.Parameters); if(navigationContext.Parameters.TryGetValue(NavParamKeys.MachineId, out int machineId)) _ = LoadMachineAsync(machineId); } [RelayCommand] public Task GoBack() { if(_regionManager.TryGoBack(RegionNames.Content)) return Task.CompletedTask; switch(_navigationSource) { case nameof(NewsViewModel): _regionManager.RequestNavigate(RegionNames.Content, nameof(NewsPage)); break; case nameof(CompanyDetailViewModel): { var parameters = new NavigationParameters { { NavParamKeys.CompanyId, _sourceCompanyId } }; _regionManager.RequestNavigate(RegionNames.Content, nameof(CompanyDetailPage), parameters); break; } case nameof(ConsolesListViewModel): _regionManager.RequestNavigate(RegionNames.Content, nameof(ConsolesListPage)); break; case nameof(SmartphonesListViewModel): _regionManager.RequestNavigate(RegionNames.Content, nameof(SmartphonesListPage)); break; case nameof(ComputersListViewModel): _regionManager.RequestNavigate(RegionNames.Content, nameof(ComputersListPage)); break; case nameof(GpuDetailViewModel): case nameof(ProcessorDetailViewModel): case nameof(SoundSynthDetailViewModel): { // When a machine is opened from a component detail page, use the journal // so Back returns to the prior page instead of rebuilding a navigation loop. _regionManager.Regions[RegionNames.Content].NavigationService.Journal.GoBack(); break; } default: _regionManager.RequestNavigate(RegionNames.Content, nameof(NewsPage)); break; } return Task.CompletedTask; } [RelayCommand] public Task ViewPhotoDetails(Guid photoId) { var parameters = new NavigationParameters { { NavParamKeys.PhotoId, photoId } }; _logger.LogInformation("Navigating to photo details for {PhotoId}", photoId); _regionManager.RequestNavigate(RegionNames.Content, nameof(PhotoDetailPage), parameters); return Task.CompletedTask; } [RelayCommand] public Task ViewPromoArtDetails(Guid promoArtId) { var parameters = new NavigationParameters { { NavParamKeys.MachinePromoArtId, promoArtId } }; _logger.LogInformation("Navigating to promo art details for {PromoArtId}", promoArtId); _regionManager.RequestNavigate(RegionNames.Content, nameof(MachinePromoArtDetailPage), parameters); return Task.CompletedTask; } [RelayCommand] public Task NavigateToSoftware(SoftwareListItem? sw) { if(sw is null) return Task.CompletedTask; var parameters = new NavigationParameters { { NavParamKeys.SoftwareId, sw.Id }, { NavParamKeys.NavigationSource, nameof(MachineViewViewModel) } }; _regionManager.RequestNavigate(RegionNames.Content, nameof(SoftwareViewPage), parameters); return Task.CompletedTask; } [RelayCommand] public Task NavigateToFamily() { if(!FamilyId.HasValue) return Task.CompletedTask; var parameters = new NavigationParameters { { NavParamKeys.MachineFamilyId, FamilyId.Value }, { NavParamKeys.MachineId, _currentMachineId }, { NavParamKeys.NavigationSource, nameof(MachineViewViewModel) } }; _regionManager.RequestNavigate(RegionNames.Content, nameof(MachineFamilyViewPage), parameters); return Task.CompletedTask; } [RelayCommand] public Task NavigateToProcessor(ProcessorDisplayItem? processor) { if(processor is null || processor.Id <= 0) return Task.CompletedTask; var parameters = new NavigationParameters { { NavParamKeys.ProcessorId, processor.Id }, { NavParamKeys.MachineId, _currentMachineId }, { NavParamKeys.NavigationSource, nameof(MachineViewViewModel) } }; _regionManager.RequestNavigate(RegionNames.Content, nameof(ProcessorDetailPage), parameters); return Task.CompletedTask; } [RelayCommand] public Task NavigateToGpu(GpuDisplayItem? gpu) { if(gpu is null || gpu.Id <= 0) return Task.CompletedTask; var parameters = new NavigationParameters { { NavParamKeys.GpuId, gpu.Id }, { NavParamKeys.MachineId, _currentMachineId }, { NavParamKeys.NavigationSource, nameof(MachineViewViewModel) } }; _regionManager.RequestNavigate(RegionNames.Content, nameof(GpuDetailPage), parameters); return Task.CompletedTask; } [RelayCommand] public Task NavigateToSoundSynth(SoundSynthesizerDisplayItem? soundSynth) { if(soundSynth is null || soundSynth.Id <= 0) return Task.CompletedTask; var parameters = new NavigationParameters { { NavParamKeys.SoundSynthId, soundSynth.Id }, { NavParamKeys.MachineId, _currentMachineId }, { NavParamKeys.NavigationSource, nameof(MachineViewViewModel) } }; _regionManager.RequestNavigate(RegionNames.Content, nameof(SoundSynthDetailPage), parameters); return Task.CompletedTask; } [RelayCommand] public async Task OpenVideo(MachineVideoDisplayItem? video) { if(video?.LaunchUri is null) return; try { await Launcher.LaunchUriAsync(video.LaunchUri); } catch(Exception ex) { _logger.LogError(ex, "Error launching video for machine {MachineId}", _currentMachineId); } } /// /// Sets the navigation source context from navigation parameters. /// public void SetNavigationSource(INavigationParameters parameters) { if(parameters.TryGetValue(NavParamKeys.NavigationSource, out string source)) _navigationSource = source; if(parameters.TryGetValue(NavParamKeys.CompanyId, out int companyId)) _sourceCompanyId = companyId; if(parameters.TryGetValue(NavParamKeys.GpuId, out int gpuId)) _sourceGpuId = gpuId; if(parameters.TryGetValue(NavParamKeys.ProcessorId, out int processorId)) _sourceProcessorId = processorId; if(parameters.TryGetValue(NavParamKeys.SoundSynthId, out int soundSynthId)) _sourceSoundSynthId = soundSynthId; } [RelayCommand] public Task LoadData() { // Placeholder for retry functionality HasError = false; ErrorMessage = string.Empty; return Task.CompletedTask; } public async Task LoadMachineAsync(int machineId) { try { IsLoading = true; IsDataLoaded = false; HasError = false; ErrorMessage = string.Empty; Processors.Clear(); Memory.Clear(); Gpus.Clear(); SoundSynthesizers.Clear(); Storage.Clear(); Software.Clear(); Videos.Clear(); Photos.Clear(); PromoArtGroups.Clear(); _logger.LogInformation("Loading machine {MachineId}", machineId); _currentMachineId = machineId; // Fetch machine data from API MachineDto? machine = await _computersService.GetMachineByIdAsync(machineId); if(machine is null) { HasError = true; ErrorMessage = _localizer["Machine not found"]; IsLoading = false; return; } // Populate basic information MachineName = machine.Name ?? string.Empty; CompanyName = machine.Company ?? string.Empty; FamilyId = machine.FamilyId; FamilyName = machine.FamilyName; ModelName = machine.Model; // Check if this is a prototype IsPrototype = machine.Prototype == true; // Set introduction date if available and not a prototype if(machine.Introduced.HasValue && !IsPrototype) IntroductionDateDisplay = DatePrecisionFormatter.Format(machine.Introduced, machine.IntroducedPrecision); // Populate processors if(machine.Processors != null) { foreach(ProcessorDto processor in machine.Processors) { var details = new List(); var speed = (int)(processor.Speed ?? 0); int gprSize = processor.GprSize ?? 0; int cores = processor.Cores ?? 0; if(speed > 0) details.Add(string.Format(_localizer["_0_MHz"], speed)); if(gprSize > 0) details.Add(string.Format(_localizer["_0_bits"], gprSize)); if(cores > 1) details.Add(string.Format(_localizer["_0_cores"], cores)); Processors.Add(new ProcessorDisplayItem { Id = processor.Id ?? 0, DisplayName = processor.Name ?? string.Empty, Manufacturer = processor.Company ?? string.Empty, HasDetails = details.Count > 0, DetailsText = string.Join(", ", details) }); } } // Populate memory if(machine.Memory != null) { foreach(MemoryDto mem in machine.Memory) { long size = mem.Size ?? 0; string sizeStr = size > 0 ? size > 1024 ? string.Format(_localizer["_0_bytes_1_"], size, size.Bytes().Humanize()) : string.Format(_localizer["_0_bytes"], size) : _localizer["Unknown_male"]; // Get humanized memory usage description string usageDescription = mem.Usage.HasValue ? ((MemoryUsage)mem.Usage.Value).Humanize() : _localizer["Unknown_male"]; Memory.Add(new MemoryDisplayItem { SizeDisplay = sizeStr, TypeDisplay = usageDescription }); } } // Populate GPUs if(machine.Gpus != null) { foreach(GpuDto gpu in machine.Gpus) { Gpus.Add(new GpuDisplayItem { Id = gpu.Id ?? 0, DisplayName = LocalizeSpecialGpuName(gpu.Name), Manufacturer = gpu.Company ?? string.Empty, HasManufacturer = !string.IsNullOrEmpty(gpu.Company) }); } } // Populate sound synthesizers if(machine.SoundSynthesizers != null) { foreach(SoundSynthDto synth in machine.SoundSynthesizers) { var details = new List(); int voices = synth.Voices ?? 0; if(voices > 0) details.Add(string.Format(_localizer["_0_voices"], voices)); SoundSynthesizers.Add(new SoundSynthesizerDisplayItem { Id = synth.Id ?? 0, DisplayName = LocalizeSpecialSoundSynthName(synth.Name), HasDetails = details.Count > 0, DetailsText = string.Join(", ", details) }); } } // Populate storage if(machine.Storage != null) { foreach(StorageDto storage in machine.Storage) { long capacity = storage.Capacity ?? 0; string displayText = capacity > 0 ? capacity > 1024 ? string.Format(_localizer["_0_bytes_1_"], capacity, capacity.Bytes().Humanize()) : string.Format(_localizer["_0_bytes"], capacity) : _localizer["Storage"]; // Get humanized storage type description string typeNote = storage.Type.HasValue ? ((StorageType)storage.Type.Value).Humanize() : _localizer["Unknown_male"]; Storage.Add(new StorageDisplayItem { DisplayText = displayText, TypeNote = typeNote }); } } // Populate software List softwareList = await _computersService.GetSoftwareByMachineAsync(machineId); string baseUrl = _configuration.GetSection("ApiClient:Url").Value; foreach(SoftwareDto sw in softwareList) { int id = (int)(sw.Id ?? 0); if(id == 0) continue; Software.Add(new SoftwareListItem { Id = id, Name = sw.Name ?? string.Empty, Family = sw.Family, Kind = (SoftwareKind)(sw.Kind ?? 0), FrontCoverId = sw.FrontCoverId, CoverImageUrl = sw.FrontCoverId.HasValue ? $"{baseUrl}/assets/photos/software-covers/webp/4k/{sw.FrontCoverId}.webp" : null }); } // Populate videos List videoList = await _computersService.GetVideosByMachineAsync(machineId); foreach(MachineVideoDto 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)}") }); } // Populate promo art List promoArtList = await _machinePromoArtService.GetPromoArtByMachineAsync(machineId); string otherGroup = _localizer["OtherPromoArt"]; foreach(var group in promoArtList.GroupBy(item => string.IsNullOrWhiteSpace(item.GroupName) ? otherGroup : item.GroupName!) .OrderBy(group => group.Key, StringComparer.CurrentCultureIgnoreCase)) { var displayGroup = new MachinePromoArtGroupDisplayItem { GroupName = group.Key }; foreach(MachinePromoArtDto promoArt in group) { if(!promoArt.Id.HasValue) continue; var promoArtItem = new MachinePromoArtDisplayItem { PromoArtId = promoArt.Id.Value, GroupName = group.Key, Caption = promoArt.Caption ?? string.Empty }; _ = LoadPromoArtThumbnailAsync(promoArtItem); displayGroup.Items.Add(promoArtItem); } if(displayGroup.Items.Count > 0) PromoArtGroups.Add(displayGroup); } // Load localized description try { string langCode = GetIso639CodeFromCulture(); MachineDescriptionDto? desc = await _computersService.GetDescriptionAsync(machineId, langCode); DescriptionHtml = desc?.Html ?? desc?.Markdown ?? string.Empty; } catch(Exception ex) { _logger.LogError("Failed to load machine description: {Exception}", ex.Message); } // Populate photos List photoIds = await _computersService.GetMachinePhotosAsync(machineId); if(photoIds.Count > 0) { foreach(Guid photoId in photoIds) { var photoItem = new PhotoCarouselDisplayItem { PhotoId = photoId }; // Load thumbnail image asynchronously _ = LoadPhotoThumbnailAsync(photoItem); Photos.Add(photoItem); } } UpdateVisibilities(); IsDataLoaded = true; IsLoading = false; } catch(Exception ex) { _logger.LogError(ex, "Error loading machine {MachineId}", machineId); HasError = true; ErrorMessage = ex.Message; IsLoading = false; } } private void UpdateVisibilities() { ShowIntroductionDate = !string.IsNullOrEmpty(IntroductionDateDisplay) ? Visibility.Visible : Visibility.Collapsed; ShowFamily = !string.IsNullOrEmpty(FamilyName) ? Visibility.Visible : Visibility.Collapsed; ShowModel = !string.IsNullOrEmpty(ModelName) ? Visibility.Visible : Visibility.Collapsed; ShowFamilyOrModel = ShowFamily == Visibility.Visible || ShowModel == Visibility.Visible ? Visibility.Visible : Visibility.Collapsed; ShowProcessors = Processors.Count > 0 ? Visibility.Visible : Visibility.Collapsed; ShowMemory = Memory.Count > 0 ? Visibility.Visible : Visibility.Collapsed; ShowGpus = Gpus.Count > 0 ? Visibility.Visible : Visibility.Collapsed; ShowSoundSynthesizers = SoundSynthesizers.Count > 0 ? Visibility.Visible : Visibility.Collapsed; ShowStorage = Storage.Count > 0 ? Visibility.Visible : Visibility.Collapsed; ShowSoftware = Software.Count > 0 ? Visibility.Visible : Visibility.Collapsed; ShowVideos = Videos.Count > 0 ? Visibility.Visible : Visibility.Collapsed; ShowPhotos = Photos.Count > 0 ? Visibility.Visible : Visibility.Collapsed; ShowPromoArt = PromoArtGroups.Count > 0 ? Visibility.Visible : Visibility.Collapsed; ShowDescription = HasDescription ? Visibility.Visible : Visibility.Collapsed; OnPropertyChanged(nameof(PromoArtCount)); OnPropertyChanged(nameof(HasPromoArt)); } private async Task LoadPhotoThumbnailAsync(PhotoCarouselDisplayItem photoItem) { try { Stream stream = await _photoCache.GetThumbnailAsync(photoItem.PhotoId); photoItem.ThumbnailImageSource = await _imageSourceFactory.CreateBitmapImageSourceAsync(stream); } catch(Exception ex) { _logger.LogError(ex, "Error loading photo thumbnail {PhotoId}", photoItem.PhotoId); } } async Task LoadPromoArtThumbnailAsync(MachinePromoArtDisplayItem promoArtItem) { try { Stream stream = await _machinePromoArtCache.GetThumbnailAsync(promoArtItem.PromoArtId); promoArtItem.ThumbnailImageSource = await _imageSourceFactory.CreateBitmapImageSourceAsync(stream); } catch(Exception ex) { _logger.LogError(ex, "Error loading promo art thumbnail {PromoArtId}", promoArtItem.PromoArtId); } } 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" }; } string LocalizeSpecialGpuName(string? name) => name switch { "DB_FRAMEBUFFER" => _localizer["Framebuffer"], "DB_SOFTWARE" => _localizer["Software"], "DB_NONE" => _localizer["None_female"], _ => name ?? string.Empty }; string LocalizeSpecialSoundSynthName(string? name) => name switch { "DB_SOFTWARE" => _localizer["Software"], _ => name ?? string.Empty }; }