Files
Mimante/Mimante/ViewModels/ProductViewModel.cs
T
Alby96 7ca504a70a 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.
2026-08-04 21:49:53 +02:00

470 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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));
}
}