diff --git a/src/PolyTrader.App.Avalonia/App.axaml b/src/PolyTrader.App.Avalonia/App.axaml index fdc3c0a..c2b51f0 100644 --- a/src/PolyTrader.App.Avalonia/App.axaml +++ b/src/PolyTrader.App.Avalonia/App.axaml @@ -409,11 +409,21 @@ + + + + diff --git a/src/PolyTrader.App.Avalonia/Shell/ThemeManager.cs b/src/PolyTrader.App.Avalonia/Shell/ThemeManager.cs index b7f509f..a82965f 100644 --- a/src/PolyTrader.App.Avalonia/Shell/ThemeManager.cs +++ b/src/PolyTrader.App.Avalonia/Shell/ThemeManager.cs @@ -2,7 +2,9 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Media; +using Avalonia.Platform; using Avalonia.Styling; +using Avalonia.Threading; using PolyTraderSharp.Models; using PolyTraderSharp.Services; @@ -23,13 +25,37 @@ namespace PolyTrader.App.Avalonia.Shell private static ServerSettings? _settings; private static string? _settingsPath; private static TerminalLogger? _logger; + private static bool _colorHookAttached; /// Feuert nach jedem Wechsel – Fenster zeichnen daraufhin ihre Code-Farben neu. public static event Action? ThemeChanged; /// Ist gerade das dunkle Schema aktiv (auch wenn es über „System" kam)? - public static bool IsDark => - global::Avalonia.Application.Current?.ActualThemeVariant == ThemeVariant.Dark; + /// + /// Gelesen wird die angeforderte Variante, nicht ActualThemeVariant der Anwendung: + /// Bei „System" steht dort – die Fenster lösen das + /// selbst nach hell oder dunkel auf, die Anwendung erfährt davon nichts. Der Vergleich mit + /// lieferte deshalb im Auslieferungszustand immer + /// false, obwohl dunkel gezeichnet wurde: Beschriftung, Symbol und + /// sagten geschlossen das Gegenteil dessen, was zu sehen war. + /// + public static bool IsDark + { + get + { + var app = global::Avalonia.Application.Current; + if (app == null) return false; + + // Feste Wahl: die Antwort steht bereits in der angeforderten Variante. + var requested = app.RequestedThemeVariant; + if (requested == ThemeVariant.Dark) return true; + if (requested == ThemeVariant.Light) return false; + + // „System": Die Betriebssystem-Einstellung ist die Quelle, aus der auch die Fenster + // auflösen – also dort nachsehen statt auf Anwendungsebene zu raten. + return app.PlatformSettings?.GetColorValues().ThemeVariant == PlatformThemeVariant.Dark; + } + } /// /// Einmalig beim Start aufrufen: übernimmt das gespeicherte Schema und merkt sich, wohin @@ -41,6 +67,28 @@ namespace PolyTrader.App.Avalonia.Shell _settingsPath = settingsPath; _logger = logger; ApplyVariant(settings.Theme); + HookSystemColorChanges(); + } + + /// + /// Bei „System" wechselt das Schema, ohne dass jemand bei uns klickt. Avalonia stellt die + /// Standard-Steuerelemente dann selbst um – die im Code gesetzten Farben und die Anzeige in + /// der Kopf- und Statusleiste bleiben aber stehen, bis feuert. + /// + private static void HookSystemColorChanges() + { + if (_colorHookAttached) return; + + var platform = global::Avalonia.Application.Current?.PlatformSettings; + if (platform == null) return; + + platform.ColorValuesChanged += (_, _) => + { + // Nur wer dem Betriebssystem folgt, ist von dessen Umstellung betroffen. + if (_settings?.Theme != AppThemeVariant.System) return; + Dispatcher.UIThread.Post(() => ThemeChanged?.Invoke()); + }; + _colorHookAttached = true; } /// Wechselt hell ↔ dunkel und speichert die Wahl. „System" wird dabei zu einer festen Wahl. @@ -92,7 +140,12 @@ namespace PolyTrader.App.Avalonia.Shell public static IBrush Brush(string key) { var app = global::Avalonia.Application.Current; - if (app != null && app.TryFindResource(key, app.ActualThemeVariant, out object? value) && value is IBrush b) + + // Nicht ActualThemeVariant: bei „System" ist das Default, und die Ressourcensuche + // liefert dann die hellen Farben, während die Fenster dunkel zeichnen. IsDark kennt + // die tatsächlich gezeichnete Variante – dieselbe Quelle wie für Symbol und Beschriftung. + var variant = IsDark ? ThemeVariant.Dark : ThemeVariant.Light; + if (app != null && app.TryFindResource(key, variant, out object? value) && value is IBrush b) return b; // Unbekannter Schlüssel ist ein Programmierfehler, darf die Oberfläche aber nicht kippen. diff --git a/src/PolyTrader.App.Avalonia/ViewModels/DashboardChartsModel.cs b/src/PolyTrader.App.Avalonia/ViewModels/DashboardChartsModel.cs index 8dae7a9..3aa05dd 100644 --- a/src/PolyTrader.App.Avalonia/ViewModels/DashboardChartsModel.cs +++ b/src/PolyTrader.App.Avalonia/ViewModels/DashboardChartsModel.cs @@ -71,29 +71,46 @@ namespace PolyTrader.App.Avalonia.ViewModels } }; + // Ohne Trades hat die Achse keine Werte. LiveCharts nahm dann einen Standardbereich um + // 0 an - und weil Tick 0 dem 01.01.0001 entspricht, stand an der Achse zwoelfmal + // "01.01." (Befund B-8 des Linux-Sichttests). Jetzt spannt sie im Leerzustand den + // tatsaechlich gewaehlten Zeitraum auf. + DateTime? emptyFrom = null, emptyTo = null; + if (curve.Count == 0) + { + emptyTo = DateTime.UtcNow.Date.AddDays(1); + emptyFrom = (since ?? emptyTo.Value.AddDays(-30)).Date; + if (emptyFrom >= emptyTo) emptyFrom = emptyTo.Value.AddDays(-1); + } + + // LiveCharts setzt rund zehn Teilstriche, gleich wie kurz der Zeitraum ist. Bei sieben + // Trades an zwei Tagen stand deshalb fuenfmal "25.08." und fuenfmal "26.08." an der + // Achse - fachlich richtig, gelesen aber als Fehler (Befund #386). Zwei Wege zu einem + // Ziel: keine zwei Teilstriche mit derselben Beschriftung. + TimeSpan span = curve.Count > 0 + ? curve[^1].At - curve[0].At + : emptyTo!.Value - emptyFrom!.Value; + bool shortSpan = span < TimeSpan.FromDays(3); + var axis = new Axis { // Ticks kommen als DateTime-Ticks – zurueckrechnen und kurz formatieren. // Die Bereichspruefung ist Pflicht: LiveCharts fragt auch Werte ausserhalb des // DateTime-Bereichs ab, und new DateTime(...) wuerde dort werfen. Labeler = value => value >= 0 && value <= DateTime.MaxValue.Ticks - ? new DateTime((long)value).ToString("dd.MM.") + ? new DateTime((long)value).ToString(shortSpan ? "dd.MM. HH:mm" : "dd.MM.") : string.Empty, UnitWidth = TimeSpan.FromDays(1).Ticks, - LabelsRotation = 0 + // Kurzer Zeitraum: die Uhrzeit unterscheidet die Teilstriche. Laengerer Zeitraum: + // das Datum genuegt, dann aber hoechstens ein Teilstrich je Tag. + MinStep = shortSpan ? 0d : TimeSpan.FromDays(1).Ticks, + LabelsRotation = shortSpan ? 45 : 0 }; - // Ohne Trades hat die Achse keine Werte. LiveCharts nahm dann einen Standardbereich um - // 0 an - und weil Tick 0 dem 01.01.0001 entspricht, stand an der Achse zwoelfmal - // "01.01." (Befund B-8 des Linux-Sichttests). Jetzt spannt sie im Leerzustand den - // tatsaechlich gewaehlten Zeitraum auf. - if (curve.Count == 0) + if (emptyFrom.HasValue) { - DateTime end = DateTime.UtcNow.Date.AddDays(1); - DateTime start = (since ?? end.AddDays(-30)).Date; - if (start >= end) start = end.AddDays(-1); - axis.MinLimit = start.Ticks; - axis.MaxLimit = end.Ticks; + axis.MinLimit = emptyFrom.Value.Ticks; + axis.MaxLimit = emptyTo!.Value.Ticks; } EquityXAxes = new[] { axis }; @@ -104,15 +121,7 @@ namespace PolyTrader.App.Avalonia.ViewModels var byMod = TradeAnalytics.PnlByKey(scoped, t => string.IsNullOrEmpty(t.ModuleName) ? "—" : t.ModuleName); - ModuleSeries = new ISeries[] - { - new ColumnSeries - { - Name = "PnL", - Values = byMod.Select(x => (double)x.Pnl).ToArray(), - Fill = new SolidColorPaint(Accent) - } - }; + ModuleSeries = SignedColumns(byMod.Select(x => x.Pnl).ToArray()); ModuleXAxes = new[] { new Axis { Labels = byMod.Select(x => x.Key).ToArray(), LabelsRotation = 0 } }; } @@ -120,16 +129,7 @@ namespace PolyTrader.App.Avalonia.ViewModels { var byDay = TradeAnalytics.PnlByDay(scoped); - DaySeries = new ISeries[] - { - new ColumnSeries - { - Name = "PnL", - Values = byDay.Select(x => (double)x.Pnl).ToArray(), - // Verlusttage rot, Gewinntage gruen – dieselbe Leseweise wie in den Trade-Listen. - Fill = new SolidColorPaint(byDay.Sum(x => x.Pnl) >= 0m ? Positive : Negative) - } - }; + DaySeries = SignedColumns(byDay.Select(x => x.Pnl).ToArray()); DayXAxes = new[] { new Axis @@ -140,6 +140,45 @@ namespace PolyTrader.App.Avalonia.ViewModels }; } + /// + /// Balken nach ihrem Vorzeichen faerben: Gewinn gruen, Verlust rot – dieselbe Leseweise wie + /// in den Trade-Listen. + /// + /// + /// Eine ColumnSeries hat genau ein Fill fuer alle ihre Balken; je Balken faerben + /// geht damit nicht. Frueher stand hier deshalb ein Ausdruck ueber die SUMME aller Werte – + /// der faerbte im Verlustfall auch die Gewinntage rot und sagte beim Ueberfliegen das + /// Gegenteil der Zahl darunter (Befund #385). + /// + /// Stattdessen zwei Reihen an denselben Positionen: die eine traegt die Gewinne, die andere + /// die Verluste, die jeweils andere laesst dort eine Luecke (null, wird nicht + /// gezeichnet). IgnoresBarPosition haelt sie uebereinander statt nebeneinander. + /// + private static ISeries[] SignedColumns(IReadOnlyList values) + { + double?[] OnlyWhere(bool positive) => values + .Select(v => positive == (v >= 0m) ? (double?)v : null) + .ToArray(); + + return new ISeries[] + { + new ColumnSeries + { + Name = "Gewinn", + Values = OnlyWhere(true), + Fill = new SolidColorPaint(Positive), + IgnoresBarPosition = true + }, + new ColumnSeries + { + Name = "Verlust", + Values = OnlyWhere(false), + Fill = new SolidColorPaint(Negative), + IgnoresBarPosition = true + } + }; + } + private void Set(ref T field, T value, [CallerMemberName] string? name = null) { field = value; diff --git a/src/PolyTrader.App.Avalonia/ViewModels/SettingsFields.cs b/src/PolyTrader.App.Avalonia/ViewModels/SettingsFields.cs index ae19b38..9046c13 100644 --- a/src/PolyTrader.App.Avalonia/ViewModels/SettingsFields.cs +++ b/src/PolyTrader.App.Avalonia/ViewModels/SettingsFields.cs @@ -186,6 +186,9 @@ namespace PolyTrader.App.Avalonia.ViewModels /// Nur-Lese-Anzeige (Eigenschaft ohne Setter, oder nicht editierbarer Typ). public sealed class ReadOnlySettingsField : SettingsField { + /// Steht für „hier steht nichts" – dieselbe Schreibweise wie in der Account-Übersicht. + public const string EmptyPlaceholder = "—"; + public ReadOnlySettingsField(object t, PropertyInfo p, string l, string? d) : base(t, p, l, d) { } public string Value @@ -198,6 +201,25 @@ namespace PolyTrader.App.Avalonia.ViewModels return raw?.ToString() ?? string.Empty; } } + + /// + /// Der Wert für die Oberfläche: leer wird zum Platzhalter. + /// + /// + /// „Deaktivierte Module" ist im Auslieferungszustand eine leere Liste. Ein leeres Nur-Lese-Feld + /// ist von einem fehlenden Bedienelement nicht zu unterscheiden – in der Zeile stand links die + /// Beschriftung, rechts der Hilfetext und dazwischen nichts, während jede andere Zeile des + /// Dialogs dort ein Feld hat (Befund #386). Der Platzhalter beantwortet die Frage „ist hier + /// etwas kaputt?" ohne Blick in den Quelltext. + /// + public string DisplayValue + { + get + { + string v = Value; + return string.IsNullOrWhiteSpace(v) ? EmptyPlaceholder : v; + } + } } /// Eine Kategorie mit ihren Feldern – entspricht einer Überschrift im Dialog. diff --git a/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml b/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml index a092ccd..c150af6 100644 --- a/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/DashboardWindow.axaml @@ -121,7 +121,7 @@ - + diff --git a/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml b/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml index 53c50bb..ff5a955 100644 --- a/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/JobsWindow.axaml @@ -40,7 +40,7 @@ - diff --git a/src/PolyTrader.App.Avalonia/Views/Modules/AccountingWindow.axaml b/src/PolyTrader.App.Avalonia/Views/Modules/AccountingWindow.axaml index 3d2d500..435aafc 100644 --- a/src/PolyTrader.App.Avalonia/Views/Modules/AccountingWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/Modules/AccountingWindow.axaml @@ -116,7 +116,7 @@ - + diff --git a/src/PolyTrader.App.Avalonia/Views/Modules/CopyTradingWindow.axaml b/src/PolyTrader.App.Avalonia/Views/Modules/CopyTradingWindow.axaml index 42ce71d..bfaa195 100644 --- a/src/PolyTrader.App.Avalonia/Views/Modules/CopyTradingWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/Modules/CopyTradingWindow.axaml @@ -103,7 +103,7 @@ - + @@ -146,7 +146,7 @@ - + diff --git a/src/PolyTrader.App.Avalonia/Views/Modules/ResolutionFarmingWindow.axaml b/src/PolyTrader.App.Avalonia/Views/Modules/ResolutionFarmingWindow.axaml index 4891d81..48234d0 100644 --- a/src/PolyTrader.App.Avalonia/Views/Modules/ResolutionFarmingWindow.axaml +++ b/src/PolyTrader.App.Avalonia/Views/Modules/ResolutionFarmingWindow.axaml @@ -22,7 +22,7 @@ - + @@ -41,7 +41,7 @@ - + @@ -61,7 +61,7 @@ - +