diff --git a/SubathonManager.Core/Events/SettingsEvents.cs b/SubathonManager.Core/Events/SettingsEvents.cs index 963593b..034d8ae 100644 --- a/SubathonManager.Core/Events/SettingsEvents.cs +++ b/SubathonManager.Core/Events/SettingsEvents.cs @@ -7,6 +7,7 @@ namespace SubathonManager.Core.Events; public static class SettingsEvents { public static event Action? SettingsUnsavedChanges; public static event Action? EventVisibilityChanged; + public static event Action? PrimaryCurrencyChanged; public static event Action? HotLinkToDevTunnelsRequested; public static event Action? HotLinkToSourceRequested; @@ -14,6 +15,10 @@ public static void RaiseSettingsUnsavedChanges(bool hasPendingChanges) { SettingsUnsavedChanges?.Invoke(hasPendingChanges); } + public static void RaisePrimaryCurrencyChanged(string currency) { + PrimaryCurrencyChanged?.Invoke(currency); + } + public static void RaiseEventVisibilityChanged() { EventVisibilityChanged?.Invoke(); } diff --git a/SubathonManager.Core/Utils.cs b/SubathonManager.Core/Utils.cs index 023483b..dc3034b 100644 --- a/SubathonManager.Core/Utils.cs +++ b/SubathonManager.Core/Utils.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; @@ -192,6 +193,37 @@ public static string EscapeCsv(string? value) { return value; } + public static string DescribeTokenPointRate(string? pointsPer100, string unitPlural, string? unitSingular = null) { + if (!decimal.TryParse(pointsPer100, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal points)) + return ""; + if (points < 0.0001m) return ""; + + if (points > 100m) + return $"= 1 {unitSingular ?? unitPlural.TrimEnd('s')} / {PointsLabel(points / 100m)}"; + + decimal needed = Math.Ceiling(100m / points); + string unit = needed == 1 ? unitSingular ?? unitPlural.TrimEnd('s') : unitPlural; + return $"= {needed.ToString("N0", CultureInfo.InvariantCulture)} {unit} / point"; + } + + public static string DescribeMoneyPointRate(string? pointsPerUnit, string? currency) { + if (!decimal.TryParse(pointsPerUnit, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal points)) + return ""; + if (points < 0.0001m) return ""; + + string unit = string.IsNullOrWhiteSpace(currency) ? "" : $" {currency.Trim()}"; + if (points > 100m) return $"= 1{unit} / {PointsLabel(points)}"; + + decimal needed = Math.Ceiling(100m / points) / 100m; + return $"= {needed.ToString("#,##0.##", CultureInfo.InvariantCulture)}{unit} / point"; + } + + private static string PointsLabel(decimal points) { + return points == 1m + ? "point" + : $"{points.ToString("#,##0.##", CultureInfo.InvariantCulture)} points"; + } + public static (bool, double) GetAltCurrencyUseAsDonation(IConfig config, SubathonEventType? eventType) { double modifier = 1; if (!eventType.IsToken()) diff --git a/SubathonManager.Data/DbContext.cs b/SubathonManager.Data/DbContext.cs index 0d2a56c..1452b2e 100644 --- a/SubathonManager.Data/DbContext.cs +++ b/SubathonManager.Data/DbContext.cs @@ -6,6 +6,7 @@ using SubathonManager.Core; using SubathonManager.Core.Enums; using SubathonManager.Core.Models; +using SubathonManager.Data.Widgets; // ReSharper disable NullableWarningSuppressionIsUsed @@ -67,6 +68,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { .HasForeignKey(w => w.RouteId) .OnDelete(DeleteBehavior.Cascade); // deleting route deletes its widgets + modelBuilder.Entity() + .Property(w => w.HtmlPath) + .HasConversion( + v => WidgetPathStore.ToStored(v), + v => WidgetPathStore.ToAbsolute(v)); + modelBuilder.Entity() .HasMany(w => w.CssVariables) .WithOne(cv => cv.Widget) diff --git a/SubathonManager.Data/Widgets/WidgetCatalog.cs b/SubathonManager.Data/Widgets/WidgetCatalog.cs index cf25531..8317130 100644 --- a/SubathonManager.Data/Widgets/WidgetCatalog.cs +++ b/SubathonManager.Data/Widgets/WidgetCatalog.cs @@ -238,29 +238,11 @@ public static async Task DeleteAsync(IDbContextFactory facto } public static string ToAbsolutePath(string storedPath) { - try { - return Path.IsPathRooted(storedPath) - ? storedPath - : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), storedPath)); - } - catch { - return storedPath; - } + return WidgetPathStore.ToAbsolute(storedPath); } private static string ToStoredPath(string absolute) { - try { - string full = Path.GetFullPath(absolute); - string baseDir = Directory.GetCurrentDirectory(); - string relative = Path.GetRelativePath(baseDir, full); - - return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative) - ? full - : relative; - } - catch { - return absolute; - } + return WidgetPathStore.ToStored(absolute); } private static IEnumerable EnumeratePackages(string root) { diff --git a/SubathonManager.Data/Widgets/WidgetPathRepair.cs b/SubathonManager.Data/Widgets/WidgetPathRepair.cs new file mode 100644 index 0000000..a375d3c --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPathRepair.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; + +namespace SubathonManager.Data.Widgets; + +public static class WidgetPathRepair { + private static readonly string[] Anchors = ["presets", "imports"]; + + public static int Run(IDbContextFactory factory, ILogger? logger = null) { + WidgetPackPaths.InvalidateResolveCache(); + WidgetPackPaths.InvalidateVersionCache(); + + using AppDbContext db = factory.CreateDbContext(); + List widgets = db.Widgets.ToList(); + + var repaired = 0; + foreach (Widget widget in widgets) { + if (WidgetFiles.Current.Exists(widget.HtmlPath)) continue; + if (!TryRebase(widget.HtmlPath, out string rebased)) continue; + + logger?.LogInformation("Repointed widget {Name} ({Id}) from {Old} to {New}", + widget.Name, widget.Id, widget.HtmlPath, rebased); + + widget.HtmlPath = rebased; + repaired++; + } + + if (repaired > 0) { + db.SaveChanges(); + WidgetPackPaths.InvalidateResolveCache(); + } + + return repaired; + } + + public static int Normalize(IDbContextFactory factory) { + using AppDbContext db = factory.CreateDbContext(); + List widgets = db.Widgets.ToList(); + + var rewritten = 0; + foreach (Widget widget in widgets) { + if (Path.IsPathRooted(WidgetPathStore.ToStored(widget.HtmlPath))) continue; + + db.Entry(widget).Property(w => w.HtmlPath).IsModified = true; + rewritten++; + } + + if (rewritten > 0) db.SaveChanges(); + return rewritten; + } + + public static bool TryRebase(string storedPath, out string repaired) { + repaired = string.Empty; + if (string.IsNullOrWhiteSpace(storedPath)) return false; + + string[] parts = storedPath.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries); + + for (var i = parts.Length - 2; i >= 0; i--) { + string segment = parts[i]; + int anchor = Array.FindIndex(Anchors, + a => string.Equals(a, segment, StringComparison.OrdinalIgnoreCase)); + if (anchor < 0) continue; + + string[] segments = [Path.GetFullPath(Anchors[anchor]), .. parts[(i + 1)..]]; + string candidate = Path.GetFullPath(Path.Combine(segments)); + + if (!WidgetFiles.Current.Exists(candidate)) continue; + + repaired = candidate; + return true; + } + + return false; + } +} diff --git a/SubathonManager.Data/Widgets/WidgetPathStore.cs b/SubathonManager.Data/Widgets/WidgetPathStore.cs new file mode 100644 index 0000000..8276046 --- /dev/null +++ b/SubathonManager.Data/Widgets/WidgetPathStore.cs @@ -0,0 +1,33 @@ +namespace SubathonManager.Data.Widgets; + +public static class WidgetPathStore { + public static string ToStored(string? absolute) { + if (string.IsNullOrWhiteSpace(absolute)) return absolute ?? string.Empty; + + try { + string full = Path.GetFullPath(absolute); + string baseDir = Directory.GetCurrentDirectory(); + string relative = Path.GetRelativePath(baseDir, full); + + return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative) + ? full + : relative; + } + catch { + return absolute; + } + } + + public static string ToAbsolute(string? stored) { + if (string.IsNullOrWhiteSpace(stored)) return stored ?? string.Empty; + + try { + return Path.IsPathRooted(stored) + ? stored + : Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), stored)); + } + catch { + return stored; + } + } +} diff --git a/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs b/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs index 2fa7acf..8790d79 100644 --- a/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs +++ b/SubathonManager.Tests/CoreUnitTests/UtilsTests.cs @@ -27,6 +27,54 @@ public void ParseDurationString() { Assert.Equal(TimeSpan.FromSeconds(87), Utils.ParseDurationString("0:87")); // technically valid } + [Fact] + public void DescribeTokenPointRate() { + Assert.Equal("= 295 bits / point", Utils.DescribeTokenPointRate("0.34", "bits")); + Assert.Equal("= 300 bits / point", Utils.DescribeTokenPointRate("0.334", "bits")); + Assert.Equal("= 200 bits / point", Utils.DescribeTokenPointRate("0.5", "bits")); + Assert.Equal("= 100 bits / point", Utils.DescribeTokenPointRate("1", "bits")); + Assert.Equal("= 10 beets / point", Utils.DescribeTokenPointRate("10", "beets")); + Assert.Equal("= 1 bit / point", Utils.DescribeTokenPointRate("100", "bits")); + Assert.Equal("= 1 kudos / point", Utils.DescribeTokenPointRate("100", "kudos", "kudos")); + + Assert.Equal("= 1 bit / 2.5 points", Utils.DescribeTokenPointRate("250", "bits")); + Assert.Equal("= 1 bit / 200 points", Utils.DescribeTokenPointRate("20000", "bits")); + Assert.Equal("= 1 beet / 1.5 points", Utils.DescribeTokenPointRate("150", "beets")); + Assert.Equal("= 1 kudos / 12,345.67 points", Utils.DescribeTokenPointRate("1234567", "kudos", "kudos")); + Assert.Equal("= 10,000 tokens / point", Utils.DescribeTokenPointRate("0.01", "tokens")); + + Assert.Equal("", Utils.DescribeTokenPointRate("0", "bits")); + Assert.Equal("", Utils.DescribeTokenPointRate("-5", "bits")); + Assert.Equal("", Utils.DescribeTokenPointRate("", "bits")); + Assert.Equal("", Utils.DescribeTokenPointRate(null, "bits")); + Assert.Equal("", Utils.DescribeTokenPointRate("abc", "bits")); + Assert.Equal("", Utils.DescribeTokenPointRate("0.00000000000000000000000000001", "bits")); + } + + [Fact] + public void DescribeMoneyPointRate() { + Assert.Equal("= 2.95 USD / point", Utils.DescribeMoneyPointRate("0.34", "USD")); + Assert.Equal("= 0.34 CAD / point", Utils.DescribeMoneyPointRate("3", "CAD")); + Assert.Equal("= 0.5 EUR / point", Utils.DescribeMoneyPointRate("2", "EUR")); + Assert.Equal("= 1 USD / point", Utils.DescribeMoneyPointRate("1", "USD")); + Assert.Equal("= 0.01 USD / point", Utils.DescribeMoneyPointRate("100", "USD")); + + Assert.Equal("= 1 USD / 20,000 points", Utils.DescribeMoneyPointRate("20000", "USD")); + Assert.Equal("= 1 CAD / 150.5 points", Utils.DescribeMoneyPointRate("150.5", "CAD")); + Assert.Equal("= 1 / 200 points", Utils.DescribeMoneyPointRate("200", "")); + Assert.Equal("= 10 JPY / point", Utils.DescribeMoneyPointRate("0.1", "JPY")); + Assert.Equal("= 1,000 USD / point", Utils.DescribeMoneyPointRate("0.001", "USD")); + Assert.Equal("= 2.95 / point", Utils.DescribeMoneyPointRate("0.34", "")); + Assert.Equal("= 3 / point", Utils.DescribeMoneyPointRate("0.334", "")); + + Assert.Equal("", Utils.DescribeMoneyPointRate("0", "USD")); + Assert.Equal("", Utils.DescribeMoneyPointRate("-2", "USD")); + Assert.Equal("", Utils.DescribeMoneyPointRate("", "USD")); + Assert.Equal("", Utils.DescribeMoneyPointRate(null, "USD")); + Assert.Equal("", Utils.DescribeMoneyPointRate("abc", "USD")); + Assert.Equal("", Utils.DescribeMoneyPointRate("0.00000000000000000000000000001", "USD")); + } + [Fact] public void GenerateHashFromString() { Assert.Equal(Guid.Parse("bdc43e17-41dc-fb50-8c1c-54a72b1ec93e"), diff --git a/SubathonManager.Tests/DataUnitTests/WidgetPathRepairTests.cs b/SubathonManager.Tests/DataUnitTests/WidgetPathRepairTests.cs new file mode 100644 index 0000000..2ead675 --- /dev/null +++ b/SubathonManager.Tests/DataUnitTests/WidgetPathRepairTests.cs @@ -0,0 +1,215 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SubathonManager.Core.Interfaces; +using SubathonManager.Core.Models; +using SubathonManager.Data; +using SubathonManager.Data.Widgets; +using SubathonManager.Tests.Utility; + +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Tests.DataUnitTests; + +[Collection("WorkingDirectory")] +public class WidgetPathRepairTests { + private static IDbContextFactory MakeFactory() { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContextFactory(o => o.UseInMemoryDatabase(Guid.NewGuid().ToString())); + return services.BuildServiceProvider().GetRequiredService>(); + } + + private static string MakePackedWidget(string entry = "content/widget.html") { + string folder = Path.Combine(WidgetPackPaths.PackedRoot, "wolf.widgets.timer"); + Directory.CreateDirectory(folder); + TestPacks.WriteZip(Path.Combine(folder, "1-0-0.smw"), + [new KeyValuePair(entry, "")]); + WidgetPackPaths.InvalidateResolveCache(); + return WidgetPackPaths.EntryPathIn(Path.Combine(folder, "1-0-0"), entry); + } + + private static T WithPackFileSystem(Func body) { + IWidgetFileSystem previous = WidgetFiles.Current; + WidgetFiles.Current = new WidgetPackFileSystem(); + try { + return body(); + } + finally { + WidgetFiles.Current = previous; + } + } + + private static string StalePath(string current) { + string relative = Path.GetRelativePath(Directory.GetCurrentDirectory(), current); + return Path.Combine(Path.GetTempPath(), "SubathonManager_win-x64_v2.0.1", relative); + } + + [Fact] + public void ToStored_PathInsideAppFolder_BecomesRelative() { + using var ws = new TempWorkspace("pathstore"); + string absolute = ws.WriteFile("imports/widgets/unpacked/wolf/widget.html", ""); + + string stored = WidgetPathStore.ToStored(absolute); + + Assert.False(Path.IsPathRooted(stored)); + Assert.Equal(absolute, WidgetPathStore.ToAbsolute(stored)); + } + + [Fact] + public void ToStored_PathOutsideAppFolder_StaysAbsolute() { + using var ws = new TempWorkspace("pathstore"); + string outside = Path.Combine(Path.GetTempPath(), "elsewhere", "widget.html"); + + string stored = WidgetPathStore.ToStored(outside); + + Assert.True(Path.IsPathRooted(stored)); + Assert.Equal(Path.GetFullPath(outside), stored); + } + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" ", " ")] + public void PathStore_BlankInput_IsPassedThrough(string? input, string expected) { + Assert.Equal(expected, WidgetPathStore.ToStored(input)); + Assert.Equal(expected, WidgetPathStore.ToAbsolute(input)); + } + + [Fact] + public void TryRebase_StaleInstallFolder_ReanchorsPackedWidget() { + using var ws = new TempWorkspace("rebase"); + string current = MakePackedWidget(); + + WithPackFileSystem(() => { + Assert.True(WidgetPathRepair.TryRebase(StalePath(current), out string repaired)); + Assert.Equal(current, repaired); + return true; + }); + } + + [Fact] + public void TryRebase_StalePresetPath_ReanchorsOnPresetsRoot() { + using var ws = new TempWorkspace("rebase"); + const string entry = "content/widget.html"; + string packFolder = ws.Dir("presets", "harlequin"); + TestPacks.WriteZip(Path.Combine(packFolder, "Timer_1.0.0.smw"), + [new KeyValuePair(entry, "")]); + WidgetPackPaths.InvalidateResolveCache(); + + string current = WidgetPackPaths.EntryPathIn(Path.Combine(packFolder, "Timer_1.0.0"), entry); + + WithPackFileSystem(() => { + Assert.True(WidgetPathRepair.TryRebase(StalePath(current), out string repaired)); + Assert.Equal(current, repaired); + return true; + }); + } + + [Fact] + public void TryRebase_NoAnchorSegment_IsLeftAlone() { + using var ws = new TempWorkspace("rebase"); + MakePackedWidget(); + + WithPackFileSystem(() => { + string loose = Path.Combine(Path.GetTempPath(), "MyWidgets", "timer", "widget.html"); + Assert.False(WidgetPathRepair.TryRebase(loose, out string repaired)); + Assert.Equal(string.Empty, repaired); + return true; + }); + } + + [Fact] + public void TryRebase_AnchorPresentButNoSuchWidget_IsLeftAlone() { + using var ws = new TempWorkspace("rebase"); + MakePackedWidget(); + + WithPackFileSystem(() => { + string missing = Path.Combine(Path.GetTempPath(), "old", "imports", "widgets", + "packed", "wolf.widgets.gone", "1-0-0", "content", "widget.html"); + Assert.False(WidgetPathRepair.TryRebase(missing, out _)); + return true; + }); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("widget.html")] + public void TryRebase_DegenerateInput_IsLeftAlone(string? input) { + using var ws = new TempWorkspace("rebase"); + Assert.False(WidgetPathRepair.TryRebase(input!, out _)); + } + + [Fact] + public void Run_RepointsStaleWidget_AndLeavesHealthyOnesAlone() { + using var ws = new TempWorkspace("repair"); + string current = MakePackedWidget(); + string healthy = ws.WriteFile("imports/widgets/unpacked/wolf/ok.html", ""); + IDbContextFactory factory = MakeFactory(); + + var route = new Route { Id = Guid.NewGuid(), Name = "Overlay" }; + var stale = new Widget("Stale", StalePath(current)) { RouteId = route.Id }; + var fine = new Widget("Fine", healthy) { RouteId = route.Id }; + + using (AppDbContext db = factory.CreateDbContext()) { + db.Routes.Add(route); + db.Widgets.AddRange(stale, fine); + db.SaveChanges(); + } + + int repaired = WithPackFileSystem(() => WidgetPathRepair.Run(factory)); + + Assert.Equal(1, repaired); + using (AppDbContext db = factory.CreateDbContext()) { + Assert.Equal(current, db.Widgets.Single(w => w.Name == "Stale").HtmlPath); + Assert.Equal(healthy, db.Widgets.Single(w => w.Name == "Fine").HtmlPath); + } + } + + [Fact] + public void Run_UnrecoverableWidget_IsLeftUntouched() { + using var ws = new TempWorkspace("repair"); + MakePackedWidget(); + IDbContextFactory factory = MakeFactory(); + + string orphan = Path.Combine(Path.GetTempPath(), "MyWidgets", "timer", "widget.html"); + var route = new Route { Id = Guid.NewGuid(), Name = "Overlay" }; + + using (AppDbContext db = factory.CreateDbContext()) { + db.Routes.Add(route); + db.Widgets.Add(new Widget("Orphan", orphan) { RouteId = route.Id }); + db.SaveChanges(); + } + + int repaired = WithPackFileSystem(() => WidgetPathRepair.Run(factory)); + + Assert.Equal(0, repaired); + using (AppDbContext db = factory.CreateDbContext()) { + Assert.Equal(orphan, db.Widgets.Single().HtmlPath); + } + } + + [Fact] + public void Normalize_CountsOnlyPathsInsideTheAppFolder() { + using var ws = new TempWorkspace("normalize"); + string inside = ws.WriteFile("imports/widgets/unpacked/wolf/widget.html", ""); + string outside = Path.Combine(Path.GetTempPath(), "elsewhere", "widget.html"); + IDbContextFactory factory = MakeFactory(); + + var route = new Route { Id = Guid.NewGuid(), Name = "Overlay" }; + using (AppDbContext db = factory.CreateDbContext()) { + db.Routes.Add(route); + db.Widgets.AddRange( + new Widget("Inside", inside) { RouteId = route.Id }, + new Widget("Outside", outside) { RouteId = route.Id }); + db.SaveChanges(); + } + + Assert.Equal(1, WidgetPathRepair.Normalize(factory)); + + using (AppDbContext db = factory.CreateDbContext()) { + Assert.Equal(inside, db.Widgets.Single(w => w.Name == "Inside").HtmlPath); + Assert.Equal(Path.GetFullPath(outside), db.Widgets.Single(w => w.Name == "Outside").HtmlPath); + } + } +} diff --git a/SubathonManager.UI/App.axaml.cs b/SubathonManager.UI/App.axaml.cs index 5b2cdd7..ec1c597 100644 --- a/SubathonManager.UI/App.axaml.cs +++ b/SubathonManager.UI/App.axaml.cs @@ -80,7 +80,6 @@ public override void OnFrameworkInitializationCompleted() { config.LoadOrCreateDefault(); config.MigrateConfig(); MigrateSecureStore(config); - SweepWidgetPackCache(); bool bitsAsDonationCheck = config.GetBool("Currency", "BitsLikeAsDonation"); Utils.DonationSettings["BitsLikeAsDonation"] = bitsAsDonationCheck; @@ -124,6 +123,9 @@ public override void OnFrameworkInitializationCompleted() { .Include(s => s.Products).AsNoTracking().ToList()); } + RepairWidgetPaths(config); + SweepWidgetPackCache(); + GoAffProConfigMigration.Run(config); foreach (GoAffProStore store in GoAffProStoreRegistry.All()) Utils.DonationSettings[store.InternalName] = @@ -267,6 +269,33 @@ private void OnShutdownRequested(object? sender, ShutdownRequestedEventArgs e) { // } + private void RepairWidgetPaths(IConfig config) { + IDbContextFactory? factory = _factory; + if (factory == null) return; + + try { + int repaired = WidgetPathRepair.Run(factory, _logger); + if (repaired > 0) + _logger?.LogInformation("Repointed {Count} widget path(s) to the current install folder", repaired); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "Failed to repoint widget paths"); + } + + if (config.GetBool("App", "WidgetPathsNormalized")) return; + + try { + int rewritten = WidgetPathRepair.Normalize(factory); + if (rewritten > 0) + _logger?.LogInformation("Stored {Count} widget path(s) relative to the install folder", rewritten); + + if (config.SetBool("App", "WidgetPathsNormalized", true)) config.Save(); + } + catch (Exception ex) { + _logger?.LogWarning(ex, "Failed to normalize widget paths"); + } + } + private void SweepWidgetPackCache() { try { var factory = AppServices.Provider.GetRequiredService>(); diff --git a/SubathonManager.UI/UiUtils/UiHelpers.cs b/SubathonManager.UI/UiUtils/UiHelpers.cs index 9af166c..e159e5d 100644 --- a/SubathonManager.UI/UiUtils/UiHelpers.cs +++ b/SubathonManager.UI/UiUtils/UiHelpers.cs @@ -8,6 +8,11 @@ using Avalonia.Media; using Avalonia.Threading; using Avalonia.VisualTree; +using Microsoft.Extensions.DependencyInjection; +using SubathonManager.Core; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Events; +using SubathonManager.Core.Interfaces; namespace SubathonManager.UI.UiUtils; @@ -88,6 +93,50 @@ public static bool IsInteractiveSource(object? source, Visual? stopAt = null) { return false; } + public static void AttachTokenPointRateHint(TextBox pointsBox, TextBlock hint, string unitPlural, + string? unitSingular = null) { + void Update() { + hint.Text = Utils.DescribeTokenPointRate(pointsBox.Text, unitPlural, unitSingular); + } + + pointsBox.TextChanged += (_, _) => Update(); + Update(); + } + + public static void AttachMoneyPointRateHint(TextBox pointsBox, TextBlock hint) { + AttachMoneyHint(pointsBox, hint, null, false); + } + + public static void AttachOrderPointRateHint(TextBox pointsBox, TextBlock hint, ComboBox? dollarModeBox) { + AttachMoneyHint(pointsBox, hint, dollarModeBox, true); + } + + private static void AttachMoneyHint(TextBox pointsBox, TextBlock hint, ComboBox? dollarModeBox, + bool collapseWhenEmpty) { + string currency = PrimaryCurrency(); + + void Update() { + bool perDollar = dollarModeBox == null || + string.Equals($"{dollarModeBox.SelectedItem}", $"{OrderTypeModes.Dollar}", + StringComparison.OrdinalIgnoreCase); + hint.Text = perDollar ? Utils.DescribeMoneyPointRate(pointsBox.Text, currency) : ""; + if (collapseWhenEmpty) hint.IsVisible = !string.IsNullOrEmpty(hint.Text); + } + + pointsBox.TextChanged += (_, _) => Update(); + if (dollarModeBox != null) dollarModeBox.SelectionChanged += (_, _) => Update(); + SettingsEvents.PrimaryCurrencyChanged += updated => { + currency = updated; + Dispatcher.UIThread.Post(Update); + }; + Update(); + } + + private static string PrimaryCurrency() { + var config = AppServices.Provider.GetRequiredService(); + return (config.Get("Currency", "Primary", "USD") ?? "USD").Trim().ToUpperInvariant(); + } + public static void UpdateButtonPendingBorder(Border border, bool hasPendingChanges) { border.BorderBrush = hasPendingChanges ? new SolidColorBrush(Color.FromRgb(0xF5, 0xC5, 0x18)) diff --git a/SubathonManager.UI/Views/SettingsView.Settings.cs b/SubathonManager.UI/Views/SettingsView.Settings.cs index 3a315c4..bca280d 100644 --- a/SubathonManager.UI/Views/SettingsView.Settings.cs +++ b/SubathonManager.UI/Views/SettingsView.Settings.cs @@ -25,6 +25,16 @@ private void InitCurrencySelects() { ExternalServiceSettingsControl.UpdateCurrencyBoxes(currencies, DefaultCurrencyBox.SelectedItem as string ?? "USD"); StreamingSettingsControl.UpdateCurrencyBoxes(currencies, DefaultCurrencyBox.SelectedItem as string ?? "USD"); + RaisePrimaryCurrency(); + } + + private void DefaultCurrencyBox_SelectionChanged(object? sender, SelectionChangedEventArgs e) { + RaisePrimaryCurrency(); + } + + private void RaisePrimaryCurrency() { + string currency = (DefaultCurrencyBox.SelectedItem as string ?? "").Trim().ToUpperInvariant(); + if (currency.Length >= 3) SettingsEvents.RaisePrimaryCurrencyChanged(currency); } private void OpenDataFolder_Click(object? sender, RoutedEventArgs e) { diff --git a/SubathonManager.UI/Views/SettingsView.axaml b/SubathonManager.UI/Views/SettingsView.axaml index 29c8322..b2c298f 100644 --- a/SubathonManager.UI/Views/SettingsView.axaml +++ b/SubathonManager.UI/Views/SettingsView.axaml @@ -46,7 +46,7 @@ + IsEditable="True" SelectionChanged="DefaultCurrencyBox_SelectionChanged" /> diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml b/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml index 4810837..acfabe3 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml @@ -19,11 +19,13 @@ - + - @@ -34,11 +36,13 @@ - + - diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml.cs index baae6f1..6a12720 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/ChatExtensionSettings.axaml.cs @@ -8,12 +8,15 @@ using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.Integration; +using SubathonManager.UI.UiUtils; namespace SubathonManager.UI.Views.SettingsViews.Extensions; public partial class ChatExtensionSettings : SettingsControl { public ChatExtensionSettings() { InitializeComponent(); + UiHelpers.AttachTokenPointRateHint(Bits2TextBox, BitsRateHint, "bits"); + UiHelpers.AttachTokenPointRateHint(Beets2TextBox, BeetsRateHint, "beets"); Loaded += (_, _) => RegisterUnsavedChangeHandlers(); } @@ -44,13 +47,13 @@ public override bool UpdateValueSettings(AppDbContext db) { SubathonValue? blerpBeetsValue = db.SubathonValues.FirstOrDefault(sv => sv.EventType == SubathonEventType.BlerpBits && sv.Meta == ""); - if (blerpBeetsValue != null && double.TryParse(BitsTextBox.Text, out double beetsSeconds) && + if (blerpBeetsValue != null && double.TryParse(BeetsTextBox.Text, out double beetsSeconds) && !beetsSeconds.Equals(blerpBeetsValue.Seconds / 100.0)) { blerpBeetsValue.Seconds = beetsSeconds / 100.0; hasUpdated = true; } - if (blerpBeetsValue != null && double.TryParse(Bits2TextBox.Text, out double beetsPoints) && + if (blerpBeetsValue != null && double.TryParse(Beets2TextBox.Text, out double beetsPoints) && !beetsPoints.Equals(blerpBeetsValue.Points)) { blerpBeetsValue.Points = beetsPoints; hasUpdated = true; diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/StreamElementsSettings.axaml b/SubathonManager.UI/Views/SettingsViews/Extensions/StreamElementsSettings.axaml index 9bc9ed1..846bc15 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/StreamElementsSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/StreamElementsSettings.axaml @@ -25,8 +25,10 @@ - + { IntegrationEvents.ConnectionUpdated += UpdateStatus; RegisterUnsavedChangeHandlers(); diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/StreamLabsSettings.axaml b/SubathonManager.UI/Views/SettingsViews/Extensions/StreamLabsSettings.axaml index 19ba495..e9207c5 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/StreamLabsSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/StreamLabsSettings.axaml @@ -25,8 +25,10 @@ - + { IntegrationEvents.ConnectionUpdated += UpdateStatus; RegisterUnsavedChangeHandlers(); diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/TangiaSettings.axaml b/SubathonManager.UI/Views/SettingsViews/Extensions/TangiaSettings.axaml index 1bc064f..9b155b5 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/TangiaSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/TangiaSettings.axaml @@ -28,8 +28,10 @@ - + { IntegrationEvents.ConnectionUpdated += UpdateStatus; RegisterUnsavedChangeHandlers(); diff --git a/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.axaml b/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.axaml index e0ce2ce..aeb0010 100644 --- a/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/Extensions/TipeeeStreamSettings.axaml @@ -24,8 +24,10 @@ - + { IntegrationEvents.ConnectionUpdated += UpdateStatus; RegisterUnsavedChangeHandlers(); diff --git a/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml b/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml index 3c9c9cc..0c020ea 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml @@ -11,9 +11,11 @@ - + - + + diff --git a/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml.cs index 8d0cd59..343aa34 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/ExternalServiceSettings.axaml.cs @@ -10,6 +10,7 @@ using SubathonManager.Core.Objects; using SubathonManager.Data; using SubathonManager.Integration; +using SubathonManager.UI.UiUtils; namespace SubathonManager.UI.Views.SettingsViews.External; @@ -18,6 +19,7 @@ public partial class ExternalServiceSettings : SettingsControl { public ExternalServiceSettings() { InitializeComponent(); + UiHelpers.AttachMoneyPointRateHint(DonoBox2, DonoRateHint); Loaded += (_, _) => RegisterUnsavedChangeHandlers(); } diff --git a/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml b/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml index c30005c..ea60d74 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/External/FourthWallSettings.axaml @@ -65,8 +65,10 @@ - + - - - - - - - - + + + + + + + + + + + - + - - - - - - - - + + + + + + + + + + + - + { IntegrationEvents.ConnectionUpdated += UpdateStatus; IntegrationEvents.FourthWallMembershipsSynced += SyncMemberships; diff --git a/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.axaml b/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.axaml index e824c48..fe9c396 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.axaml +++ b/SubathonManager.UI/Views/SettingsViews/External/GoAffPro/GoAffProSourceControl.axaml @@ -21,18 +21,22 @@ Foreground="{DynamicResource SystemAccentColorLight2}" Margin="14 0 4 0" /> - - - - - - + + + + + + + - - + + + + - + - + - + - - - - - - - - + + + + + + + + + + + - + RegisterUnsavedChangeHandlers(); } diff --git a/SubathonManager.UI/Views/SettingsViews/External/PallySettings.axaml b/SubathonManager.UI/Views/SettingsViews/External/PallySettings.axaml index 893d684..ee2298a 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/PallySettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/External/PallySettings.axaml @@ -51,8 +51,10 @@ - + { IntegrationEvents.ConnectionUpdated += UpdateStatus; RegisterUnsavedChangeHandlers(); diff --git a/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml b/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml index 73341f8..5869065 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml @@ -60,19 +60,23 @@ - - - - - - - + + + + + + + + + + - + - - - - - - - - + + + + + + + + + + + - + diff --git a/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml.cs b/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml.cs index 9dbb801..0103f28 100644 --- a/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml.cs +++ b/SubathonManager.UI/Views/SettingsViews/External/ThroneSettings.axaml.cs @@ -14,6 +14,7 @@ using SubathonManager.Data; using SubathonManager.Integration; using SubathonManager.UI.Services; +using SubathonManager.UI.UiUtils; namespace SubathonManager.UI.Views.SettingsViews.External; @@ -22,6 +23,8 @@ public partial class ThroneSettings : DevTunnelSettingsControl { public ThroneSettings() { InitializeComponent(); + UiHelpers.AttachOrderPointRateHint(ContribBox2, ContribRateHint, null); + UiHelpers.AttachOrderPointRateHint(GiftsBox2, GiftsRateHint, ModeBox); Loaded += (_, _) => { IntegrationEvents.ConnectionUpdated += UpdateStatus; RegisterUnsavedChangeHandlers(); diff --git a/SubathonManager.UI/Views/SettingsViews/Streaming/PicartoSettings.axaml b/SubathonManager.UI/Views/SettingsViews/Streaming/PicartoSettings.axaml index 6708b94..344cbef 100644 --- a/SubathonManager.UI/Views/SettingsViews/Streaming/PicartoSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/Streaming/PicartoSettings.axaml @@ -30,8 +30,10 @@ - + { IntegrationEvents.ConnectionUpdated += UpdateStatus; RegisterUnsavedChangeHandlers(); diff --git a/SubathonManager.UI/Views/SettingsViews/Streaming/TwitchSettings.axaml b/SubathonManager.UI/Views/SettingsViews/Streaming/TwitchSettings.axaml index 4e3e8e3..59bd593 100644 --- a/SubathonManager.UI/Views/SettingsViews/Streaming/TwitchSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/Streaming/TwitchSettings.axaml @@ -43,8 +43,10 @@ - + - + { IntegrationEvents.ConnectionUpdated += UpdateStatus; diff --git a/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.axaml b/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.axaml index 0b05894..787a5e0 100644 --- a/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.axaml +++ b/SubathonManager.UI/Views/SettingsViews/Streaming/YouTubeSettings.axaml @@ -26,8 +26,10 @@ - + { IntegrationEvents.ConnectionUpdated += UpdateStatus; RegisterUnsavedChangeHandlers();