Add Uno app software rankings for parity with Blazor

Adds a Software Rankings index page (grouped by gameplay/genre/setting/category/platform)
and a ranking detail leaderboard page, reusing the already-generated Kiota rankings
endpoints. Wires ranking placement chips into the software detail page and adds a
sidebar entry point, mirroring the existing Blazor Rankings/RankingDetail pages.
This commit is contained in:
2026-06-28 09:50:59 +01:00
parent 27b71bede8
commit e60a4b5fc1
21 changed files with 1026 additions and 0 deletions

View File

@@ -297,6 +297,8 @@ public partial class App : PrismApplication
containerRegistry.Register<SoftwareViewViewModel>();
containerRegistry.Register<SoftwareReleaseViewViewModel>();
containerRegistry.Register<SoftwareCompilationViewViewModel>();
containerRegistry.Register<SoftwareRankingsViewModel>();
containerRegistry.Register<SoftwareRankingDetailViewModel>();
containerRegistry.Register<ProfileViewModel>();
containerRegistry.Register<AdminSoftwareViewModel>();
containerRegistry.Register<AdminSoftwareFamiliesViewModel>();
@@ -389,6 +391,8 @@ public partial class App : PrismApplication
containerRegistry.RegisterForNavigation<SoftwareViewPage, SoftwareViewViewModel>();
containerRegistry.RegisterForNavigation<SoftwareReleaseViewPage, SoftwareReleaseViewViewModel>();
containerRegistry.RegisterForNavigation<SoftwareCompilationViewPage, SoftwareCompilationViewViewModel>();
containerRegistry.RegisterForNavigation<SoftwareRankingsPage, SoftwareRankingsViewModel>();
containerRegistry.RegisterForNavigation<SoftwareRankingDetailPage, SoftwareRankingDetailViewModel>();
containerRegistry.RegisterForNavigation<ProfilePage, ProfileViewModel>();
containerRegistry.RegisterForNavigation<AdminSoftwarePage, AdminSoftwareViewModel>();
containerRegistry.RegisterForNavigation<AdminSoftwareFamiliesPage, AdminSoftwareFamiliesViewModel>();

View File

@@ -18,5 +18,6 @@
<local:CountToVisibilityConverter x:Key="CountToVisibilityConverter" />
<local:NullableToVisibilityConverter x:Key="NullableToVisibilityConverter" />
<local:BoolToInfoBarSeverityConverter x:Key="BoolToInfoBarSeverityConverter" />
<local:MarechaiScoreToBrushConverter x:Key="MarechaiScoreToBrushConverter" />
</ResourceDictionary>

View File

@@ -30,6 +30,7 @@ public static class NavParamKeys
public const string ScreenshotId = "ScreenshotId";
public const string CoverId = "CoverId";
public const string SoftwarePromoArtId = "SoftwarePromoArtId";
public const string RankingId = "RankingId";
public const string WwpcImportId = "WwpcImportId";
public const string SearchQuery = "SearchQuery";
public const string MachineFamilyId = "MachineFamilyId";

View File

@@ -257,6 +257,18 @@
BorderThickness="0"
CornerRadius="0" />
<!-- Software Rankings -->
<Button Content="{Binding RankingsButtonText}"
Command="{Binding NavigateToRankingsCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Padding="16,10,16,10"
FontSize="13"
Background="Transparent"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
BorderThickness="0"
CornerRadius="0" />
<!-- Sound Synthesizers -->
<Button Content="{Binding SoundSynthesizersButtonText}"
Command="{Binding NavigateToSoundSynthesizersCommand}"

View File

@@ -163,6 +163,33 @@ public class NullableToVisibilityConverter : IValueConverter
throw new NotImplementedException();
}
/// <summary>
/// Maps a 0-10 Marechai score to a themed brush (>=8 success, >=6 info, >=4 warning, otherwise neutral).
/// </summary>
public class MarechaiScoreToBrushConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
double? score = value switch
{
double d => d,
float f => f,
_ => null
};
return score switch
{
>= 8 => new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Green),
>= 6 => new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.DodgerBlue),
>= 4 => new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Orange),
_ => new Microsoft.UI.Xaml.Media.SolidColorBrush(Microsoft.UI.Colors.Gray)
};
}
public object ConvertBack(object value, Type targetType, object parameter, string language) =>
throw new NotImplementedException();
}
/// <summary>
/// Maps success/failure booleans to InfoBar severity.
/// </summary>

View File

