Skip to content

Commit 9d1f0d6

Browse files
authored
Merge pull request #3819 from Nexus-Mods/new-downloads-page
Added: Boilerplate/Skeleton of the Downloads Page
2 parents 1fdbe21 + 839540d commit 9d1f0d6

22 files changed

+1342
-1988
lines changed

src/NexusMods.App.UI/Controls/MiniGameWidget/Standard/MiniGameWidgetViewModel.cs

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
using NexusMods.Abstractions.Games;
88
using NexusMods.Abstractions.Settings;
99
using NexusMods.Abstractions.UI;
10+
using NexusMods.App.UI.Controls;
11+
using NexusMods.App.UI.Helpers;
1012
using NexusMods.CrossPlatform.Process;
1113
using ReactiveUI;
1214
using ReactiveUI.Fody.Helpers;
@@ -28,7 +30,7 @@ public MiniGameWidgetViewModel(ILogger<MiniGameWidgetViewModel> logger,
2830
.WhenAnyValue(vm => vm.Game)
2931
.Where(game => game is not null)
3032
.OffUi()
31-
.SelectMany(LoadImage)
33+
.SelectMany(game => ImageHelper.LoadGameIconAsync(game, (int)ImageSizes.GameThumbnail.Width, _logger))
3234
.WhereNotNull()
3335
.ToProperty(this, vm => vm.Image, scheduler: RxApp.MainThreadScheduler);
3436

@@ -45,22 +47,7 @@ public MiniGameWidgetViewModel(ILogger<MiniGameWidgetViewModel> logger,
4547
);
4648
}
4749

48-
private async Task<Bitmap?> LoadImage(IGame? game)
49-
{
50-
if (game is null)
51-
return null;
5250

53-
try
54-
{
55-
var iconStream = await game.Icon.GetStreamAsync();
56-
return Bitmap.DecodeToWidth(iconStream, (int)ImageSizes.GameThumbnail.Width);
57-
}
58-
catch (Exception ex)
59-
{
60-
_logger.LogError(ex, "While loading game image for {GameName}", game.Name);
61-
return null;
62-
}
63-
}
6451

6552
[Reactive] public IGame? Game { get; set; }
6653
public GameInstallation[]? GameInstallations { get; set; }

src/NexusMods.App.UI/Controls/Spine/SpineViewModel.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
using NexusMods.App.UI.Controls.Spine.Buttons.Icon;
1919
using NexusMods.App.UI.Controls.Spine.Buttons.Image;
2020
using NexusMods.App.UI.LeftMenu;
21+
using NexusMods.App.UI.Pages.Downloads;
2122
using NexusMods.App.UI.Pages.LoadoutPage;
2223
using NexusMods.App.UI.Pages.MyGames;
2324
using NexusMods.App.UI.Resources;
@@ -84,6 +85,7 @@ public SpineViewModel(
8485
Downloads = spineDownloadsButtonViewModel;
8586
Downloads.WorkspaceContext = new DownloadsContext();
8687
_specialSpineItems.Add(Downloads);
88+
Downloads.Click = ReactiveCommand.Create(NavigateToDownloads);
8789

8890
var workspaceController = windowManager.ActiveWorkspaceController;
8991

@@ -281,6 +283,18 @@ private void NavigateToMyGames()
281283
workspaceController.OpenPage(ws.Id, pageData, behavior);
282284
}
283285

