Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Microsoft.Extensions.DependencyInjection;
using WarframeMarketTracker.Controls;
using WarframeMarketTracker.Services;
using WarframeMarketTracker.Views;

Expand Down Expand Up @@ -38,6 +39,8 @@ public override void OnFrameworkInitializationCompleted()
var notificationService = _services.GetRequiredService<INotificationService>();
notificationService.Initialize();

CachedImage.Initialize(_services.GetRequiredService<IThumbnailCache>());

SetupTrayIcon(desktop, mainWindow);
FixTrayMenuPosition();
}
Expand Down
41 changes: 41 additions & 0 deletions Controls/CachedImage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using Avalonia;
using Avalonia.Controls;
using WarframeMarketTracker.Services;

namespace WarframeMarketTracker.Controls;

public class CachedImage
{
private static IThumbnailCache? _cache;

public static readonly AttachedProperty<string?> ThumbPathProperty =
AvaloniaProperty.RegisterAttached<CachedImage, Image, string?>("ThumbPath");

static CachedImage()
{
ThumbPathProperty.Changed.AddClassHandler<Image>(OnThumbPathChanged);
}

public static void Initialize(IThumbnailCache cache) => _cache = cache;

public static string? GetThumbPath(Image obj) => obj.GetValue(ThumbPathProperty);
public static void SetThumbPath(Image obj, string? value) => obj.SetValue(ThumbPathProperty, value);

private CachedImage() { }

private static async void OnThumbPathChanged(Image image, AvaloniaPropertyChangedEventArgs e)
{
// Clear immediately so a recycled container doesn't briefly show the previous item's thumb
image.Source = null;

var path = e.GetNewValue<string?>();
if (string.IsNullOrEmpty(path) || _cache == null) return;

var bitmap = await _cache.GetAsync(path);
if (bitmap == null) return;

// Container may have been recycled to a different item while loading
if (GetThumbPath(image) != path) return;
image.Source = bitmap;
}
}
10 changes: 10 additions & 0 deletions Models/MarketOffer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace WarframeMarketTracker.Models;

public record MarketOffer(
string Slug,
string ItemName,
string OrderId,
int Platinum,
int TargetPlatinum,
string SellerName,
string Whisper);
4 changes: 3 additions & 1 deletion Models/Items.cs → Models/WfmResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ public record ItemShort(
)
{
public string EnglishName => I18N.TryGetValue("en", out var en) ? en.Name : Slug;

public string? Thumb => I18N.TryGetValue("en", out var en) ? en.SubIcon ?? en.Icon : null;
}

public record ItemI18N(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("icon")] string Icon,
[property: JsonPropertyName("thumb")] string Thumb
[property: JsonPropertyName("subIcon")] string? SubIcon
);
67 changes: 20 additions & 47 deletions Program.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
using System;
using System.Net.Http;
using Avalonia;
using Avalonia.Labs.Notifications;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using WarframeMarketTracker.Services;
using WarframeMarketTracker.ViewModels;
Expand All @@ -16,8 +14,6 @@ namespace WarframeMarketTracker;
internal static class Program
{
public const string AppName = "Warframe Market Tracker";
public const string NotificationChannelId = "market_alerts";
public const string AppUserModelId = "com.warframe.market.tracker";

[STAThread]
public static void Main(string[] args)
Expand All @@ -43,18 +39,22 @@ public static void Main(string[] args)
.UseSerilog()
.ConfigureServices((_, services) =>
{
const string httpClientName = "WfmApi";
const string warframeMarketEndpoint = "https://api.warframe.market/v2/";
const string userAgent = $"{nameof(WarframeMarketTracker)}/{BuildInfo.AppVersion}";

// 1. Setup named HTTP Clients
services.AddHttpClient(httpClientName, c =>
services.AddHttpClient("WfmApi", c =>
{
c.BaseAddress = new Uri(warframeMarketEndpoint);
c.BaseAddress = new Uri("https://api.warframe.market/v2/");
c.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
});

services.AddHttpClient("WfmAssets", c =>
{
c.BaseAddress = new Uri("https://warframe.market/static/assets/");
c.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
});

services.AddHttpClient("GitHub", c =>
{
c.BaseAddress = new Uri("https://api.github.com/");
Expand All @@ -63,42 +63,26 @@ public static void Main(string[] args)
c.Timeout = TimeSpan.FromSeconds(5);
});

// 2. Business Logic & API
services.AddTransient<IWarframeMarketService>(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient(httpClientName);
var logger = sp.GetRequiredService<ILogger<WarframeMarketService>>();
return new WarframeMarketService(client, logger);
});

// 3. The Cache (must be a singleton or why else cache)
services.AddSingleton<IItemCache>(sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var client = factory.CreateClient(httpClientName);
return new ItemCache(client);
});

// 3b. Thread-safe registry for tracked items (shared between UI and poller)
// 2. Services & State
services.AddTransient<IWarframeMarketService, WarframeMarketService>();
services.AddSingleton<IItemCache, ItemCache>();
services.AddSingleton<ITrackedItemRegistry, TrackedItemRegistry>();
services.AddSingleton<ITrackedItemStore, TrackedItemStore>();
services.AddSingleton<IThumbnailCache, ThumbnailCache>();

// 4. Background Services
// 3. Background Services
services.AddHostedService<ItemCacheHydrationService>();
services.AddHostedService<MarketPollingService>();

// 5. Components & UI
// 4. Components & UI
services.AddSingleton<IUserInterfaceNotificationService, UserInterfaceNotificationService>();
services.AddSingleton<INotificationService, NativeNotificationService>();
services.AddSingleton<IDialogService, DialogService>();
services.AddTransient<AboutWindowViewModel>();
services.AddSingleton<Func<AboutWindowViewModel>>(sp => sp.GetRequiredService<AboutWindowViewModel>);
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<MainWindow>(sp =>
{
var vm = sp.GetRequiredService<MainWindowViewModel>();
var window = new MainWindow { DataContext = vm };
vm.Owner = window;
return window;
});
services.AddSingleton(sp => new Lazy<MainWindowViewModel>(sp.GetRequiredService<MainWindowViewModel>));
services.AddSingleton<MainWindow>();
})
.Build();