@@ -59,6 +59,7 @@ public partial class MainViewModel : ObservableObject
public string PeopleButtonText => _localizer["PeopleButton"];
public string ProcessorsButtonText => _localizer["ProcessorsButton"];
public string SoftwareButtonText => _localizer["SoftwareButton"];
public string RankingsButtonText => _localizer["RankingsButton"];
public string SoundSynthesizersButtonText => _localizer["SoundSynthesizersButton"];
public string MessagesButtonText => "Messages";
public string UserManagementButtonText => _localizer["UserManagementButton"];
@@ -136,6 +137,7 @@ public partial class MainViewModel : ObservableObject
NavigateToPeopleCommand = new RelayCommand(() => NavigateTo(nameof(PeoplePage)));
NavigateToProcessorsCommand = new RelayCommand(() => NavigateTo(nameof(ProcessorListPage)));
NavigateToSoftwareCommand = new RelayCommand(() => NavigateTo(nameof(SoftwarePage)));
NavigateToRankingsCommand = new RelayCommand(() => NavigateTo(nameof(SoftwareRankingsPage)));
NavigateToSoundSynthesizersCommand = new RelayCommand(() => NavigateTo(nameof(SoundSynthListPage)));
NavigateToMessagesCommand = new RelayCommand(() => NavigateTo(nameof(MessagesPage)));
NavigateToUsersCommand = new RelayCommand(() => NavigateTo(nameof(UsersPage)));
@@ -204,6 +206,7 @@ public partial class MainViewModel : ObservableObject
public ICommand NavigateToPeopleCommand { get; }
public ICommand NavigateToProcessorsCommand { get; }
public ICommand NavigateToSoftwareCommand { get; }
public ICommand NavigateToRankingsCommand { get; }
public ICommand NavigateToSoundSynthesizersCommand { get; }
public ICommand NavigateToMessagesCommand { get; }
public ICommand NavigateToUsersCommand { get; }

View File