286+
private void NavigateToDownloads()
287+
{
288+
var workspaceController = _windowManager.ActiveWorkspaceController;
289+
290+
workspaceController.ChangeOrCreateWorkspaceByContext<DownloadsContext>(() => new PageData
291+
{
292+
FactoryId = DownloadsPageFactory.StaticId,
293+
Context = new AllDownloadsPageContext()
294+
}
295+
);
296+
}
297+
284298
private class LoadoutSpineEntriesComparer : IComparer<IImageButtonViewModel>
285299
{
286300
public int Compare(IImageButtonViewModel? x, IImageButtonViewModel? y)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using Avalonia.Media.Imaging;
2+
using Microsoft.Extensions.Logging;
3+
using NexusMods.Abstractions.Games;
4+
using NexusMods.UI.Sdk.Icons;
5+
6+
namespace NexusMods.App.UI.Helpers;
7+
8+
/// <summary>
9+
/// Shared utility for loading game images and creating IconValues.
10+
/// </summary>
11+
public static class ImageHelper
12+
{
13+
/// <summary>
14+
/// Loads a game icon as a Bitmap from the game's icon stream.
15+
/// </summary>
16+
/// <param name="game">The game to load the icon for.</param>
17+
/// <param name="width">The width to decode the image to.</param>
18+
/// <param name="logger">Logger for error handling.</param>
19+
/// <returns>A Bitmap if successful, null otherwise.</returns>
20+
public static async Task<Bitmap?> LoadGameIconAsync(IGame? game, int width, ILogger logger)
21+
{
22+
if (game is null)
23+
return null;
24+
25+
try
26+
{
27+
await using var iconStream = await game.Icon.GetStreamAsync();
28+
return Bitmap.DecodeToWidth(iconStream, width);
29+
}
30+
catch (Exception ex)
31+
{
32+
logger.LogError(ex, "While loading game image for {GameName}", game.Name);
33+
return null;
34+
}
35+
}
36+
37+
/// <summary>
38+
/// Creates an IconValue from a Bitmap with fallback handling.
39+
/// </summary>
40+
/// <param name="bitmap">The bitmap to convert to an IconValue.</param>
41+
/// <param name="fallback">The fallback IconValue to use if bitmap is null.</param>
42+
/// <returns>An IconValue containing the bitmap or the fallback.</returns>
43+
public static IconValue CreateIconValueFromBitmap(Bitmap? bitmap, IconValue fallback)
44+
{
45+
if (bitmap != null)
46+
return new AvaloniaImage(bitmap);
47+
48+
return fallback;
49+
}
50+
}

src/NexusMods.App.UI/ImageSizes.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ namespace NexusMods.App.UI;
1010
/// </summary>
1111
public static class ImageSizes
1212
{
13+
/// <summary>
14+
/// Size for the LeftMenu icon, typically used in LeftMenu items
15+
/// </summary>
16+
public static readonly Size LeftMenuIcon = new(24, 24);
17+
1318
/// <summary>
1419
/// Size for the game icon, typically used in Spine and MyLoadouts
1520
/// </summary>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Collections.ObjectModel;
2+
using NexusMods.Abstractions.UI;
3+
using NexusMods.App.UI.Controls;
4+
using NexusMods.App.UI.LeftMenu.Items;
5+
using NexusMods.App.UI.Resources;
6+
using NexusMods.App.UI.WorkspaceSystem;
7+
using NexusMods.UI.Sdk.Icons;
8+
9+
namespace NexusMods.App.UI.LeftMenu.Downloads;
10+
11+
public class DownloadsLeftMenuDesignViewModel : AViewModel<IDownloadsLeftMenuViewModel>, IDownloadsLeftMenuViewModel
12+
{
13+
public WorkspaceId WorkspaceId { get; } = WorkspaceId.NewId();
14+
15+
public ILeftMenuItemViewModel LeftMenuItemAllDownloads { get; } = new LeftMenuItemDesignViewModel
16+
{
17+
Text = new StringComponent(Language.DownloadsLeftMenu_AllDownloads),
18+
Icon = IconValues.Download,
19+
};
20+
21+
public ReadOnlyObservableCollection<ILeftMenuItemViewModel> LeftMenuItemsPerGameDownloads { get; }
22+
23+
public ILeftMenuItemViewModel LeftMenuItemAllCompleted { get; } = new LeftMenuItemDesignViewModel
24+
{
25+
Text = new StringComponent(Language.DownloadsLeftMenu_AllCompleted),
26+
Icon = IconValues.CheckCircle,
27+
};
28+
29+
public DownloadsLeftMenuDesignViewModel()
30+
{
31+
var perGameItems = new[]
32+
{
33+
new LeftMenuItemDesignViewModel
34+
{
35+
Text = new StringComponent(string.Format(Language.DownloadsLeftMenu_GameSpecificDownloads, "Stardew Valley")),
36+
Icon = IconValues.FolderEditOutline,
37+
},
38+
new LeftMenuItemDesignViewModel
39+
{
40+
Text = new StringComponent(string.Format(Language.DownloadsLeftMenu_GameSpecificDownloads, "Cyberpunk 2077")),
41+
Icon = IconValues.FolderEditOutline,
42+
},
43+
new LeftMenuItemDesignViewModel
44+
{
45+
Text = new StringComponent(string.Format(Language.DownloadsLeftMenu_GameSpecificDownloads, "Skyrim")),
46+
Icon = IconValues.FolderEditOutline,
47+
},
48+
};
49+
50+
LeftMenuItemsPerGameDownloads = new ReadOnlyObservableCollection<ILeftMenuItemViewModel>(
51+
new ObservableCollection<ILeftMenuItemViewModel>(perGameItems));
52+
}
53+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using NexusMods.App.UI.WorkspaceSystem;
2+
3+
namespace NexusMods.App.UI.LeftMenu.Downloads;
4+
5+
public class DownloadsLeftMenuFactory(IServiceProvider serviceProvider) : ILeftMenuFactory<DownloadsContext>
6+
{
7+
public ILeftMenuViewModel CreateLeftMenuViewModel(DownloadsContext context, WorkspaceId workspaceId,
8+
IWorkspaceController workspaceController)
9+
{
10+
return new DownloadsLeftMenuViewModel(workspaceId, workspaceController, serviceProvider);
11+
}
12+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<reactiveUi:ReactiveUserControl x:TypeArguments="downloads:IDownloadsLeftMenuViewModel"
2+
xmlns="https://github.com/avaloniaui"
3+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
5+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
6+
xmlns:reactiveUi="http://reactiveui.net"
7+
xmlns:downloads="clr-namespace:NexusMods.App.UI.LeftMenu.Downloads"
8+
xmlns:items="clr-namespace:NexusMods.App.UI.LeftMenu.Items"
9+
mc:Ignorable="d" d:DesignWidth="200" d:DesignHeight="550"
10+
x:Class="NexusMods.App.UI.LeftMenu.Downloads.DownloadsLeftMenuView">
11+
<Design.DataContext>
12+
<downloads:DownloadsLeftMenuDesignViewModel />
13+
</Design.DataContext>
14+
15+
<ScrollViewer>
16+
<StackPanel Margin="12,0,12,12" x:Name="LeftMenuStack">
17+
<items:LeftMenuItemView x:Name="AllDownloadsItem" />
18+
19+
<!-- per-game downloads section -->
20+
<ItemsControl x:Name="PerGameDownloadsItemsControl">
21+
<ItemsControl.ItemsPanel>
22+
<ItemsPanelTemplate>
23+
<StackPanel Spacing="{StaticResource Spacing-0.5}" />
24+
</ItemsPanelTemplate>
25+
</ItemsControl.ItemsPanel>
26+
</ItemsControl>
27+
28+
<!-- completed downloads -->
29+
<items:LeftMenuItemView x:Name="AllCompletedItem" />
30+
31+
</StackPanel>
32+
</ScrollViewer>
33+
34+
</reactiveUi:ReactiveUserControl>
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System.Reactive.Disposables;
2+
using Avalonia.ReactiveUI;
3+
using JetBrains.Annotations;
4+
using NexusMods.App.UI.Resources;
5+
using ReactiveUI;
6+
7+
namespace NexusMods.App.UI.LeftMenu.Downloads;
8+
9+
[UsedImplicitly]
10+
public partial class DownloadsLeftMenuView : ReactiveUserControl<IDownloadsLeftMenuViewModel>
11+
{
12+
public DownloadsLeftMenuView()
13+
{
14+
InitializeComponent();
15+
16+
this.WhenActivated(d =>
17+
{
18+
this.OneWayBind(ViewModel, vm => vm.LeftMenuItemAllDownloads, view => view.AllDownloadsItem.ViewModel)
19+
.DisposeWith(d);
20+
21+
this.OneWayBind(ViewModel, vm => vm.LeftMenuItemsPerGameDownloads, view => view.PerGameDownloadsItemsControl.ItemsSource)
22+
.DisposeWith(d);
23+
24+
this.OneWayBind(ViewModel, vm => vm.LeftMenuItemAllCompleted, view => view.AllCompletedItem.ViewModel)
25+
.DisposeWith(d);
26+
});
27+
}
28+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using System.Collections.ObjectModel;
2+
using System.Reactive.Disposables;
3+
using System.Reactive.Linq;
4+
using DynamicData;
5+
using DynamicData.Binding;
6+
using JetBrains.Annotations;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Logging;
9+
using NexusMods.Abstractions.GameLocators;
10+
using NexusMods.Abstractions.Games;
11+
using NexusMods.Abstractions.UI;
12+
using NexusMods.App.UI.Controls;
13+
using NexusMods.App.UI.Helpers;
14+
using NexusMods.App.UI.LeftMenu.Items;
15+
using NexusMods.App.UI.Pages.Downloads;
16+
using NexusMods.App.UI.Resources;
17+
using NexusMods.App.UI.WorkspaceSystem;
18+
using NexusMods.UI.Sdk.Icons;
19+
using ReactiveUI;
20+
21+
namespace NexusMods.App.UI.LeftMenu.Downloads;
22+
23+
[UsedImplicitly]
24+
public class DownloadsLeftMenuViewModel : AViewModel<IDownloadsLeftMenuViewModel>, IDownloadsLeftMenuViewModel
25+
{
26+
public WorkspaceId WorkspaceId { get; }
27+
public ILeftMenuItemViewModel LeftMenuItemAllDownloads { get; }
28+
public ILeftMenuItemViewModel LeftMenuItemAllCompleted { get; }
29+
30+
private ReadOnlyObservableCollection<ILeftMenuItemViewModel> _leftMenuItemsPerGameDownloads = new([]);
31+
public ReadOnlyObservableCollection<ILeftMenuItemViewModel> LeftMenuItemsPerGameDownloads => _leftMenuItemsPerGameDownloads;
32+
33+
private readonly ILogger<DownloadsLeftMenuViewModel> _logger;
34+
35+
public DownloadsLeftMenuViewModel(
36+
WorkspaceId workspaceId,
37+
IWorkspaceController workspaceController,
38+
IServiceProvider serviceProvider)
39+
{
40+
WorkspaceId = workspaceId;
41+
_logger = serviceProvider.GetRequiredService<ILogger<DownloadsLeftMenuViewModel>>();
42+
43+
var gameRegistry = serviceProvider.GetRequiredService<IGameRegistry>();
44+
45+
// All Downloads menu item
46+
LeftMenuItemAllDownloads = new LeftMenuItemViewModel(
47+
workspaceController,
48+
WorkspaceId,
49+
new PageData
50+
{
51+
FactoryId = DownloadsPageFactory.StaticId,
52+
Context = new AllDownloadsPageContext(),
53+
}
54+
)
55+
{
56+
Text = new StringComponent(Language.DownloadsLeftMenu_AllDownloads),
57+
Icon = IconValues.Download,
58+
};
59+
60+
// All Completed menu item
61+
LeftMenuItemAllCompleted = new LeftMenuItemViewModel(
62+
workspaceController,
63+
WorkspaceId,
64+
new PageData
65+
{
66+
FactoryId = DownloadsPageFactory.StaticId,
67+
Context = new CompletedDownloadsPageContext(), // TODO: Add completed filter context when implemented
68+
}
69+
)
70+
{
71+
Text = new StringComponent(Language.DownloadsLeftMenu_AllCompleted),
72+
Icon = IconValues.CheckCircle,
73+
};
74+
75+
// Per-game downloads (dynamic)
76+
this.WhenActivated(disposable =>
77+
{
78+
gameRegistry.InstalledGames
79+
.ToObservableChangeSet()
80+
.Transform(gameInstallation => CreatePerGameDownloadItem(gameInstallation, workspaceController, workspaceId, _logger))
81+
.DisposeMany()
82+
.OnUI()
83+
.Bind(out _leftMenuItemsPerGameDownloads)
84+
.Subscribe()
85+
.DisposeWith(disposable);
86+
});
87+
}
88+
89+
private static ILeftMenuItemViewModel CreatePerGameDownloadItem(
90+
GameInstallation gameInstallation,
91+
IWorkspaceController workspaceController,
92+
WorkspaceId workspaceId,
93+
ILogger<DownloadsLeftMenuViewModel> logger)
94+
{
95+
// TODO: Replace with proper game-specific context when per-game filtering is implemented
96+
var viewModel = new LeftMenuItemViewModel(
97+
workspaceController,
98+
workspaceId,
99+
new PageData
100+
{
101+
FactoryId = DownloadsPageFactory.StaticId,
102+
Context = new GameSpecificDownloadsPageContext(gameInstallation.Game.GameId), // TODO: Add game filter context
103+
}
104+
)
105+
{
106+
Text = new StringComponent(string.Format(Language.DownloadsLeftMenu_GameSpecificDownloads, gameInstallation.Game.Name)),
107+
Icon = IconValues.FolderEditOutline, // Initial fallback icon
108+
};
109+
110+
// Load game icon asynchronously
111+
Observable.FromAsync(() => ImageHelper.LoadGameIconAsync((IGame)gameInstallation.Game, (int)ImageSizes.LeftMenuIcon.Width, logger))
112+
.ObserveOn(RxApp.MainThreadScheduler)
113+
.Subscribe(bitmap => viewModel.Icon = ImageHelper.CreateIconValueFromBitmap(bitmap, IconValues.FolderEditOutline));
114+
115+
return viewModel;
116+
}
117+
}

0 commit comments

Comments
 (0)