Add utility classes for theme management, waiting, watched products, and notifications
- Implement ThemeManager for dynamic light/dark theme switching in the application. - Create Wait class for cancellable delays without exceptions for smoother user experience. - Introduce WatchedProductsStore to manage and persist watched products in JSON format. - Add WindowsNotifier for system notifications to inform users of important events. - Develop ProductViewModel to encapsulate product data and manage UI interactions effectively.
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using AutoBidder.Models;
|
||||
using AutoBidder.Utilities;
|
||||
|
||||
namespace AutoBidder.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// Una riga della scheda Prodotti: la scheda del prodotto e, accanto, ciò che lo
|
||||
/// storico dice di lui.
|
||||
///
|
||||
/// <para>I limiti si espongono come testo e non come numeri annullabili perché la
|
||||
/// casella vuota è un'informazione vera — "nessuna preferenza, vale il predefinito" —
|
||||
/// e un <c>0</c> significherebbe l'opposto, cioè un tetto a zero.</para>
|
||||
/// </summary>
|
||||
public class ProductViewModel : INotifyPropertyChanged
|
||||
{
|
||||
/// <summary>Voci della colonna "Stato": la prima è l'assenza di preferenza.</summary>
|
||||
public static readonly string[] StateChoices = { DefaultLabel, "Attiva", "Osserva", "Ferma" };
|
||||
|
||||
private const string DefaultLabel = "Predefinito";
|
||||
|
||||
private ProductStatSummary? _stats;
|
||||
|
||||
public ProductViewModel(WatchedProduct rule, ProductStatSummary? stats)
|
||||
{
|
||||
Rule = rule;
|
||||
_stats = stats;
|
||||
}
|
||||
|
||||
/// <summary>La scheda salvata su disco. Le modifiche vanno rese durevoli dal chiamante.</summary>
|
||||
public WatchedProduct Rule { get; }
|
||||
|
||||
public string DisplayName => Rule.DisplayName;
|
||||
|
||||
public string Identity => Rule.Identity;
|
||||
|
||||
// ── Stellina ─────────────────────────────────────────────────────
|
||||
|
||||
public bool IsWatched
|
||||
{
|
||||
get => Rule.IsWatched;
|
||||
set
|
||||
{
|
||||
if (Rule.IsWatched == value) return;
|
||||
Rule.IsWatched = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(WatchGlyph));
|
||||
OnPropertyChanged(nameof(WatchTooltip));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stella piena quando il prodotto è seguito, vuota altrimenti.</summary>
|
||||
public string WatchGlyph => IsWatched ? "" : "";
|
||||
|
||||
public string WatchTooltip => IsWatched
|
||||
? "Seguito: le aste nuove di questo prodotto entrano da sole nel monitor."
|
||||
: "Non seguito: i limiti qui sotto valgono comunque per le aste che aggiungi tu.";
|
||||
|
||||
// ── Limiti propri del prodotto ───────────────────────────────────
|
||||
|
||||
public string MinPriceText
|
||||
{
|
||||
get => Format(Rule.MinPrice);
|
||||
set
|
||||
{
|
||||
Rule.MinPrice = ParseDouble(value);
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasCustomLimits));
|
||||
OnPropertyChanged(nameof(MatchesAdvice));
|
||||
}
|
||||
}
|
||||
|
||||
public string MaxPriceText
|
||||
{
|
||||
get => Format(Rule.MaxPrice);
|
||||
set
|
||||
{
|
||||
Rule.MaxPrice = ParseDouble(value);
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasCustomLimits));
|
||||
OnPropertyChanged(nameof(MatchesAdvice));
|
||||
}
|
||||
}
|
||||
|
||||
public string MaxClicksText
|
||||
{
|
||||
get => Rule.MaxClicks?.ToString(CultureInfo.InvariantCulture) ?? "";
|
||||
set
|
||||
{
|
||||
Rule.MaxClicks = ParseInt(value);
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasCustomLimits));
|
||||
|
||||
// Le puntate massime entrano nel tetto di spesa: cambiarle sposta il
|
||||
// massimo consigliato, e la colonna accanto deve accorgersene.
|
||||
RefreshAdvice();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orizzonte proprio: oltre quanti minuti dall'inizio non aggiungere l'asta da
|
||||
/// sola. Vuoto = vale il predefinito generale, <c>0</c> = nessun limite qui.
|
||||
/// </summary>
|
||||
public string MaxStartMinutesText
|
||||
{
|
||||
get => Rule.MaxStartMinutes?.ToString(CultureInfo.InvariantCulture) ?? "";
|
||||
set
|
||||
{
|
||||
Rule.MaxStartMinutes = ParseInt(value);
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasCustomLimits));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stato d'ingresso mostrato nella colonna a tendina.</summary>
|
||||
public string StateChoice
|
||||
{
|
||||
get => Rule.AutoAddState switch
|
||||
{
|
||||
"Active" => "Attiva",
|
||||
"Watch" => "Osserva",
|
||||
"Stopped" => "Ferma",
|
||||
_ => DefaultLabel
|
||||
};
|
||||
set
|
||||
{
|
||||
Rule.AutoAddState = value switch
|
||||
{
|
||||
"Attiva" => "Active",
|
||||
"Osserva" => "Watch",
|
||||
"Ferma" => "Stopped",
|
||||
_ => null
|
||||
};
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasCustomLimits));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasCustomLimits => Rule.HasCustomLimits;
|
||||
|
||||
public string AutoAddedCountDisplay => Rule.AutoAddedCount.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
// ── Statistiche del prodotto ─────────────────────────────────────
|
||||
|
||||
public bool HasStats => _stats is { Count: > 0 };
|
||||
|
||||
public int StatCount => _stats?.Count ?? 0;
|
||||
public int StatWonCount => _stats?.WonCount ?? 0;
|
||||
|
||||
public string StatCountDisplay => _stats == null ? "—" : _stats.Count.ToString(CultureInfo.InvariantCulture);
|
||||
|
||||
public string StatWinRateDisplay => HasStats
|
||||
? $"{(_stats!.WonCount * 100.0 / _stats.Count):F0}%"
|
||||
: "—";
|
||||
|
||||
public string StatAverageDisplay => HasStats && _stats!.AverageFinalPrice > 0
|
||||
? $"{_stats.AverageFinalPrice:F2} €" : "—";
|
||||
|
||||
public string StatMinDisplay => HasStats && _stats!.MinFinalPrice > 0
|
||||
? $"{_stats.MinFinalPrice:F2} €" : "—";
|
||||
|
||||
public string StatMaxDisplay => HasStats && _stats!.MaxFinalPrice > 0
|
||||
? $"{_stats.MaxFinalPrice:F2} €" : "—";
|
||||
|
||||
// ── Valore, costo vero e risparmio ───────────────────────────────
|
||||
//
|
||||
// Il prezzo di aggiudicazione da solo racconta meta' della storia: un buono da
|
||||
// 50 EUR chiuso a 4 EUR con duecento puntate e' costato 44 EUR, non 4. Il costo
|
||||
// vero e il risparmio si mostrano quindi insieme, e solo quando le puntate del
|
||||
// vincitore sono note: un risparmio calcolato sul solo prezzo sarebbe un numero
|
||||
// lusinghiero e falso.
|
||||
|
||||
/// <summary>Valore "Compra Subito" del prodotto.</summary>
|
||||
public string StatValueDisplay =>
|
||||
_stats?.BuyNowPrice is > 0 ? $"{_stats.BuyNowPrice.Value:F2} €" : "—";
|
||||
|
||||
/// <summary>Puntate tipiche spese da chi vince (mediana), sulle aste verificate.</summary>
|
||||
public string StatWinnerBidsDisplay => _stats?.WinnerBidsDisplay ?? "—";
|
||||
|
||||
/// <summary>Costo reale di una vittoria tipica: prezzo + puntate spese.</summary>
|
||||
public string StatWinCostDisplay =>
|
||||
_stats?.TypicalWinCost is { } c ? $"{c:F2} €" : "—";
|
||||
|
||||
/// <summary>Risparmio sul valore, calcolato sul costo vero.</summary>
|
||||
public string StatSavingsDisplay =>
|
||||
_stats?.TypicalSavingsPercent is { } p ? $"{p:F0}%" : "—";
|
||||
|
||||
/// <summary>True quando vincere costa piu' che comprare: va detto, non nascosto.</summary>
|
||||
public bool SavingsIsNegative => _stats?.TypicalSavingsPercent is < 0;
|
||||
|
||||
/// <summary>True quando il risparmio e' calcolabile: altrimenti la cella resta smorzata.</summary>
|
||||
public bool HasSavings => _stats?.TypicalSavingsPercent is not null;
|
||||
|
||||
/// <summary>Il ragionamento dietro costo e risparmio, per il suggerimento.</summary>
|
||||
public string ValueExplanation
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_stats == null || _stats.Count == 0) return "Nessuna asta conclusa per questo prodotto.";
|
||||
|
||||
if (_stats.WinnerBidsSample == 0)
|
||||
{
|
||||
return $"Valore {StatValueDisplay}. Le puntate spese da chi vince non sono " +
|
||||
"ancora note per nessuna delle aste registrate, quindi il costo reale " +
|
||||
"e il risparmio non si possono calcolare.\n\n" +
|
||||
"Si recuperano dal server con «Recupera puntate dei vincitori» " +
|
||||
"nella scheda Storico.";
|
||||
}
|
||||
|
||||
var bidCost = SettingsManager.Load().AverageBidCostEuro;
|
||||
var prezzo = PriceAdvisor.Percentile(_stats.FinalPrices, 0.50);
|
||||
|
||||
return $"Su {_stats.WinnerBidsSample} aste di cui si conoscono le puntate del " +
|
||||
$"vincitore (su {_stats.Count} concluse):\n\n" +
|
||||
$"chi vince spende in mediana {_stats.MedianWinnerBids:F0} puntate, e si " +
|
||||
$"aggiudica il prodotto a {prezzo:F2} €.\n\n" +
|
||||
$"Costo reale = {prezzo:F2} € + {_stats.MedianWinnerBids:F0} × " +
|
||||
$"{bidCost:F2} € = {StatWinCostDisplay}, contro un valore di " +
|
||||
$"{StatValueDisplay}: risparmio {StatSavingsDisplay}." +
|
||||
(_stats.FreeBidShare is { } quota
|
||||
? "\n\nIl conto è prudente: le suppone tutte a pagamento. In realtà " +
|
||||
$"il {quota:F0}% delle puntate spese dai vincitori di questo prodotto " +
|
||||
"era gratuita, quindi accumulando puntate gratis il costo scende molto."
|
||||
: "");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Limiti consigliati ───────────────────────────────────────────
|
||||
//
|
||||
// Il consiglio si ricalcola a ogni lettura perché dipende anche dai limiti scritti
|
||||
// a mano — il numero massimo di puntate entra nel tetto di spesa — e quindi deve
|
||||
// cambiare mentre l'utente li modifica, non alla prossima apertura.
|
||||
|
||||
private PriceAdvisor.Advice Advice()
|
||||
{
|
||||
if (_stats == null || _stats.FinalPrices.Count == 0) return default;
|
||||
|
||||
var settings = SettingsManager.Load();
|
||||
|
||||
return PriceAdvisor.Suggest(new PriceAdvisor.Input(
|
||||
FinalPrices: _stats.FinalPrices,
|
||||
WinnerBids: _stats.WinnerBids,
|
||||
BuyNowPrice: _stats.BuyNowPrice,
|
||||
ShippingCost: null,
|
||||
MaxBids: Rule.MaxClicks ?? settings.DefaultMaxClicks,
|
||||
BidCostEuro: settings.AverageBidCostEuro,
|
||||
MinSavingsPercentage: settings.MinSavingsPercentage,
|
||||
Coverage: settings.SuggestedPriceCoveragePercent / 100.0));
|
||||
}
|
||||
|
||||
public bool HasAdvice => Advice().HasAdvice;
|
||||
|
||||
public double? SuggestedMinPrice => Advice() is { HasAdvice: true } a ? a.MinPrice : null;
|
||||
|
||||
public double? SuggestedMaxPrice => Advice() is { HasAdvice: true } a ? a.MaxPrice : null;
|
||||
|
||||
/// <summary>Puntate massime consigliate: quante ne servono, ma solo se si possono pagare.</summary>
|
||||
public int? SuggestedMaxBids => Advice() is { HasAdvice: true } a ? a.MaxBids : null;
|
||||
|
||||
public string SuggestedMaxBidsDisplay => SuggestedMaxBids?.ToString() ?? "—";
|
||||
|
||||
// ── Statistiche estese ───────────────────────────────────────────
|
||||
// Una mediana da sola nasconde la forbice: su un articolo dove si vince con 12
|
||||
// puntate o con 900, sapere solo "25" porta a impostare limiti che non reggono.
|
||||
|
||||
public string StatMedianPriceDisplay => _stats?.MedianFinalPriceDisplay ?? "—";
|
||||
public string StatP75PriceDisplay => _stats?.P75FinalPriceDisplay ?? "—";
|
||||
|
||||
public string StatMinWinnerBidsDisplay => _stats?.MinWinnerBidsDisplay ?? "—";
|
||||
public string StatMaxWinnerBidsDisplay => _stats?.MaxWinnerBidsDisplay ?? "—";
|
||||
public string StatP75WinnerBidsDisplay => _stats?.P75WinnerBidsDisplay ?? "—";
|
||||
|
||||
public string StatMinSavingsDisplay => _stats?.MinSavingsDisplay ?? "—";
|
||||
public string StatMaxSavingsDisplay => _stats?.MaxSavingsDisplay ?? "—";
|
||||
public string StatProfitableShareDisplay => _stats?.ProfitableShareDisplay ?? "—";
|
||||
public string StatFrequencyDisplay => _stats?.FrequencyDisplay ?? "—";
|
||||
|
||||
/// <summary>Su quante aste il conteggio delle puntate è verificato.</summary>
|
||||
public string StatWinnerBidsSampleDisplay =>
|
||||
_stats is null ? "—" : $"{_stats.WinnerBidsSample}/{_stats.Count}";
|
||||
|
||||
/// <summary>Il dettaglio completo, per il suggerimento sulle colonne statistiche.</summary>
|
||||
public string StatsExplanation
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_stats is null || _stats.Count == 0) return "Nessuna asta conclusa per questo prodotto.";
|
||||
|
||||
var t = new System.Text.StringBuilder();
|
||||
t.Append($"{_stats.Count} aste concluse");
|
||||
if (_stats.HoursBetweenAuctions is > 0)
|
||||
t.Append($", una ogni {_stats.FrequencyDisplay} circa");
|
||||
t.Append(".\n\n");
|
||||
|
||||
t.Append($"Prezzo: min {_stats.MinFinalPrice:F2} € · 25° {_stats.P25FinalPrice:F2} € · " +
|
||||
$"mediana {_stats.MedianFinalPrice:F2} € · 75° {_stats.P75FinalPrice:F2} € · " +
|
||||
$"90° {_stats.P90FinalPrice:F2} € · max {_stats.MaxFinalPrice:F2} €\n\n");
|
||||
|
||||
if (_stats.WinnerBidsSample > 0)
|
||||
{
|
||||
t.Append($"Puntate per vincere (su {_stats.WinnerBidsSample} aste verificate): " +
|
||||
$"min {_stats.MinWinnerBids} · 25° {_stats.P25WinnerBids:F0} · " +
|
||||
$"mediana {_stats.MedianWinnerBids:F0} · 75° {_stats.P75WinnerBids:F0} · " +
|
||||
$"90° {_stats.P90WinnerBids:F0} · max {_stats.MaxWinnerBids}\n\n");
|
||||
|
||||
if (_stats.MinSavingsPercent is { } lo && _stats.MaxSavingsPercent is { } hi)
|
||||
{
|
||||
t.Append($"Risparmio: dal {lo:F0}% al {hi:F0}%, mediana " +
|
||||
$"{_stats.MedianSavingsPercent:F0}%. Sarebbe stato in utile nel " +
|
||||
$"{_stats.ProfitableShareDisplay} delle aste.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
t.Append("Le puntate spese da chi vince non sono ancora note: si recuperano " +
|
||||
"dal server con «Recupera puntate dei vincitori» nella scheda Storico.");
|
||||
}
|
||||
|
||||
return t.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public string SuggestedMinDisplay =>
|
||||
SuggestedMinPrice is > 0 ? $"{SuggestedMinPrice:F2} €" : HasAdvice ? "0,00 €" : "—";
|
||||
|
||||
public string SuggestedMaxDisplay =>
|
||||
SuggestedMaxPrice is > 0 ? $"{SuggestedMaxPrice:F2} €" : "—";
|
||||
|
||||
/// <summary>Il ragionamento dietro il consiglio, per il pannello e i suggerimenti.</summary>
|
||||
public string AdviceExplanation => Advice().Explanation;
|
||||
|
||||
/// <summary>True quando il tetto non arriva al prezzo tipico: l'articolo non conviene.</summary>
|
||||
public bool AdviceWarns => Advice().NotWorthwhile;
|
||||
|
||||
/// <summary>True se i limiti scritti sono già quelli consigliati (a meno di un centesimo).</summary>
|
||||
public bool MatchesAdvice
|
||||
{
|
||||
get
|
||||
{
|
||||
var a = Advice();
|
||||
if (!a.HasAdvice) return false;
|
||||
|
||||
return Rule.MinPrice.HasValue && Rule.MaxPrice.HasValue &&
|
||||
Math.Abs(Rule.MinPrice.Value - (a.MinPrice ?? 0)) < 0.005 &&
|
||||
Math.Abs(Rule.MaxPrice.Value - (a.MaxPrice ?? 0)) < 0.005 &&
|
||||
(a.MaxBids is not > 0 || Rule.MaxClicks == a.MaxBids);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scrive nei limiti del prodotto i valori consigliati. Restituisce false se non
|
||||
/// c'è un consiglio: il chiamante deve poter contare quante righe ha davvero toccato.
|
||||
/// </summary>
|
||||
public bool ApplyAdvice()
|
||||
{
|
||||
var a = Advice();
|
||||
if (!a.HasAdvice || a.MaxPrice is not > 0) return false;
|
||||
|
||||
Rule.MinPrice = a.MinPrice;
|
||||
Rule.MaxPrice = a.MaxPrice;
|
||||
|
||||
// Anche il tetto di puntate: prezzo e puntate sono due facce dello stesso
|
||||
// conto, e applicarne uno solo lascerebbe il limite a metà.
|
||||
if (a.MaxBids is > 0) Rule.MaxClicks = a.MaxBids;
|
||||
|
||||
RefreshLimits();
|
||||
return true;
|
||||
}
|
||||
|
||||
public string StatAverageBidsDisplay => HasStats
|
||||
? _stats!.AverageMyBids.ToString("F1", CultureInfo.InvariantCulture) : "—";
|
||||
|
||||
public string StatLastSeenDisplay => HasStats
|
||||
? _stats!.LastSeen.ToLocalTime().ToString("dd/MM/yyyy") : "—";
|
||||
|
||||
/// <summary>
|
||||
/// Riaggancia le statistiche dopo una ricarica dello storico, senza ricostruire la
|
||||
/// riga: la griglia manterrebbe altrimenti la selezione su un oggetto sostituito.
|
||||
/// </summary>
|
||||
public void UpdateStats(ProductStatSummary? stats)
|
||||
{
|
||||
_stats = stats;
|
||||
|
||||
OnPropertyChanged(nameof(HasStats));
|
||||
OnPropertyChanged(nameof(StatCount));
|
||||
OnPropertyChanged(nameof(StatWonCount));
|
||||
OnPropertyChanged(nameof(StatCountDisplay));
|
||||
OnPropertyChanged(nameof(StatWinRateDisplay));
|
||||
OnPropertyChanged(nameof(StatAverageDisplay));
|
||||
OnPropertyChanged(nameof(StatMinDisplay));
|
||||
OnPropertyChanged(nameof(StatMaxDisplay));
|
||||
OnPropertyChanged(nameof(StatValueDisplay));
|
||||
OnPropertyChanged(nameof(StatWinnerBidsDisplay));
|
||||
OnPropertyChanged(nameof(StatWinCostDisplay));
|
||||
OnPropertyChanged(nameof(StatSavingsDisplay));
|
||||
OnPropertyChanged(nameof(SavingsIsNegative));
|
||||
OnPropertyChanged(nameof(HasSavings));
|
||||
OnPropertyChanged(nameof(ValueExplanation));
|
||||
OnPropertyChanged(nameof(StatAverageBidsDisplay));
|
||||
OnPropertyChanged(nameof(StatLastSeenDisplay));
|
||||
RefreshAdvice();
|
||||
}
|
||||
|
||||
/// <summary>Rilegge dal modello i campi modificabili (dopo un'applicazione in blocco).</summary>
|
||||
public void RefreshLimits()
|
||||
{
|
||||
OnPropertyChanged(nameof(MinPriceText));
|
||||
OnPropertyChanged(nameof(MaxPriceText));
|
||||
OnPropertyChanged(nameof(MaxClicksText));
|
||||
OnPropertyChanged(nameof(MaxStartMinutesText));
|
||||
OnPropertyChanged(nameof(StateChoice));
|
||||
OnPropertyChanged(nameof(HasCustomLimits));
|
||||
RefreshAdvice();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rilegge il consiglio. Va chiamato dopo ogni modifica ai limiti: il tetto di spesa
|
||||
/// dipende dal numero massimo di puntate, quindi cambiando quello cambia anche il
|
||||
/// prezzo massimo consigliato — e vederlo fermo mentre si digita sarebbe fuorviante.
|
||||
/// </summary>
|
||||
public void RefreshAdvice()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasAdvice));
|
||||
OnPropertyChanged(nameof(SuggestedMinPrice));
|
||||
OnPropertyChanged(nameof(SuggestedMaxPrice));
|
||||
OnPropertyChanged(nameof(SuggestedMinDisplay));
|
||||
OnPropertyChanged(nameof(SuggestedMaxDisplay));
|
||||
OnPropertyChanged(nameof(SuggestedMaxBids));
|
||||
OnPropertyChanged(nameof(SuggestedMaxBidsDisplay));
|
||||
OnPropertyChanged(nameof(AdviceExplanation));
|
||||
OnPropertyChanged(nameof(AdviceWarns));
|
||||
OnPropertyChanged(nameof(MatchesAdvice));
|
||||
}
|
||||
|
||||
// ── Conversioni ──────────────────────────────────────────────────
|
||||
|
||||
private static string Format(double? value) =>
|
||||
value.HasValue ? value.Value.ToString("0.00", CultureInfo.InvariantCulture) : "";
|
||||
|
||||
/// <summary>
|
||||
/// Accetta sia la virgola sia il punto: chi scrive "12,50" in un'applicazione
|
||||
/// italiana non deve vedersi rifiutare il valore.
|
||||
/// </summary>
|
||||
private static double? ParseDouble(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
return double.TryParse(text.Trim().Replace(',', '.'), NumberStyles.Any,
|
||||
CultureInfo.InvariantCulture, out var parsed) && parsed >= 0
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int? ParseInt(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
return int.TryParse(text.Trim(), out var parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user