@@ -0,0 +1,145 @@
#nullable enable
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using Marechai.ApiClient.Models;
using Marechai.App.Navigation;
using Marechai.App.Presentation.Views;
using Marechai.App.Services;
using Microsoft.UI.Xaml.Data;
namespace Marechai.App.Presentation.ViewModels;
[Bindable]
public partial class SoftwareRankingDetailViewModel : ObservableObject, IRegionAware
{
private readonly SoftwareBrowsingService _browsingService;
private readonly IStringLocalizer _localizer;
private readonly ILogger<SoftwareRankingDetailViewModel> _logger;
private readonly IRegionManager _regionManager;
private int _rankingId;
[ObservableProperty]
private ObservableCollection<SoftwareRankingDto> _entries = [];
[ObservableProperty]
private string _errorMessage = string.Empty;
[ObservableProperty]
private bool _hasError;
[ObservableProperty]
private bool _isDataLoaded;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private bool _isEmpty;
[ObservableProperty]
private string _pageTitle = string.Empty;
public SoftwareRankingDetailViewModel(SoftwareBrowsingService browsingService,
IStringLocalizer localizer,
ILogger<SoftwareRankingDetailViewModel> logger,
IRegionManager regionManager)
{
_browsingService = browsingService;
_localizer = localizer;
_logger = logger;
_regionManager = regionManager;
LoadData = new AsyncRelayCommand(LoadDataAsync);
GoBackCommand = new AsyncRelayCommand(GoBackAsync);
NavigateToSoftwareCommand = new AsyncRelayCommand<SoftwareRankingDto>(NavigateToSoftwareAsync);
}
public IAsyncRelayCommand LoadData { get; }
public ICommand GoBackCommand { get; }
public IAsyncRelayCommand<SoftwareRankingDto> NavigateToSoftwareCommand { get; }
public bool IsNavigationTarget(NavigationContext navigationContext) => false;
public void OnNavigatedFrom(NavigationContext navigationContext) { }
public void OnNavigatedTo(NavigationContext navigationContext)
{
if(navigationContext.Parameters.TryGetValue<int>(NavParamKeys.RankingId, out int rankingId))
{
_rankingId = rankingId;
_ = LoadDataAsync();
}
}
private async Task LoadDataAsync()
{
try
{
IsLoading = true;
ErrorMessage = string.Empty;
HasError = false;
IsDataLoaded = false;
IsEmpty = false;
Entries.Clear();
await LoadTitleAsync();
System.Collections.Generic.List<SoftwareRankingDto> entries =
await _browsingService.GetRankingAsync(_rankingId);
foreach(SoftwareRankingDto entry in entries) Entries.Add(entry);
if(Entries.Count == 0)
IsEmpty = true;
else
IsDataLoaded = true;
}
catch(Exception ex)
{
_logger.LogError(ex, "Error loading ranking {RankingId}", _rankingId);
ErrorMessage = _localizer["Failed to load ranking. Please try again later."].Value;
HasError = true;
}
finally
{
IsLoading = false;
}
}
private async Task LoadTitleAsync()
{
RankingIndexResponseDto? index = await _browsingService.GetRankingsIndexAsync();
RankingIndexEntryDto? entry = index?.Rankings?.FirstOrDefault(r => r.Id == _rankingId);
PageTitle = entry?.Dimension == 0
? _localizer["Top250SoftwareLabel"]
: entry?.DimensionName ?? _localizer["RankingsTitle"];
}
private Task GoBackAsync()
{
_regionManager.RequestNavigate(RegionNames.Content, nameof(SoftwareRankingsPage));
return Task.CompletedTask;
}
private Task NavigateToSoftwareAsync(SoftwareRankingDto? entry)
{
if(entry?.SoftwareId is null) return Task.CompletedTask;
var parameters = new NavigationParameters
{
{ NavParamKeys.SoftwareId, entry.SoftwareId.Value },
{ NavParamKeys.NavigationSource, nameof(SoftwareRankingDetailViewModel) }
};
_regionManager.RequestNavigate(RegionNames.Content, nameof(SoftwareViewPage), parameters);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,184 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
using Marechai.ApiClient.Models;
using Marechai.App.Navigation;
using Marechai.App.Presentation.Views;
using Marechai.App.Services;
using Microsoft.UI.Xaml.Data;
namespace Marechai.App.Presentation.ViewModels;
[Bindable]
public partial class SoftwareRankingsViewModel : ObservableObject, IRegionAware
{
private readonly SoftwareBrowsingService _browsingService;
private readonly IStringLocalizer _localizer;
private readonly ILogger<SoftwareRankingsViewModel> _logger;
private readonly IRegionManager _regionManager;
[ObservableProperty]
private ObservableCollection<RankingGroup> _rankingGroups = [];
[ObservableProperty]
private RankingIndexEntryDto? _overallRanking;
[ObservableProperty]
private string _errorMessage = string.Empty;
[ObservableProperty]
private bool _hasError;
[ObservableProperty]
private bool _isDataLoaded;
[ObservableProperty]
private bool _isLoading;
[ObservableProperty]
private string _statusText = string.Empty;
public SoftwareRankingsViewModel(SoftwareBrowsingService browsingService,
IStringLocalizer localizer,
ILogger<SoftwareRankingsViewModel> logger,
IRegionManager regionManager)
{
_browsingService = browsingService;
_localizer = localizer;
_logger = logger;
_regionManager = regionManager;
LoadData = new AsyncRelayCommand(LoadDataAsync);
GoBackCommand = new AsyncRelayCommand(GoBackAsync);
NavigateToRankingCommand = new AsyncRelayCommand<RankingIndexEntryDto>(NavigateToRankingAsync);
}
public IAsyncRelayCommand LoadData { get; }
public ICommand GoBackCommand { get; }
public IAsyncRelayCommand<RankingIndexEntryDto> NavigateToRankingCommand { get; }
public bool IsNavigationTarget(NavigationContext navigationContext) => false;
public void OnNavigatedFrom(NavigationContext navigationContext) { }
public void OnNavigatedTo(NavigationContext navigationContext)
{
_ = LoadDataAsync();
}
private async Task LoadDataAsync()
{
try
{
IsLoading = true;
ErrorMessage = string.Empty;
HasError = false;
IsDataLoaded = false;
RankingGroups.Clear();
OverallRanking = null;
RankingIndexResponseDto? response = await _browsingService.GetRankingsIndexAsync();
if(response?.Rankings is null || response.Rankings.Count == 0)
{
ErrorMessage = _localizer["No rankings found"].Value;
HasError = true;
return;
}
UpdateStatusText(response.Status);
OverallRanking = response.Rankings.FirstOrDefault(r => r.Dimension == 0);
List<RankingIndexEntryDto> byGenre = response.Rankings.Where(r => r.Dimension == 1).ToList();
List<RankingIndexEntryDto> byPlatform = response.Rankings.Where(r => r.Dimension == 2).ToList();
AddGenreGroup(byGenre, genreType: 2, _localizer["By Gameplay"]);
AddGenreGroup(byGenre, genreType: 0, _localizer["By Genre"]);
AddGenreGroup(byGenre, genreType: 3, _localizer["By Setting"]);
AddGenreGroup(byGenre, genreType: 4, _localizer["By Category"]);
if(byPlatform.Count > 0)
{
RankingGroups.Add(new RankingGroup
{
Title = _localizer["By Platform"],
Entries = new ObservableCollection<RankingIndexEntryDto>(
byPlatform.OrderBy(r => r.DimensionName))
});
}
IsDataLoaded = true;
}
catch(Exception ex)
{
_logger.LogError(ex, "Error loading rankings index");
ErrorMessage = _localizer["Failed to load rankings. Please try again later."].Value;
HasError = true;
}
finally
{
IsLoading = false;
}
}
private void AddGenreGroup(List<RankingIndexEntryDto> byGenre, byte genreType, string title)
{
List<RankingIndexEntryDto> entries = byGenre.Where(r => r.GenreType == genreType)
.OrderBy(r => r.DimensionName)
.ToList();
if(entries.Count == 0) return;
RankingGroups.Add(new RankingGroup
{
Title = title,
Entries = new ObservableCollection<RankingIndexEntryDto>(entries)
});
}
private void UpdateStatusText(RankingsStatusDto? status)
{
if(status is null) return;
StatusText = status.IsComputing == true
? _localizer["Rankings are being computed"]
: status.LastComputedAt.HasValue
? string.Format(_localizer["Last computed: {0}"], status.LastComputedAt.Value.LocalDateTime)
: string.Empty;
}
private Task GoBackAsync()
{
_regionManager.RequestNavigate(RegionNames.Content, nameof(SoftwarePage));
return Task.CompletedTask;
}
private Task NavigateToRankingAsync(RankingIndexEntryDto? entry)
{
if(entry?.Id is null) return Task.CompletedTask;
var parameters = new NavigationParameters
{
{ NavParamKeys.RankingId, entry.Id.Value },
{ NavParamKeys.NavigationSource, nameof(SoftwareRankingsViewModel) }
};
_regionManager.RequestNavigate(RegionNames.Content, nameof(SoftwareRankingDetailPage), parameters);
return Task.CompletedTask;
}
}
public class RankingGroup
{
public string Title { get; set; } = string.Empty;
public ObservableCollection<RankingIndexEntryDto> Entries { get; set; } = [];
}

View File

@@ -45,6 +45,9 @@ public partial class SoftwareViewViewModel : ObservableObject, IRegionAware
private string? _currentUserId;
private SoftwareUserReviewDto? _myReview;
[ObservableProperty]
private ObservableCollection<SoftwareRankingPlacementDto> _rankingPlacements = [];
[ObservableProperty]
private string _softwareName = string.Empty;
@@ -651,6 +654,9 @@ public partial class SoftwareViewViewModel : ObservableObject, IRegionAware
_logger.LogError("Failed to load software description: {Exception}", ex.Message);
}
// Load ranking placements
await LoadRankingPlacementsAsync(softwareId);
UpdateVisibilities();
IsDataLoaded = true;
IsLoading = false;
@@ -664,6 +670,39 @@ public partial class SoftwareViewViewModel : ObservableObject, IRegionAware
}
}
private async Task LoadRankingPlacementsAsync(int softwareId)
{
try
{
List<SoftwareRankingPlacementDto> placements =
await _browsingService.GetRankingPlacementsAsync(softwareId);
RankingPlacements.Clear();
foreach(SoftwareRankingPlacementDto placement in placements) RankingPlacements.Add(placement);
}
catch(Exception ex)
{
_logger.LogError(ex, "Error loading ranking placements for software {SoftwareId}", softwareId);
}
}
[RelayCommand]
public Task NavigateToRanking(SoftwareRankingPlacementDto? placement)
{
if(placement?.RankingId is null) return Task.CompletedTask;
var parameters = new NavigationParameters
{
{ NavParamKeys.RankingId, placement.RankingId.Value },
{ NavParamKeys.NavigationSource, nameof(SoftwareViewViewModel) }
};
_regionManager.RequestNavigate(RegionNames.Content, nameof(SoftwareRankingDetailPage), parameters);
return Task.CompletedTask;
}
[RelayCommand]
public Task ViewScreenshot(ScreenshotDisplayItem? item)
{

View File

@@ -0,0 +1,202 @@
<?xml version="1.0"
encoding="utf-8"?>
<Page x:Class="Marechai.App.Presentation.Views.SoftwareRankingDetailPage"
x:Name="PageRoot"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="using:Marechai.App.Presentation.Converters"
NavigationCacheMode="Required"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Grid RowSpacing="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Header with Back Button and Title -->
<Grid Grid.Row="0"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
BorderThickness="0,0,0,1"
Padding="16,12,16,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Command="{Binding GoBackCommand}"
Style="{ThemeResource AlternateButtonStyle}"
ToolTipService.ToolTip="Go back"
Padding="8"
MinWidth="44"
MinHeight="44"
VerticalAlignment="Center">
<FontIcon Glyph="&#xE72B;"
FontSize="16" />
</Button>
<TextBlock Grid.Column="1"
Text="{Binding PageTitle}"
Margin="16,0,0,0"
VerticalAlignment="Center"
FontSize="20"
FontWeight="SemiBold"
Foreground="{ThemeResource TextControlForeground}" />
</Grid>
<!-- Main Content -->
<Grid Grid.Row="1">
<!-- Loading State -->
<StackPanel Visibility="{Binding IsLoading}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Padding="32"
Spacing="16">
<ProgressRing IsActive="True"
IsIndeterminate="True"
Height="64"
Width="64"
Foreground="{ThemeResource SystemAccentColor}" />
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=LoadingText}"
FontSize="14"
TextAlignment="Center"
Foreground="{ThemeResource SystemBaseMediumColor}" />
</StackPanel>
<!-- Error State -->
<StackPanel Visibility="{Binding HasError}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Padding="24"
Spacing="16"
MaxWidth="400">
<InfoBar IsOpen="True"
Severity="Error"
Title="{Binding Source={StaticResource Strings}, Path=SoftwareErrorInfoBar_Title}"
Message="{Binding ErrorMessage}"
IsClosable="False" />
<Button Content="{Binding Source={StaticResource Strings}, Path=RetryButton}"
Command="{Binding LoadData}"
HorizontalAlignment="Center"
Style="{ThemeResource AccentButtonStyle}" />
</StackPanel>
<!-- Empty State -->
<StackPanel Visibility="{Binding IsEmpty}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Padding="24"
Spacing="8">
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=NoRankedSoftwareLabel}"
FontSize="14"
TextAlignment="Center"
Foreground="{ThemeResource SystemBaseMediumColor}" />
</StackPanel>
<!-- Ranking List -->
<Grid Visibility="{Binding IsDataLoaded}">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<ItemsControl Padding="8"
ItemsSource="{Binding Entries}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical"
Spacing="0"
HorizontalAlignment="Stretch" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Padding="0"
Margin="0,0,0,8"
MinHeight="76"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
HorizontalAlignment="Stretch"
Command="{Binding DataContext.NavigateToSoftwareCommand, ElementName=PageRoot}"
CommandParameter="{Binding}"
Background="Transparent"
BorderThickness="0">
<Border Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8"
Padding="16,12">
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- Rank -->
<Border Grid.Column="0"
Width="40"
Height="40"
CornerRadius="20"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
VerticalAlignment="Center">
<TextBlock Text="{Binding Rank}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="14"
FontWeight="Bold" />
</Border>
<!-- Name / Family -->
<StackPanel Grid.Column="1"
Spacing="4"
VerticalAlignment="Center">
<TextBlock Text="{Binding Name}"
FontSize="16"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal"
Spacing="12">
<TextBlock Text="{Binding Family}"
FontSize="13"
Foreground="{ThemeResource SystemBaseMediumColor}"
Visibility="{Binding Family, Converter={StaticResource NullToVisibilityConverter}}" />
<TextBlock FontSize="12"
Foreground="{ThemeResource SystemBaseMediumColor}"
Visibility="{Binding CriticReviewCount, Converter={StaticResource CountToVisibilityConverter}}">
<Run Text="{Binding CriticReviewCount}" />
<Run Text="{Binding Source={StaticResource Strings}, Path=CriticReviewsCountLabel}" />
</TextBlock>
<TextBlock FontSize="12"
Foreground="{ThemeResource SystemBaseMediumColor}"
Visibility="{Binding UserRatingCount, Converter={StaticResource CountToVisibilityConverter}}">
<Run Text="{Binding UserRatingCount}" />
<Run Text="{Binding Source={StaticResource Strings}, Path=UserRatingsCountLabel}" />
</TextBlock>
</StackPanel>
</StackPanel>
<!-- Marechai Score -->
<Border Grid.Column="2"
CornerRadius="12"
Padding="10,4"
VerticalAlignment="Center"
Background="{Binding MarechaiScore, Converter={StaticResource MarechaiScoreToBrushConverter}}">
<TextBlock Text="{Binding MarechaiScore}"
FontSize="14"
FontWeight="Bold"
Foreground="White" />
</Border>
</Grid>
</Border>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Grid>
</Grid>
</Page>

