feat: add settings page

This commit is contained in:
estrogen elf
2025-06-22 16:14:04 -05:00
parent 8845431126
commit 73787608c4
8 changed files with 305 additions and 93 deletions

View File

@@ -3,5 +3,7 @@
public static class BlobCacheKeys
{
public static readonly string GithubLatestRelease = nameof(GithubLatestRelease);
public static readonly string AppSettings = nameof(AppSettings);
}
}

View File

@@ -0,0 +1,13 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace Needlework.Net.DataModels
{
public partial class AppSettings : ObservableObject
{
[ObservableProperty]
private bool _isCheckForUpdates = true;
[ObservableProperty]
private bool _isCheckForSchema = true;
}
}

View File

@@ -30,6 +30,7 @@
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.8" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.3.0" />
<PackageReference Include="BlossomiShymae.Briar" Version="0.2.0" />
<PackageReference Include="bodong.Avalonia.PropertyGrid" Version="11.2.4" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="DebounceThrottle" Version="3.0.1" />
<PackageReference Include="FastCache.Cached" Version="1.8.2" />

View File

@@ -14,6 +14,7 @@ using Needlework.Net.ViewModels.Pages.Console;
using Needlework.Net.ViewModels.Pages.Endpoints;
using Needlework.Net.ViewModels.Pages.Home;
using Needlework.Net.ViewModels.Pages.Schemas;
using Needlework.Net.ViewModels.Pages.Settings;
using Needlework.Net.ViewModels.Pages.WebSocket;
using Needlework.Net.Views.MainWindow;
using Needlework.Net.Views.Pages.About;
@@ -21,6 +22,7 @@ using Needlework.Net.Views.Pages.Console;
using Needlework.Net.Views.Pages.Endpoints;
using Needlework.Net.Views.Pages.Home;
using Needlework.Net.Views.Pages.Schemas;
using Needlework.Net.Views.Pages.Settings;
using Needlework.Net.Views.Pages.WebSocket;
using Projektanker.Icons.Avalonia;
using Projektanker.Icons.Avalonia.FontAwesome;
@@ -97,6 +99,8 @@ class Program
// WEBSOCKET
locator.Register<WebSocketViewModel>(() => new WebSocketView());
locator.Register<EventViewModel>(() => new EventView());
// SETTINGS
locator.Register<SettingsViewModel>(() => new SettingsView());
builder.AddSingleton<IDataTemplate>(locator);
}
@@ -132,6 +136,7 @@ class Program
builder.AddSingleton<PageBase, WebSocketViewModel>();
builder.AddSingleton<PageBase, SchemasViewModel>();
builder.AddSingleton<PageBase, AboutViewModel>();
builder.AddSingleton<PageBase, SettingsViewModel>();
}
private static void Program_UnhandledException(object sender, UnhandledExceptionEventArgs e)

View File