Expand All @@ -120,24 +104,13 @@ public static void Main(string[] args)
}
}

private static readonly NotificationChannel[] NotificationChannels =
[
new(NotificationChannelId, "Warframe Market Alerts", NotificationPriority.High)
];

private static AppBuilder BuildAvaloniaApp(IServiceProvider services)
=> AppBuilder.Configure(() => new App(services))
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.WithAppNotifications(new AppNotificationOptions
{
Channels = NotificationChannels,
AppName = AppName,
// Required for Windows Toast notifications to work correctly
AppUserModelId = AppUserModelId
})
.WithAppNotifications(NativeNotificationService.AppNotificationOptions)
.LogToTrace();
}
36 changes: 36 additions & 0 deletions Services/DialogService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Threading.Tasks;
using WarframeMarketTracker.ViewModels;
using WarframeMarketTracker.Views;

namespace WarframeMarketTracker.Services;

public interface IDialogService
{
Task ShowAboutAsync();
}

public class DialogService : IDialogService
{
private readonly Lazy<MainWindowViewModel> _mainViewModel;
private readonly Func<AboutWindowViewModel> _aboutViewModelFactory;

public DialogService(
Lazy<MainWindowViewModel> mainViewModel,
Func<AboutWindowViewModel> aboutViewModelFactory)
{
_mainViewModel = mainViewModel;
_aboutViewModelFactory = aboutViewModelFactory;
}

public async Task ShowAboutAsync()
{
var owner = _mainViewModel.Value.Owner;
if (owner == null) return;

var viewModel = _aboutViewModelFactory();
var window = new AboutWindow { DataContext = viewModel };
_ = viewModel.CheckForUpdateAsync();
await window.ShowDialog(owner);
}
}
23 changes: 0 additions & 23 deletions Services/INotificationService.cs

This file was deleted.

32 changes: 24 additions & 8 deletions Services/ItemCache.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System;
using System.Collections.Frozen;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
Expand All @@ -16,26 +17,41 @@ public interface IItemCache

int Count { get; }

bool TryGetByName(string name, [NotNullWhen(true)] out ItemShort? item);

Task InitializeAsync(CancellationToken ct);
}

public class ItemCache : IItemCache
{
private readonly HttpClient _httpClient;
private FrozenDictionary<string, ItemShort>? _cache;
private FrozenDictionary<string, ItemShort>? _lookup;

public IReadOnlyList<ItemShort> Items => _cache?.Values.ToList() ?? [];
// Materialized once at hydration; the catalog is immutable for the app's lifetime, so callers
// get a stable, pre-sorted snapshot without re-allocating on every binding read.
public IReadOnlyList<ItemShort> Items { get; private set; } = [];

public int Count => Items.Count;

public ItemCache(HttpClient httpClient) => _httpClient = httpClient;
public ItemCache(IHttpClientFactory httpClientFactory) => _httpClient = httpClientFactory.CreateClient("WfmApi");

public async Task InitializeAsync(CancellationToken ct)
public bool TryGetByName(string name, [NotNullWhen(true)] out ItemShort? item)
{
var response = await _httpClient.GetFromJsonAsync<WfmResponse<List<ItemShort>>>("items", ct);
if (response?.Data != null)
if (_lookup is not null && _lookup.TryGetValue(name, out var found))
{
_cache = response.Data.ToFrozenDictionary(i => i.EnglishName, StringComparer.OrdinalIgnoreCase);
item = found;
return true;
}
item = null;
return false;
}

public async Task InitializeAsync(CancellationToken ct)
{
var response = await _httpClient.GetFromJsonAsync<WfmResponse<List<ItemShort>>>("items", ct);
if (response?.Data == null) return;

_lookup = response.Data.ToFrozenDictionary(i => i.EnglishName, StringComparer.OrdinalIgnoreCase);
Items = response.Data.OrderBy(i => i.EnglishName).ToArray();
}
}
Loading
Loading