View File

@@ -0,0 +1,13 @@
#nullable enable
using Microsoft.UI.Xaml.Controls;
namespace Marechai.App.Presentation.Views;
public sealed partial class SoftwareRankingDetailPage : Page
{
public SoftwareRankingDetailPage()
{
InitializeComponent();
}
}

View File

@@ -0,0 +1,190 @@
<?xml version="1.0"
encoding="utf-8"?>
<Page x:Class="Marechai.App.Presentation.Views.SoftwareRankingsPage"
x:Name="PageRoot"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:loc="using:Marechai.App.Presentation.Converters"
NavigationCacheMode="Required"
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
<Grid RowSpacing="0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" /> <RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- Header with Back Button and Title -->
<Grid Grid.Row="0"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
BorderThickness="0,0,0,1"
Padding="16,12,16,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" /> <ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Command="{Binding GoBackCommand}"
Style="{ThemeResource AlternateButtonStyle}"
ToolTipService.ToolTip="Go back"
Padding="8"
MinWidth="44"
MinHeight="44"
VerticalAlignment="Center">
<FontIcon Glyph="&#xE72B;"
FontSize="16" />
</Button>
<StackPanel Grid.Column="1"
Margin="16,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=RankingsTitle}"
FontSize="20"
FontWeight="SemiBold"
Foreground="{ThemeResource TextControlForeground}" />
<TextBlock Text="{Binding StatusText}"
FontSize="12"
Foreground="{ThemeResource SystemBaseMediumColor}"
Margin="0,4,0,0"
Visibility="{Binding StatusText, Converter={StaticResource StringToVisibilityConverter}}" />
</StackPanel>
</Grid>
<!-- Main Content -->
<Grid Grid.Row="1">
<!-- Loading State -->
<StackPanel Visibility="{Binding IsLoading}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Padding="32"
Spacing="16">
<ProgressRing IsActive="True"
IsIndeterminate="True"
Height="64"
Width="64"
Foreground="{ThemeResource SystemAccentColor}" />
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=LoadingText}"
FontSize="14"
TextAlignment="Center"
Foreground="{ThemeResource SystemBaseMediumColor}" />
</StackPanel>
<!-- Error State -->
<StackPanel Visibility="{Binding HasError}"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Padding="24"
Spacing="16"
MaxWidth="400">
<InfoBar IsOpen="True"
Severity="Error"
Title="{Binding Source={StaticResource Strings}, Path=SoftwareErrorInfoBar_Title}"
Message="{Binding ErrorMessage}"
IsClosable="False" />
<Button Content="{Binding Source={StaticResource Strings}, Path=RetryButton}"
Command="{Binding LoadData}"
HorizontalAlignment="Center"
Style="{ThemeResource AccentButtonStyle}" />
</StackPanel>
<!-- Rankings -->
<Grid Visibility="{Binding IsDataLoaded}">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<StackPanel Padding="16"
Spacing="24">
<!-- Overall / Top 250 -->
<Button Padding="16,12"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Command="{Binding NavigateToRankingCommand}"
CommandParameter="{Binding OverallRanking}"
Visibility="{Binding OverallRanking, Converter={StaticResource NullToVisibilityConverter}}"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Orientation="Horizontal"
Spacing="12">
<FontIcon Glyph="&#xE735;"
FontSize="22"
Foreground="{ThemeResource SystemAccentColor}" />
<StackPanel Spacing="2">
<TextBlock Text="{Binding Source={StaticResource Strings}, Path=Top250SoftwareLabel}"
FontSize="16"
FontWeight="SemiBold" />
<TextBlock Text="{Binding OverallRanking.EntryCount}"
FontSize="12"
Foreground="{ThemeResource SystemBaseMediumColor}" />
</StackPanel>
</StackPanel>
</Button>
<!-- Grouped dimensions -->
<ItemsControl ItemsSource="{Binding RankingGroups}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical"
Spacing="16" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="8">
<TextBlock Text="{Binding Title}"
FontSize="16"
FontWeight="SemiBold"
Foreground="{ThemeResource SystemBaseMediumColor}" />
<ItemsControl ItemsSource="{Binding Entries}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical"
Spacing="6" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Padding="12,8"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
Command="{Binding DataContext.NavigateToRankingCommand, ElementName=PageRoot}"
CommandParameter="{Binding}"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="6">
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding DimensionName}"
FontSize="14"
TextTrimming="CharacterEllipsis" />
<TextBlock Grid.Column="1"
Text="{Binding EntryCount}"
FontSize="12"
Foreground="{ThemeResource SystemBaseMediumColor}" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</Grid>
</Grid>
</Grid>
</Page>