@@ -1,8 +1,6 @@
using Avalonia;
using Avalonia.Media;
using Avalonia.Threading;
using BlossomiShymae.Briar;
using BlossomiShymae.Briar.Utils;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
@@ -11,7 +9,6 @@ using Needlework.Net.Constants;
using Needlework.Net.Extensions;
using Needlework.Net.Helpers;
using Needlework.Net.Messages;
using Needlework.Net.Models;
using Needlework.Net.Services;
using Needlework.Net.ViewModels.Pages;
using Needlework.Net.Views.MainWindow;
@@ -20,8 +17,6 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Net.Http.Json;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -33,25 +28,18 @@ public partial class MainWindowViewModel
{
private readonly DocumentService _documentService;
private readonly GithubService _githubService;
private readonly NotificationService _notificationService;
private readonly DialogService _dialogService;
private readonly SchemaPaneService _schemaPaneService;
private readonly IDisposable _checkForUpdatesDisposable;
private readonly IDisposable _checkForSchemaVersionDisposable;
public MainWindowViewModel(IEnumerable<PageBase> pages, DialogService dialogService, DocumentService documentService, NotificationService notificationService, GithubService githubService, SchemaPaneService schemaPaneService)
public MainWindowViewModel(IEnumerable<PageBase> pages, DialogService dialogService, DocumentService documentService, NotificationService notificationService, SchemaPaneService schemaPaneService)
{
_dialogService = dialogService;
_documentService = documentService;
_notificationService = notificationService;
_schemaPaneService = schemaPaneService;
_githubService = githubService;
NavigationViewItems = pages
.OrderBy(p => p.Index)
@@ -89,42 +77,6 @@ public partial class MainWindowViewModel
}
});
_checkForUpdatesDisposable = Observable.Timer(TimeSpan.Zero, Intervals.CheckForUpdates)
.Select(time => Unit.Default)
.Subscribe(async _ =>
{
try
{
await CheckForUpdatesAsync();
}
catch (Exception ex)
{
var message = "Failed to check for updates. Please check your internet connection or try again later.";
this.Log()
.Error(ex, message);
_notificationService.Notify(AppInfo.Name, message, InfoBarSeverity.Error);
_checkForUpdatesDisposable?.Dispose();
}
});
_checkForSchemaVersionDisposable = Observable.Timer(TimeSpan.Zero, TimeSpan.FromMinutes(5))
.Select(time => Unit.Default)
.Subscribe(async _ =>
{
try
{
await CheckForSchemaVersionAsync();
}
catch (Exception ex)
{
var message = "Failed to check for schema version. Please check your internet connection or try again later.";
this.Log()
.Error(ex, message);
_notificationService.Notify(AppInfo.Name, message, InfoBarSeverity.Error);
_checkForSchemaVersionDisposable?.Dispose();
}
});
WeakReferenceMessenger.Default.RegisterAll(this);
}
@@ -151,8 +103,6 @@ public partial class MainWindowViewModel
public List<NavigationViewItem> NavigationViewItems { get; private set; } = [];
public bool IsSchemaVersionChecked { get; private set; }
public string AppName => AppInfo.Name;
public string Title => $"{AppInfo.Name} {AppInfo.Version}";
@@ -224,48 +174,6 @@ public partial class MainWindowViewModel
}
};
private async Task CheckForUpdatesAsync()
{
var release = await _githubService.GetLatestReleaseAsync();
if (release.IsLatest(AppInfo.Version))
{
this.Log()
.Information("New version available: {TagName}", release.TagName);
_notificationService.Notify(AppInfo.Name, $"New version available: {release.TagName}", InfoBarSeverity.Informational, null, "https://github.com/BlossomiShymae/Needlework.Net/releases/latest");
_checkForUpdatesDisposable?.Dispose();
}
}
private async Task CheckForSchemaVersionAsync()
{
if (!ProcessFinder.IsPortOpen()) return;
var lcuSchemaDocument = await _documentService.GetLcuSchemaDocumentAsync();
var client = Connector.GetLcuHttpClientInstance();
var currentSemVer = lcuSchemaDocument.Info.Version.Split('.');
var systemBuild = await client.GetFromJsonAsync<SystemBuild>("/system/v1/builds") ?? throw new NullReferenceException();
var latestSemVer = systemBuild.Version.Split('.');
if (!IsSchemaVersionChecked)
{
this.Log()
.Information("LCU Schema (current): {Version}", lcuSchemaDocument.Info.Version);
this.Log()
.Information("LCU Schema (latest): {Version}", systemBuild.Version);
IsSchemaVersionChecked = true;
}
bool isVersionMatching = currentSemVer[0] == latestSemVer[0] && currentSemVer[1] == latestSemVer[1]; // Compare major and minor versions
if (!isVersionMatching)
{
this.Log()
.Warning("LCU Schema version mismatch: Current {CurrentVersion}, Latest {LatestVersion}", lcuSchemaDocument.Info.Version, systemBuild.Version);
_notificationService.Notify(AppInfo.Name, $"LCU Schema is possibly outdated compared to latest system build. Consider submitting a pull request on dysolix/hasagi-types.\nCurrent: {string.Join(".", currentSemVer)}\nLatest: {string.Join(".", latestSemVer)}", InfoBarSeverity.Warning, null, "https://github.com/dysolix/hasagi-types#updating-the-types");
_checkForSchemaVersionDisposable?.Dispose();
}
}
public async Task<IEnumerable<object>> PopulateAsync(string? searchText, CancellationToken cancellationToken)
{
if (searchText == null) return [];

View File

@@ -0,0 +1,197 @@
using Akavache;
using Avalonia.Threading;
using BlossomiShymae.Briar;
using BlossomiShymae.Briar.Utils;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using FluentAvalonia.UI.Controls;
using Needlework.Net.Constants;
using Needlework.Net.DataModels;
using Needlework.Net.Extensions;
using Needlework.Net.Models;
using Needlework.Net.Services;
using System;
using System.Net.Http.Json;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading.Tasks;
namespace Needlework.Net.ViewModels.Pages.Settings
{
public partial class SettingsViewModel : PageBase, IEnableLogger
{
private readonly IBlobCache _blobCache;
private readonly IDisposable _checkForUpdatesDisposable;
private readonly IDisposable _checkForSchemaVersionDisposable;
private readonly GithubService _githubService;
private readonly DocumentService _documentService;
private readonly NotificationService _notificationService;
private readonly TaskCompletionSource<bool> _initializeTaskCompletionSource = new();
public SettingsViewModel(IBlobCache blobCache, GithubService githubService, DocumentService documentService, NotificationService notificationService) : base("Settings", "fa-solid fa-gear")
{
_blobCache = blobCache;
_githubService = githubService;
_documentService = documentService;
_notificationService = notificationService;
_checkForUpdatesDisposable = Observable.Timer(TimeSpan.Zero, Intervals.CheckForUpdates)
.Select(time => Unit.Default)
.Subscribe(async _ =>
{
try
{
await _initializeTaskCompletionSource.Task;
if (AppSettings!.IsCheckForUpdates)
{
await CheckForUpdatesAsync();
}
}
catch (Exception ex)
{
var message = "Failed to check for updates. Please check your internet connection or try again later.";
this.Log()
.Error(ex, message);
_notificationService.Notify(AppInfo.Name, message, InfoBarSeverity.Error);
_checkForUpdatesDisposable?.Dispose();
}
});
_checkForSchemaVersionDisposable = Observable.Timer(TimeSpan.Zero, TimeSpan.FromMinutes(5))
.Select(time => Unit.Default)
.Subscribe(async _ =>
{
try
{
await _initializeTaskCompletionSource.Task;
if (AppSettings!.IsCheckForSchema)
{
await CheckForSchemaVersionAsync();
}
}
catch (Exception ex)
{
var message = "Failed to check for schema version. Please check your internet connection or try again later.";
this.Log()
.Error(ex, message);
_notificationService.Notify(AppInfo.Name, message, InfoBarSeverity.Error);
_checkForSchemaVersionDisposable?.Dispose();
}
});
}
[ObservableProperty]
private bool _isBusy = true;
[ObservableProperty]
private AppSettings? _appSettings;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(UpdateCheckTitle), nameof(UpdateCheckIconValue), nameof(UpdateCheckLastChecked))]
private Guid _upToDateGuid = Guid.Empty;
public bool IsUpToDate { get; private set; }
public bool IsSchemaVersionChecked { get; private set; }
public string UpdateCheckTitle => IsUpToDate switch
{
true => "You're up to date",
false => "You're out of date"
};
public string UpdateCheckIconValue => IsUpToDate switch
{
true => "fa-heart-circle-check",
false => "fa-heart-circle-exclamation"
};
public string UpdateCheckLastChecked => $"Last checked: {DateTime.Now:dddd}, {DateTime.Now:T}";
partial void OnAppSettingsChanged(AppSettings? value)
{
if (AppSettings is AppSettings appSettings)
{
_blobCache.InsertObject(BlobCacheKeys.AppSettings, appSettings);
}
}
public override async Task InitializeAsync()
{
await Dispatcher.UIThread.InvokeAsync(async () =>
{
try
{
AppSettings = await _blobCache.GetObject<AppSettings>(BlobCacheKeys.AppSettings);
}
catch (Exception ex)
{
this.Log()
.Warning(ex, "Failed to get application settings.");
AppSettings = new();
}
finally
{
AppSettings!.PropertyChanged += (s, e) => OnAppSettingsChanged((AppSettings?)s);
IsBusy = false;
_initializeTaskCompletionSource.SetResult(true);
}
});
}
[RelayCommand]
private async Task CheckForUpdatesAsync()
{
var release = await _githubService.GetLatestReleaseAsync();
if (release.IsLatest(AppInfo.Version))
{
this.Log()
.Information("New version available: {TagName}", release.TagName);
_notificationService.Notify(AppInfo.Name, $"New version available: {release.TagName}", InfoBarSeverity.Informational, null, "https://github.com/BlossomiShymae/Needlework.Net/releases/latest");
_checkForUpdatesDisposable?.Dispose();
IsUpToDate = false;
}
else
{
IsUpToDate = true;
}
UpToDateGuid = Guid.NewGuid();
}
private async Task CheckForSchemaVersionAsync()
{
if (!ProcessFinder.IsPortOpen()) return;
var lcuSchemaDocument = await _documentService.GetLcuSchemaDocumentAsync();
var client = Connector.GetLcuHttpClientInstance();
var currentSemVer = lcuSchemaDocument.Info.Version.Split('.');
var systemBuild = await client.GetFromJsonAsync<SystemBuild>("/system/v1/builds") ?? throw new NullReferenceException();
var latestSemVer = systemBuild.Version.Split('.');
if (!IsSchemaVersionChecked)
{
this.Log()
.Information("LCU Schema (current): {Version}", lcuSchemaDocument.Info.Version);
this.Log()
.Information("LCU Schema (latest): {Version}", systemBuild.Version);
IsSchemaVersionChecked = true;
}
bool isVersionMatching = currentSemVer[0] == latestSemVer[0] && currentSemVer[1] == latestSemVer[1]; // Compare major and minor versions
if (!isVersionMatching)
{
this.Log()
.Warning("LCU Schema outdated: Current {CurrentVersion}, Latest {LatestVersion}", lcuSchemaDocument.Info.Version, systemBuild.Version);
_notificationService.Notify(AppInfo.Name, $"LCU Schema is outdated compared to latest system build. Consider submitting a pull request on dysolix/hasagi-types.\nCurrent: {string.Join(".", currentSemVer)}\nLatest: {string.Join(".", latestSemVer)}", InfoBarSeverity.Warning, null, "https://github.com/dysolix/hasagi-types#updating-the-types");
_checkForSchemaVersionDisposable?.Dispose();
}
}
}
}