View File

@@ -0,0 +1,13 @@
#nullable enable
using Microsoft.UI.Xaml.Controls;
namespace Marechai.App.Presentation.Views;
public sealed partial class SoftwareRankingsPage : Page
{
public SoftwareRankingsPage()
{
InitializeComponent();
}
}

View File

@@ -198,6 +198,40 @@
</Border>
</StackPanel>
<!-- Ranking Placements -->
<ItemsControl ItemsSource="{Binding RankingPlacements}"
Visibility="{Binding RankingPlacements.Count, Converter={StaticResource CountToVisibilityConverter}}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"
Spacing="8" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Padding="10,4"
Command="{Binding DataContext.NavigateToRankingCommand, ElementName=PageRoot}"
CommandParameter="{Binding}"
Background="{ThemeResource CardBackgroundFillColorSecondaryBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="12">
<StackPanel Orientation="Horizontal"
Spacing="6">
<FontIcon Glyph="&#xE735;"
FontSize="12"
Foreground="{ThemeResource SystemAccentColor}" />
<TextBlock FontSize="12">
<Run Text="#" />
<Run Text="{Binding Rank}" />
<Run Text="{Binding DimensionName}" />
</TextBlock>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!-- Description -->
<StackPanel Visibility="{Binding ShowDescription}"
Spacing="8">

View File

@@ -107,6 +107,9 @@
<data name="SoftwareButton" xml:space="preserve">
<value>Software</value>
</data>
<data name="RankingsButton" xml:space="preserve">
<value>Ranglisten</value>
</data>
<data name="SoundSynthesizersButton" xml:space="preserve">
<value>Soundsynthesizer</value>
</data>
@@ -2546,4 +2549,19 @@
<data name="ChangePasswordPage.Error.Mismatch" xml:space="preserve"><value>Die Passwörter stimmen nicht überein.</value></data>
<data name="ChangePasswordPage.Error.Generic" xml:space="preserve"><value>Das Passwort konnte nicht geändert werden.</value></data>
<data name="ChangePasswordPage.Success" xml:space="preserve"><value>Passwort erfolgreich geändert.</value></data>
<data name="RankingsTitle" xml:space="preserve"><value>Ranglisten</value></data>
<data name="Top250SoftwareLabel" xml:space="preserve"><value>Top 250 Software aller Zeiten</value></data>
<data name="NoRankedSoftwareLabel" xml:space="preserve"><value>Noch keine bewertete Software.</value></data>
<data name="CriticReviewsCountLabel" xml:space="preserve"><value>Kritikerbewertungen</value></data>
<data name="UserRatingsCountLabel" xml:space="preserve"><value>Nutzerbewertungen</value></data>
<data name="No rankings found" xml:space="preserve"><value>Keine Ranglisten gefunden</value></data>
<data name="Rankings are being computed" xml:space="preserve"><value>Ranglisten werden berechnet</value></data>
<data name="Last computed: {0}" xml:space="preserve"><value>Zuletzt berechnet: {0}</value></data>
<data name="Failed to load rankings. Please try again later." xml:space="preserve"><value>Ranglisten konnten nicht geladen werden. Bitte versuchen Sie es später erneut.</value></data>
<data name="Failed to load ranking. Please try again later." xml:space="preserve"><value>Rangliste konnte nicht geladen werden. Bitte versuchen Sie es später erneut.</value></data>
<data name="By Gameplay" xml:space="preserve"><value>Nach Gameplay</value></data>
<data name="By Genre" xml:space="preserve"><value>Nach Genre</value></data>
<data name="By Setting" xml:space="preserve"><value>Nach Setting</value></data>
<data name="By Category" xml:space="preserve"><value>Nach Kategorie</value></data>
<data name="By Platform" xml:space="preserve"><value>Nach Plattform</value></data>
</root>

View File