View File

@@ -0,0 +1,75 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Needlework.Net.ViewModels.Pages.Settings"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:uip="using:FluentAvalonia.UI.Controls.Primitives"
xmlns:i="https://github.com/projektanker/icons.avalonia"
xmlns:controls="using:Needlework.Net.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Needlework.Net.Views.Pages.Settings.SettingsView"
x:DataType="vm:SettingsViewModel">
<controls:BusyArea IsBusy="{Binding IsBusy}"
BusyText="Loading...">
<ScrollViewer Margin="16">
<StackPanel>
<StackPanel.Styles>
<Style Selector="ui|SettingsExpander">
<Setter Property="Margin" Value="0,0,0,4"/>
</Style>
</StackPanel.Styles>
<Grid ColumnDefinitions="auto,*"
Margin="16">
<i:Icon Value="{Binding UpdateCheckIconValue}"
FontSize="64"
Grid.Column="0"
Margin="0,0,16,0"/>
<StackPanel Grid.Column="1"
VerticalAlignment="Center">
<TextBlock Theme="{StaticResource SubtitleTextBlockStyle}"
Text="{Binding UpdateCheckTitle}"/>
<TextBlock Foreground="{DynamicResource TextFillColorTertiaryBrush}"
Text="{Binding UpdateCheckLastChecked}"/>
</StackPanel>
</Grid>
<ui:SettingsExpander Header="Updates">
<ui:SettingsExpander.IconSource>
<ui:ImageIconSource>
<ui:ImageIconSource.Source>
<i:IconImage Value="fa-rotate" Brush="{DynamicResource TextFillColorPrimaryBrush}"/>
</ui:ImageIconSource.Source>
</ui:ImageIconSource>
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<Button Command="{Binding CheckForUpdatesCommand}">Check for updates</Button>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="Get notified of the latest update as soon as it's available">
<ui:SettingsExpander.IconSource>
<ui:ImageIconSource>
<ui:ImageIconSource.Source>
<i:IconImage Value="fa-bullhorn" Brush="{DynamicResource TextFillColorPrimaryBrush}"/>
</ui:ImageIconSource.Source>
</ui:ImageIconSource>
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding AppSettings.IsCheckForUpdates}"/>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="Get notified when LCU Schema is outdated with /system/v1/builds">
<ui:SettingsExpander.IconSource>
<ui:ImageIconSource>
<ui:ImageIconSource.Source>
<i:IconImage Value="fa-file-circle-question" Brush="{DynamicResource TextFillColorPrimaryBrush}"/>
</ui:ImageIconSource.Source>
</ui:ImageIconSource>
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding AppSettings.IsCheckForSchema}"/>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</controls:BusyArea>
</UserControl>

View File

@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace Needlework.Net.Views.Pages.Settings;
public partial class SettingsView : UserControl
{
public SettingsView()
{
InitializeComponent();
}
}