@@ -107,6 +107,9 @@
<data name="SoftwareButton" xml:space="preserve">
<value>Software</value>
</data>
<data name="RankingsButton" xml:space="preserve">
<value>Clasificaciones</value>
</data>
<data name="SoundSynthesizersButton" xml:space="preserve">
<value>Sintetizadores de Sonido</value>
</data>
@@ -2686,4 +2689,19 @@
<data name="ChangePasswordPage.Error.Mismatch" xml:space="preserve"><value>Las contraseñas no coinciden.</value></data>
<data name="ChangePasswordPage.Error.Generic" xml:space="preserve"><value>No se pudo cambiar la contraseña.</value></data>
<data name="ChangePasswordPage.Success" xml:space="preserve"><value>Contraseña cambiada correctamente.</value></data>
<data name="RankingsTitle" xml:space="preserve"><value>Clasificaciones</value></data>
<data name="Top250SoftwareLabel" xml:space="preserve"><value>Los 250 mejores programas de todos los tiempos</value></data>
<data name="NoRankedSoftwareLabel" xml:space="preserve"><value>Todavía no hay software clasificado.</value></data>
<data name="CriticReviewsCountLabel" xml:space="preserve"><value>reseñas de críticos</value></data>
<data name="UserRatingsCountLabel" xml:space="preserve"><value>valoraciones de usuarios</value></data>
<data name="No rankings found" xml:space="preserve"><value>No se encontraron clasificaciones</value></data>
<data name="Rankings are being computed" xml:space="preserve"><value>Las clasificaciones se están calculando</value></data>
<data name="Last computed: {0}" xml:space="preserve"><value>Última actualización: {0}</value></data>
<data name="Failed to load rankings. Please try again later." xml:space="preserve"><value>No se pudieron cargar las clasificaciones. Inténtelo de nuevo más tarde.</value></data>
<data name="Failed to load ranking. Please try again later." xml:space="preserve"><value>No se pudo cargar la clasificación. Inténtelo de nuevo más tarde.</value></data>
<data name="By Gameplay" xml:space="preserve"><value>Por jugabilidad</value></data>
<data name="By Genre" xml:space="preserve"><value>Por género</value></data>
<data name="By Setting" xml:space="preserve"><value>Por ambientación</value></data>
<data name="By Category" xml:space="preserve"><value>Por categoría</value></data>
<data name="By Platform" xml:space="preserve"><value>Por plataforma</value></data>
</root>

View File

@@ -107,6 +107,9 @@
<data name="SoftwareButton" xml:space="preserve">
<value>Logiciels</value>
</data>
<data name="RankingsButton" xml:space="preserve">
<value>Classements</value>
</data>
<data name="SoundSynthesizersButton" xml:space="preserve">
<value>Synthétiseurs de Son</value>
</data>
@@ -2650,4 +2653,19 @@
<data name="ChangePasswordPage.Error.Mismatch" xml:space="preserve"><value>Les mots de passe ne correspondent pas.</value></data>
<data name="ChangePasswordPage.Error.Generic" xml:space="preserve"><value>Impossible de changer le mot de passe.</value></data>
<data name="ChangePasswordPage.Success" xml:space="preserve"><value>Mot de passe changé avec succès.</value></data>
<data name="RankingsTitle" xml:space="preserve"><value>Classements</value></data>
<data name="Top250SoftwareLabel" xml:space="preserve"><value>Top 250 des logiciels de tous les temps</value></data>
<data name="NoRankedSoftwareLabel" xml:space="preserve"><value>Aucun logiciel classé pour le moment.</value></data>
<data name="CriticReviewsCountLabel" xml:space="preserve"><value>critiques</value></data>
<data name="UserRatingsCountLabel" xml:space="preserve"><value>évaluations d'utilisateurs</value></data>
<data name="No rankings found" xml:space="preserve"><value>Aucun classement trouvé</value></data>
<data name="Rankings are being computed" xml:space="preserve"><value>Les classements sont en cours de calcul</value></data>
<data name="Last computed: {0}" xml:space="preserve"><value>Dernier calcul : {0}</value></data>
<data name="Failed to load rankings. Please try again later." xml:space="preserve"><value>Impossible de charger les classements. Veuillez réessayer plus tard.</value></data>
<data name="Failed to load ranking. Please try again later." xml:space="preserve"><value>Impossible de charger le classement. Veuillez réessayer plus tard.</value></data>
<data name="By Gameplay" xml:space="preserve"><value>Par gameplay</value></data>
<data name="By Genre" xml:space="preserve"><value>Par genre</value></data>
<data name="By Setting" xml:space="preserve"><value>Par univers</value></data>
<data name="By Category" xml:space="preserve"><value>Par catégorie</value></data>
<data name="By Platform" xml:space="preserve"><value>Par plateforme</value></data>
</root>

View File

@@ -107,6 +107,9 @@
<data name="SoftwareButton" xml:space="preserve">
<value>Software</value>
</data>
<data name="RankingsButton" xml:space="preserve">
<value>Classificações</value>
</data>
<data name="SoundSynthesizersButton" xml:space="preserve">
<value>Sintetizadores de Som</value>
</data>
@@ -2546,4 +2549,19 @@
<data name="ChangePasswordPage.Error.Mismatch" xml:space="preserve"><value>As senhas não coincidem.</value></data>
<data name="ChangePasswordPage.Error.Generic" xml:space="preserve"><value>Não foi possível alterar a senha.</value></data>
<data name="ChangePasswordPage.Success" xml:space="preserve"><value>Senha alterada com sucesso.</value></data>
<data name="RankingsTitle" xml:space="preserve"><value>Classificações</value></data>
<data name="Top250SoftwareLabel" xml:space="preserve"><value>Top 250 softwares de todos os tempos</value></data>
<data name="NoRankedSoftwareLabel" xml:space="preserve"><value>Ainda não há software classificado.</value></data>
<data name="CriticReviewsCountLabel" xml:space="preserve"><value>avaliações de críticos</value></data>
<data name="UserRatingsCountLabel" xml:space="preserve"><value>avaliações de usuários</value></data>
<data name="No rankings found" xml:space="preserve"><value>Nenhuma classificação encontrada</value></data>
<data name="Rankings are being computed" xml:space="preserve"><value>As classificações estão sendo calculadas</value></data>
<data name="Last computed: {0}" xml:space="preserve"><value>Última atualização: {0}</value></data>
<data name="Failed to load rankings. Please try again later." xml:space="preserve"><value>Não foi possível carregar as classificações. Tente novamente mais tarde.</value></data>
<data name="Failed to load ranking. Please try again later." xml:space="preserve"><value>Não foi possível carregar a classificação. Tente novamente mais tarde.</value></data>
<data name="By Gameplay" xml:space="preserve"><value>Por jogabilidade</value></data>
<data name="By Genre" xml:space="preserve"><value>Por gênero</value></data>
<data name="By Setting" xml:space="preserve"><value>Por ambientação</value></data>
<data name="By Category" xml:space="preserve"><value>Por categoria</value></data>
<data name="By Platform" xml:space="preserve"><value>Por plataforma</value></data>
</root>

View File

@@ -107,6 +107,9 @@
<data name="SoftwareButton" xml:space="preserve">
<value>Software</value>
</data>
<data name="RankingsButton" xml:space="preserve">
<value>Rankings</value>
</data>
<data name="SoundSynthesizersButton" xml:space="preserve">
<value>Sound Synthesizers</value>
</data>
@@ -3076,4 +3079,19 @@
<data name="ChangePasswordPage.Error.Mismatch" xml:space="preserve"><value>Passwords do not match.</value></data>
<data name="ChangePasswordPage.Error.Generic" xml:space="preserve"><value>Failed to change password.</value></data>
<data name="ChangePasswordPage.Success" xml:space="preserve"><value>Password changed successfully.</value></data>
<data name="RankingsTitle" xml:space="preserve"><value>Rankings</value></data>
<data name="Top250SoftwareLabel" xml:space="preserve"><value>Top 250 Software of All Time</value></data>
<data name="NoRankedSoftwareLabel" xml:space="preserve"><value>No ranked software yet.</value></data>
<data name="CriticReviewsCountLabel" xml:space="preserve"><value>critic reviews</value></data>
<data name="UserRatingsCountLabel" xml:space="preserve"><value>user ratings</value></data>
<data name="No rankings found" xml:space="preserve"><value>No rankings found</value></data>
<data name="Rankings are being computed" xml:space="preserve"><value>Rankings are being computed</value></data>
<data name="Last computed: {0}" xml:space="preserve"><value>Last computed: {0}</value></data>
<data name="Failed to load rankings. Please try again later." xml:space="preserve"><value>Failed to load rankings. Please try again later.</value></data>
<data name="Failed to load ranking. Please try again later." xml:space="preserve"><value>Failed to load ranking. Please try again later.</value></data>
<data name="By Gameplay" xml:space="preserve"><value>By Gameplay</value></data>
<data name="By Genre" xml:space="preserve"><value>By Genre</value></data>
<data name="By Setting" xml:space="preserve"><value>By Setting</value></data>
<data name="By Category" xml:space="preserve"><value>By Category</value></data>
<data name="By Platform" xml:space="preserve"><value>By Platform</value></data>
</root>

View File

@@ -723,6 +723,11 @@ public class LocalizedStrings
// Software user-facing pages
public string AllSoftwareButton => _l["AllSoftwareButton"];
public string RankingsTitle => _l["RankingsTitle"];
public string Top250SoftwareLabel => _l["Top250SoftwareLabel"];
public string NoRankedSoftwareLabel => _l["NoRankedSoftwareLabel"];
public string CriticReviewsCountLabel => _l["CriticReviewsCountLabel"];
public string UserRatingsCountLabel => _l["UserRatingsCountLabel"];
public string SoftwareLabel => _l["SoftwareLabel"];
public string SoftwareErrorInfoBar_Title => _l["SoftwareErrorInfoBar_Title"];
public string SoftwareFamilyLabel => _l["SoftwareFamilyLabel"];

View File

@@ -983,6 +983,69 @@ public class SoftwareBrowsingService
}
}
public async Task<RankingIndexResponseDto?> GetRankingsIndexAsync()
{
try
{
string lang = GetIso639CodeFromCulture();
_logger.LogInformation("Fetching rankings index");
return await _apiClient.Software.Rankings.Index.GetAsync(config =>
{
config.QueryParameters.Lang = lang;
});
}
catch(Exception ex)
{
_logger.LogError(ex, "Error fetching rankings index");
return null;
}
}
public async Task<List<SoftwareRankingDto>> GetRankingAsync(int rankingId)
{
try
{
_logger.LogInformation("Fetching ranking {RankingId}", rankingId);
List<SoftwareRankingDto>? entries = await _apiClient.Software.Rankings[rankingId].GetAsync();
return entries ?? [];
}
catch(Exception ex)
{
_logger.LogError(ex, "Error fetching ranking {RankingId}", rankingId);
return [];
}
}
public async Task<List<SoftwareRankingPlacementDto>> GetRankingPlacementsAsync(int softwareId)
{
try
{
string lang = GetIso639CodeFromCulture();
_logger.LogInformation("Fetching ranking placements for software {SoftwareId}", softwareId);
List<SoftwareRankingPlacementDto>? placements = await _apiClient.Software[softwareId].Rankings.GetAsync(
config =>
{
config.QueryParameters.Lang = lang;
});
return placements ?? [];
}
catch(Exception ex)
{
_logger.LogError(ex, "Error fetching ranking placements for software {SoftwareId}", softwareId);
return [];
}
}
private static string GetIso639CodeFromCulture()
{
string twoLetter = System.Globalization.CultureInfo.CurrentUICulture.TwoLetterISOLanguageName;