Modifica API

This commit is contained in:
2026-06-09 18:31:15 +02:00
parent cf69e3b2fd
commit 0ce501c58c
36 changed files with 212779 additions and 185792 deletions
+8
View File
@@ -0,0 +1,8 @@
---
name: my-agent
description: Descrivi cosa fa questo agente personalizzato e quando usarlo.
---
# my-agent
Definisci cosa fa questo agente personalizzato, includendo comportamento, funzionalità e istruzioni specifiche per il suo funzionamento.
@@ -1,6 +1 @@
<Application x:Class="HorseRacingPredictor.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="MainWindow.xaml">
<Application.Resources />
</Application>
<Application x:Class="HorseRacingPredictor.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="MainWindow.xaml"><Application.Resources><ResourceDictionary><ResourceDictionary.MergedDictionaries><ResourceDictionary Source="/Styles/LightTheme.xaml"/><ResourceDictionary Source="/Styles/GlobalStyles.xaml"/></ResourceDictionary.MergedDictionaries></ResourceDictionary></Application.Resources></Application>
@@ -59,4 +59,8 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<Folder Include="Football\WebSearch\" />
<Folder Include="HorseRacing\WebSearch\" />
</ItemGroup>
</Project>
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-72D75QY30S"></script>
<script>function gtag(){dataLayer.push(arguments)}window.dataLayer=window.dataLayer||[],gtag("js",new Date),gtag("config","G-72D75QY30S");</script>
<meta charset="utf-8">
<title>API-Football - Documentation</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="description" content="All the documentation about API-FOOTBALL and how to use all endpoints like Timezone, Seasons, Countries, Leagues, Teams, Standings, Fixtures, Events..">
<meta name="author" content="API FOOTBALL">
<meta name="keywords" content="api, football, soccer, statistics, rest, rest-api, restful, api, results, footballresult, soccerresult, json Football API, Soccer Livescore API, Livescore Feed, Livescore API, Football Feed, Soccer API, Soccer Feed, odds, odds xml, odds feeds, free api, free plan, free data, open data soccer, transfers, transfers players, open data json, predictions, pronostics, top scorers">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<meta name="google-site-verification" content="SjoK3Uh8ZXPz41he_9gqcEQq4oBv-nk-AqMbAZJhPYk" />
<meta property="og:url" content="https://www.api-football.com/documentation-v3" />
<meta property="og:type" content="website" />
<meta property="og:title" content="API-Football - Documentation" />
<meta property="og:description" content="All the documentation about API-FOOTBALL and how to use all endpoints like Timezone, Seasons, Countries, Leagues, Teams, Standings, Fixtures, Events.." />
<meta property="og:image" content="https://www.api-football.com/public/img/home1/hero-banner.png" />
<meta property="og:site_name" content="api-football" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:image" content="https://www.api-football.com/public/img/home1/hero-banner.png" />
<meta name="twitter:title" content="API-Football - Documentation" />
<meta name="twitter:description" content="All the documentation about API-FOOTBALL and how to use all endpoints like Timezone, Seasons, Countries, Leagues, Teams, Standings, Fixtures, Events.." />
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<style>
body {
margin: 0;
padding: 0;
}
</style>
<link rel="shortcut icon" type="image/png" href="https://www.api-football.com/public/img/favicon.ico">
<link rel="shortcut icon" href="https://www.api-football.com/public/img/favicon.ico">
<link rel="apple-touch-icon" sizes="120x120" href="https://www.api-football.com/public/img/favicon-120.png">
<link rel="apple-touch-icon" sizes="152x152" href="https://www.api-football.com/public/img/favicon-152.png">
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.public/js/1.4.2/respond.min.js"></script>
<![endif]-->
</head>
<body data-spy="scroll" data-target="#scroll-menu" data-offset="65">
<div id="loader-wrapper">
<div id="loader"></div>
</div>
<redoc spec-url='public/doc/openapi.yaml' hide-download-button></redoc>
<script src="https://cdn.redoc.ly/redoc/v2.0.0/bundles/redoc.standalone.js"> </script>
<script src="https://www.api-football.com/public/js/jquery.min.js"></script>
<script src="https://www.api-football.com/public/js/jquery-ui.js"></script>
<script src="https://www.api-football.com/public/js/owl.carousel.min.js"></script>
<script src="https://www.api-football.com/public/js/isotope.pkgd.min.js"></script>
<script src="https://www.api-football.com/public/js/imagesloaded.pkgd.min.js"></script>
<script src="https://www.api-football.com/public/js/wow.min.js"></script>
<script src="https://www.api-football.com/public/js/jquery.magnific-popup.min.js"></script>
<script src="https://www.api-football.com/public/js/jquery.counterup.min.js"></script>
<script src="https://www.api-football.com/public/js/countdown.js"></script>
<script src="https://www.api-football.com/public/js/jquery.slicknav.min.js"></script>
<script src="https://www.api-football.com/public/js/jquery.scrollUp.js"></script>
<script src="https://www.api-football.com/public/js/jquery.waypoints.min.js"></script>
<script src="https://www.api-football.com/public/js/popper.min.js"></script>
<script src="https://www.api-football.com/public/js/bootstrap.min.js"></script>
<script src="https://www.api-football.com/public/js/theme.js"></script>
<script type="text/javascript" src="//www.freeprivacypolicy.com/public/cookie-consent/3.1.0/cookie-consent.js"></script>
<script type="text/javascript">document.addEventListener('DOMContentLoaded', function () {setTimeout(function(){cookieconsent.run({"notice_banner_type":"simple","consent_type":"implied","palette":"light","language":"en","website_name":"www.api-football.com","cookies_policy_url":"https://www.api-football.com/privacy"});},1500);});</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -41,6 +41,22 @@ namespace HorseRacingPredictor.Football.API
}
}
/// <summary>
/// Ottiene le partite per una data e una lega specifica
/// </summary>
public RestResponse GetFixturesByDateAndLeague(DateTime date, int leagueId, int season)
{
try
{
string dateStr = date.ToString("yyyy-MM-dd");
return ExecuteRequest($"{Endpoint}?date={dateStr}&league={leagueId}&season={season}", ApiTypes.Fixtures);
}
catch (Exception ex)
{
throw new Exception($"Errore durante il recupero delle partite per la data {date.ToShortDateString()} e lega {leagueId}: {ex.Message}", ex);
}
}
/// <summary>
/// Ottiene le partite per un ID specifico
/// </summary>
@@ -9,14 +9,14 @@ namespace HorseRacingPredictor.Football.API
private const string Endpoint = "odds";
/// <summary>
/// Ottiene le quote per una data specifica
/// Ottiene le quote per una data specifica (tutti i bookmaker, nessun filtro).
/// </summary>
public RestResponse GetOddsByDate(DateTime date, int bookmaker = 8, int page = 1)
public RestResponse GetOddsByDate(DateTime date, int page = 1)
{
try
{
string dateStr = date.ToString("yyyy-MM-dd");
return ExecuteRequest($"{Endpoint}?date={dateStr}&bookmaker={bookmaker}&page={page}", ApiTypes.Odds);
return ExecuteRequest($"{Endpoint}?date={dateStr}&page={page}", ApiTypes.Odds);
}
catch (Exception ex)
{
@@ -25,14 +25,20 @@ namespace HorseRacingPredictor.Football.API
}
/// <summary>
/// Versione asincrona di GetOddsByDate
/// Overload legacy: mantiene la compatibilità con codice che passa bookmaker.
/// </summary>
public async Task<RestResponse> GetOddsByDateAsync(DateTime date, int bookmaker = 8, int page = 1)
public RestResponse GetOddsByDate(DateTime date, int bookmaker, int page)
=> GetOddsByDate(date, page);
/// <summary>
/// Versione asincrona di GetOddsByDate (tutti i bookmaker).
/// </summary>
public async Task<RestResponse> GetOddsByDateAsync(DateTime date, int page = 1)
{
try
{
string dateStr = date.ToString("yyyy-MM-dd");
return await ExecuteRequestAsync($"{Endpoint}?date={dateStr}&bookmaker={bookmaker}&page={page}");
return await ExecuteRequestAsync($"{Endpoint}?date={dateStr}&page={page}");
}
catch (Exception ex)
{
@@ -41,13 +47,13 @@ namespace HorseRacingPredictor.Football.API
}
/// <summary>
/// Ottiene le quote per una partita specifica
/// Ottiene le quote per una partita specifica (tutti i bookmaker).
/// </summary>
public RestResponse GetOddsByFixture(int fixtureId, int bookmaker = 8)
public RestResponse GetOddsByFixture(int fixtureId)
{
try
{
return ExecuteRequest($"{Endpoint}?fixture={fixtureId}&bookmaker={bookmaker}", ApiTypes.Odds);
return ExecuteRequest($"{Endpoint}?fixture={fixtureId}", ApiTypes.Odds);
}
catch (Exception ex)
{
@@ -0,0 +1,424 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace HorseRacingPredictor.Football
{
/// <summary>
/// Loads and joins ESPN Soccer CSV files (fixtures, teams, leagues, venues,
/// teamStats, standings, status, players, teamRoster, keyEventDescription)
/// for a given date, returning one enriched row per match sorted by kick-off time.
/// </summary>
public class EspnCsvLoader
{
// ── helpers ─────────────────────────────────────────────────────────────
private static string[] ParseCsvLine(string line)
{
var fields = new List<string>();
bool inQuotes = false;
var sb = new StringBuilder();
foreach (char c in line)
{
if (inQuotes)
{
if (c == '"') inQuotes = false;
else sb.Append(c);
}
else
{
if (c == '"') { inQuotes = true; }
else if (c == ',') { fields.Add(sb.ToString()); sb.Clear(); }
else sb.Append(c);
}
}
fields.Add(sb.ToString());
return fields.ToArray();
}
private static DataTable ReadCsv(string path)
{
var dt = new DataTable(Path.GetFileNameWithoutExtension(path));
if (!File.Exists(path)) return dt;
var lines = File.ReadAllLines(path, Encoding.UTF8);
if (lines.Length == 0) return dt;
var headers = ParseCsvLine(lines[0]);
foreach (var h in headers)
dt.Columns.Add(h.Trim(), typeof(string));
for (int i = 1; i < lines.Length; i++)
{
if (string.IsNullOrWhiteSpace(lines[i])) continue;
var vals = ParseCsvLine(lines[i]);
var row = dt.NewRow();
for (int c = 0; c < headers.Length && c < vals.Length; c++)
row[c] = vals[c]?.Trim() ?? "";
dt.Rows.Add(row);
}
return dt;
}
private static Dictionary<string, DataRow> IndexBy(DataTable dt, string key)
{
var d = new Dictionary<string, DataRow>(StringComparer.OrdinalIgnoreCase);
if (!dt.Columns.Contains(key)) return d;
foreach (DataRow r in dt.Rows)
{
var k = r[key]?.ToString() ?? "";
if (!string.IsNullOrWhiteSpace(k) && !d.ContainsKey(k))
d[k] = r;
}
return d;
}
private static Dictionary<string, List<DataRow>> IndexByMulti(DataTable dt, string key)
{
var d = new Dictionary<string, List<DataRow>>(StringComparer.OrdinalIgnoreCase);
if (!dt.Columns.Contains(key)) return d;
foreach (DataRow r in dt.Rows)
{
var k = r[key]?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(k)) continue;
if (!d.ContainsKey(k)) d[k] = new List<DataRow>();
d[k].Add(r);
}
return d;
}
private static string Get(DataRow r, string col)
=> r?.Table?.Columns?.Contains(col) == true ? r[col]?.ToString() ?? "" : "";
private static void Set(DataRow dest, string col, string value)
{
if (dest.Table.Columns.Contains(col))
dest[col] = value;
}
// ── public entry point ───────────────────────────────────────────────────
/// <summary>
/// Scans all *.csv files in <paramref name="folderPath"/>, identifies the known ESPN
/// Soccer files by name, joins them by the relational keys shown in the schema diagram,
/// and returns one <see cref="DataTable"/> row per match whose date matches
/// <paramref name="targetDate"/>. Rows are sorted chronologically by kick-off time.
/// </summary>
public static (DataTable table, string message) Load(
string folderPath, DateTime targetDate,
IProgress<int> progress = null, IProgress<string> status = null)
{
var csvFiles = Directory.GetFiles(folderPath, "*.csv", SearchOption.AllDirectories)
.ToDictionary(f => Path.GetFileNameWithoutExtension(f).ToLowerInvariant(), f => f);
DataTable TryRead(string name)
{
var key = csvFiles.Keys.FirstOrDefault(k => k.Contains(name));
return key != null ? ReadCsv(csvFiles[key]) : new DataTable(name);
}
status?.Report("Lettura file CSV…");
progress?.Report(5);
var fixtures = TryRead("fixture");
var teams = TryRead("team");
var leagues = TryRead("league");
var venues = TryRead("venue");
var teamStats = TryRead("teamstat");
var standings = TryRead("standing");
var statusTbl = TryRead("status");
var players = TryRead("player");
var teamRoster = TryRead("teamroster");
var keyEventDesc = TryRead("keyeventdescription");
status?.Report("Costruzione indici…");
progress?.Report(20);
// Build lookup dictionaries
var teamsById = IndexBy(teams, "teamId");
var leaguesById = IndexBy(leagues, "leagueId");
var venuesById = IndexBy(venues, "venueId");
var statusById = IndexBy(statusTbl,"statusId");
var keyEventsByTypeId = IndexBy(keyEventDesc, "keyEventTypeId");
// teamStats can have multiple rows per eventId+teamId index by "eventId|teamId"
var teamStatsByEventTeam = new Dictionary<string, DataRow>(StringComparer.OrdinalIgnoreCase);
foreach (DataRow r in teamStats.Rows)
{
var eid = Get(r, "eventId"); var tid = Get(r, "teamId");
if (!string.IsNullOrEmpty(eid) && !string.IsNullOrEmpty(tid))
teamStatsByEventTeam[$"{eid}|{tid}"] = r;
}
// standings index by "leagueId|teamId" (latest record wins)
var standingsByLeagueTeam = new Dictionary<string, DataRow>(StringComparer.OrdinalIgnoreCase);
foreach (DataRow r in standings.Rows)
{
var lid = Get(r, "leagueId"); var tid = Get(r, "teamId");
if (!string.IsNullOrEmpty(lid) && !string.IsNullOrEmpty(tid))
standingsByLeagueTeam[$"{lid}|{tid}"] = r;
}
// teamRoster multi-index by teamId
var rosterByTeam = IndexByMulti(teamRoster, "teamId");
// players index by athleteId
var playersById = IndexBy(players, "athleteId");
status?.Report("Filtraggio partite per data…");
progress?.Report(35);
// ── Filter fixtures by targetDate ────────────────────────────────────
string targetDateStr = targetDate.ToString("yyyy-MM-dd");
var dayFixtures = new List<DataRow>();
foreach (DataRow r in fixtures.Rows)
{
var dateVal = Get(r, "date");
if (dateVal.StartsWith(targetDateStr, StringComparison.Ordinal))
dayFixtures.Add(r);
}
if (dayFixtures.Count == 0)
return (new DataTable(), $"Nessuna partita trovata per il {targetDateStr} nei CSV");
// Sort by kick-off time
dayFixtures = dayFixtures
.OrderBy(r => Get(r, "date"))
.ToList();
// ── Build output schema ──────────────────────────────────────────────
var dt = new DataTable();
void AddCol(string name) { if (!dt.Columns.Contains(name)) dt.Columns.Add(name, typeof(string)); }
// Fixture core
AddCol("eventId"); AddCol("Data/Ora"); AddCol("StatoPartita");
AddCol("Lega"); AddCol("LeagueShortName"); AddCol("Stagione");
AddCol("Stadio"); AddCol("Città"); AddCol("Paese Stadio"); AddCol("CapacitàStadio"); AddCol("Presenze");
// Home team
AddCol("SquadraCasa"); AddCol("NomeBreveCasa"); AddCol("ColoreCasa");
AddCol("VincitoreCasa"); AddCol("GoalCasa"); AddCol("RigoreCasa");
// Away team
AddCol("SquadraOspite"); AddCol("NomeBreviOspite"); AddCol("ColoreOspite");
AddCol("VincitoireOspite"); AddCol("GoalOspite"); AddCol("RigoreOspite");
// Home stats
AddCol("H_PossessoPct"); AddCol("H_Falli"); AddCol("H_GialleCasa"); AddCol("H_RosseCasa");
AddCol("H_Fuorigioco"); AddCol("H_Angoli"); AddCol("H_Parate");
AddCol("H_Tiri"); AddCol("H_TiriInPorta"); AddCol("H_TiriPct");
AddCol("H_PassaggiAccurati"); AddCol("H_PassaggiTotali"); AddCol("H_PassaggiPct");
AddCol("H_CrossAccurati"); AddCol("H_CrossTotali");
AddCol("H_LongBallAccurati"); AddCol("H_LongBallTotali");
AddCol("H_TiriBloccati"); AddCol("H_TacklePct"); AddCol("H_Intercetti");
AddCol("H_Clearance"); AddCol("H_RigoriFatti"); AddCol("H_RigoriTentati");
// Away stats
AddCol("A_PossessoPct"); AddCol("A_Falli"); AddCol("A_GialleOspite"); AddCol("A_RosseOspite");
AddCol("A_Fuorigioco"); AddCol("A_Angoli"); AddCol("A_Parate");
AddCol("A_Tiri"); AddCol("A_TiriInPorta"); AddCol("A_TiriPct");
AddCol("A_PassaggiAccurati"); AddCol("A_PassaggiTotali"); AddCol("A_PassaggiPct");
AddCol("A_CrossAccurati"); AddCol("A_CrossTotali");
AddCol("A_LongBallAccurati"); AddCol("A_LongBallTotali");
AddCol("A_TiriBloccati"); AddCol("A_TacklePct"); AddCol("A_Intercetti");
AddCol("A_Clearance"); AddCol("A_RigoriFatti"); AddCol("A_RigoriTentati");
// Home standings
AddCol("H_Posizione"); AddCol("H_PartiteGiocate"); AddCol("H_Vittorie");
AddCol("H_Pareggi"); AddCol("H_Sconfitte"); AddCol("H_Punti");
AddCol("H_GoalFatti"); AddCol("H_GoalSubiti"); AddCol("H_DiffReti");
AddCol("H_PortaInviolata"); AddCol("H_Forma"); AddCol("H_ProssimoAvversario");
// Away standings
AddCol("A_Posizione"); AddCol("A_PartiteGiocate"); AddCol("A_Vittorie");
AddCol("A_Pareggi"); AddCol("A_Sconfitte"); AddCol("A_Punti");
AddCol("A_GoalFatti"); AddCol("A_GoalSubiti"); AddCol("A_DiffReti");
AddCol("A_PortaInviolata"); AddCol("A_Forma"); AddCol("A_ProssimoAvversario");
// Home roster summary
AddCol("H_Portieri"); AddCol("H_Difensori"); AddCol("H_Centrocampisti"); AddCol("H_Attaccanti");
AddCol("H_NumeroGiocatori");
// Away roster summary
AddCol("A_Portieri"); AddCol("A_Difensori"); AddCol("A_Centrocampisti"); AddCol("A_Attaccanti");
AddCol("A_NumeroGiocatori");
// Raw IDs (useful for AI)
AddCol("leagueId"); AddCol("homeTeamId"); AddCol("awayTeamId"); AddCol("venueId"); AddCol("statusId");
status?.Report("Costruzione righe output…");
progress?.Report(50);
int total = dayFixtures.Count;
int done = 0;
foreach (var fx in dayFixtures)
{
var eventId = Get(fx, "eventId");
var leagueId = Get(fx, "leagueId");
var homeTeamId = Get(fx, "homeTeamId");
var awayTeamId = Get(fx, "awayTeamId");
var venueId = Get(fx, "venueId");
var statusId = Get(fx, "statusId");
var dateStr = Get(fx, "date");
teamsById.TryGetValue(homeTeamId, out var homeTeam);
teamsById.TryGetValue(awayTeamId, out var awayTeam);
leaguesById.TryGetValue(leagueId, out var league);
venuesById.TryGetValue(venueId, out var venue);
statusById.TryGetValue(statusId, out var stat);
teamStatsByEventTeam.TryGetValue($"{eventId}|{homeTeamId}", out var hStats);
teamStatsByEventTeam.TryGetValue($"{eventId}|{awayTeamId}", out var aStats);
standingsByLeagueTeam.TryGetValue($"{leagueId}|{homeTeamId}", out var hStand);
standingsByLeagueTeam.TryGetValue($"{leagueId}|{awayTeamId}", out var aStand);
// Roster summaries
string RosterSummary(string teamId, string pos)
{
if (!rosterByTeam.TryGetValue(teamId, out var rosterRows)) return "";
var names = new List<string>();
foreach (var rr in rosterRows)
{
if (!Get(rr, "position").Contains(pos, StringComparison.OrdinalIgnoreCase)) continue;
var aid = Get(rr, "athleteId");
playersById.TryGetValue(aid, out var pl);
var name = pl != null ? Get(pl, "displayName") : Get(rr, "playerDisplayName");
if (!string.IsNullOrWhiteSpace(name)) names.Add(name);
}
return string.Join("; ", names);
}
int RosterCount(string teamId)
=> rosterByTeam.TryGetValue(teamId, out var rr) ? rr.Count : 0;
var row = dt.NewRow();
Set(row, "eventId", eventId);
Set(row, "Data/Ora", dateStr);
Set(row, "StatoPartita", Get(stat, "description"));
Set(row, "Lega", Get(league, "leagueName"));
Set(row, "LeagueShortName", Get(league, "leagueShortName"));
Set(row, "Stagione", Get(league, "year"));
Set(row, "Stadio", Get(venue, "fullName"));
Set(row, "Città", Get(venue, "city"));
Set(row, "Paese Stadio", Get(venue, "country"));
Set(row, "CapacitàStadio", Get(venue, "capacity"));
Set(row, "Presenze", Get(fx, "attendance"));
Set(row, "SquadraCasa", Get(homeTeam, "displayName"));
Set(row, "NomeBreveCasa", Get(homeTeam, "shortDisplayName"));
Set(row, "ColoreCasa", Get(homeTeam, "color"));
Set(row, "VincitoreCasa", Get(fx, "homeTeamWinner"));
Set(row, "GoalCasa", Get(fx, "homeTeamScore"));
Set(row, "RigoreCasa", Get(fx, "homeTeamShootoutScore"));
Set(row, "SquadraOspite", Get(awayTeam, "displayName"));
Set(row, "NomeBreviOspite", Get(awayTeam, "shortDisplayName"));
Set(row, "ColoreOspite", Get(awayTeam, "color"));
Set(row, "VincitoireOspite", Get(fx, "awayTeamWinner"));
Set(row, "GoalOspite", Get(fx, "awayTeamScore"));
Set(row, "RigoreOspite", Get(fx, "awayTeamShootoutScore"));
// Home team stats
Set(row, "H_PossessoPct", Get(hStats, "possessionPct"));
Set(row, "H_Falli", Get(hStats, "foulsCommitted"));
Set(row, "H_GialleCasa", Get(hStats, "yellowCards"));
Set(row, "H_RosseCasa", Get(hStats, "redCards"));
Set(row, "H_Fuorigioco", Get(hStats, "offsides"));
Set(row, "H_Angoli", Get(hStats, "wonCorners"));
Set(row, "H_Parate", Get(hStats, "saves"));
Set(row, "H_Tiri", Get(hStats, "totalShots"));
Set(row, "H_TiriInPorta", Get(hStats, "shotsOnTarget"));
Set(row, "H_TiriPct", Get(hStats, "shotPct"));
Set(row, "H_PassaggiAccurati", Get(hStats, "accuratePasses"));
Set(row, "H_PassaggiTotali", Get(hStats, "totalPasses"));
Set(row, "H_PassaggiPct", Get(hStats, "passPct"));
Set(row, "H_CrossAccurati", Get(hStats, "accurateCrosses"));
Set(row, "H_CrossTotali", Get(hStats, "totalCrosses"));
Set(row, "H_LongBallAccurati", Get(hStats, "accurateLongBalls"));
Set(row, "H_LongBallTotali", Get(hStats, "totalLongBalls"));
Set(row, "H_TiriBloccati", Get(hStats, "blockedShots"));
Set(row, "H_TacklePct", Get(hStats, "tacklePct"));
Set(row, "H_Intercetti", Get(hStats, "interceptions"));
Set(row, "H_Clearance", Get(hStats, "totalClearance"));
Set(row, "H_RigoriFatti", Get(hStats, "penaltyKickGoals"));
Set(row, "H_RigoriTentati", Get(hStats, "penaltyKickShots"));
// Away team stats
Set(row, "A_PossessoPct", Get(aStats, "possessionPct"));
Set(row, "A_Falli", Get(aStats, "foulsCommitted"));
Set(row, "A_GialleOspite", Get(aStats, "yellowCards"));
Set(row, "A_RosseOspite", Get(aStats, "redCards"));
Set(row, "A_Fuorigioco", Get(aStats, "offsides"));
Set(row, "A_Angoli", Get(aStats, "wonCorners"));
Set(row, "A_Parate", Get(aStats, "saves"));
Set(row, "A_Tiri", Get(aStats, "totalShots"));
Set(row, "A_TiriInPorta", Get(aStats, "shotsOnTarget"));
Set(row, "A_TiriPct", Get(aStats, "shotPct"));
Set(row, "A_PassaggiAccurati", Get(aStats, "accuratePasses"));
Set(row, "A_PassaggiTotali", Get(aStats, "totalPasses"));
Set(row, "A_PassaggiPct", Get(aStats, "passPct"));
Set(row, "A_CrossAccurati", Get(aStats, "accurateCrosses"));
Set(row, "A_CrossTotali", Get(aStats, "totalCrosses"));
Set(row, "A_LongBallAccurati", Get(aStats, "accurateLongBalls"));
Set(row, "A_LongBallTotali", Get(aStats, "totalLongBalls"));
Set(row, "A_TiriBloccati", Get(aStats, "blockedShots"));
Set(row, "A_TacklePct", Get(aStats, "tacklePct"));
Set(row, "A_Intercetti", Get(aStats, "interceptions"));
Set(row, "A_Clearance", Get(aStats, "totalClearance"));
Set(row, "A_RigoriFatti", Get(aStats, "penaltyKickGoals"));
Set(row, "A_RigoriTentati", Get(aStats, "penaltyKickShots"));
// Home standings
Set(row, "H_Posizione", Get(hStand, "teamRank"));
Set(row, "H_PartiteGiocate", Get(hStand, "gamesPlayed"));
Set(row, "H_Vittorie", Get(hStand, "wins"));
Set(row, "H_Pareggi", Get(hStand, "ties"));
Set(row, "H_Sconfitte", Get(hStand, "losses"));
Set(row, "H_Punti", Get(hStand, "points"));
Set(row, "H_GoalFatti", Get(hStand, "gf"));
Set(row, "H_GoalSubiti", Get(hStand, "ga"));
Set(row, "H_DiffReti", Get(hStand, "gd"));
Set(row, "H_PortaInviolata", Get(hStand, "clean_sheet"));
Set(row, "H_Forma", Get(hStand, "form"));
Set(row, "H_ProssimoAvversario", Get(hStand, "next_opponent"));
// Away standings
Set(row, "A_Posizione", Get(aStand, "teamRank"));
Set(row, "A_PartiteGiocate", Get(aStand, "gamesPlayed"));
Set(row, "A_Vittorie", Get(aStand, "wins"));
Set(row, "A_Pareggi", Get(aStand, "ties"));
Set(row, "A_Sconfitte", Get(aStand, "losses"));
Set(row, "A_Punti", Get(aStand, "points"));
Set(row, "A_GoalFatti", Get(aStand, "gf"));
Set(row, "A_GoalSubiti", Get(aStand, "ga"));
Set(row, "A_DiffReti", Get(aStand, "gd"));
Set(row, "A_PortaInviolata", Get(aStand, "clean_sheet"));
Set(row, "A_Forma", Get(aStand, "form"));
Set(row, "A_ProssimoAvversario", Get(aStand, "next_opponent"));
// Roster summaries
Set(row, "H_Portieri", RosterSummary(homeTeamId, "Goalkeeper"));
Set(row, "H_Difensori", RosterSummary(homeTeamId, "Defender"));
Set(row, "H_Centrocampisti", RosterSummary(homeTeamId, "Midfielder"));
Set(row, "H_Attaccanti", RosterSummary(homeTeamId, "Forward"));
Set(row, "H_NumeroGiocatori", RosterCount(homeTeamId).ToString());
Set(row, "A_Portieri", RosterSummary(awayTeamId, "Goalkeeper"));
Set(row, "A_Difensori", RosterSummary(awayTeamId, "Defender"));
Set(row, "A_Centrocampisti", RosterSummary(awayTeamId, "Midfielder"));
Set(row, "A_Attaccanti", RosterSummary(awayTeamId, "Forward"));
Set(row, "A_NumeroGiocatori", RosterCount(awayTeamId).ToString());
// Raw IDs
Set(row, "leagueId", leagueId);
Set(row, "homeTeamId", homeTeamId);
Set(row, "awayTeamId", awayTeamId);
Set(row, "venueId", venueId);
Set(row, "statusId", statusId);
dt.Rows.Add(row);
done++;
progress?.Report(50 + (int)((double)done / total * 45));
status?.Report($"Elaborazione partite… {done}/{total}");
}
progress?.Report(100);
status?.Report($"Caricate {dt.Rows.Count} partite dal CSV ESPN Soccer");
return (dt, null);
}
}
}
@@ -0,0 +1,38 @@
using System;
using System.Collections;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace HorseRacingPredictor.Football
{
/// <summary>
/// Converte bool → Visibility (True=Visible, False=Collapsed).
/// </summary>
[ValueConversion(typeof(bool), typeof(Visibility))]
public class BoolToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is true ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> value is Visibility v && v == Visibility.Visible;
}
/// <summary>
/// Converte ICollection → Visibility (Count>0 → Visible, altrimenti Collapsed).
/// Usato per nascondere i tab di dettaglio quando la collezione è vuota.
/// </summary>
[ValueConversion(typeof(ICollection), typeof(Visibility))]
public class CollectionToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is ICollection col && col.Count > 0) return Visibility.Visible;
return Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
}
@@ -3,13 +3,27 @@ using System.Collections.Generic;
namespace HorseRacingPredictor.Football
{
// ?? Preset lega ??????????????????????????????????????????????????????????
/// <summary>
/// Rappresenta una lega/competizione nota con ID API-Football.
/// </summary>
public class LeaguePreset
{
public int Id { get; init; }
public string Name { get; init; }
public string Country { get; init; }
public string Category{ get; init; } // "Nazionale" | "Internazionale"
}
/// <summary>
/// Parametri configurabili per controllare quali endpoint API-Football scaricare
/// e come filtrare i dati risultanti.
/// Sorgente unica: API-Football (https://www.api-football.com/).
/// </summary>
public class FootballDownloadOptions
{
// ?? Endpoint da scaricare ?????????????????????????????????
// ?? Endpoint da scaricare ?????????????????????????????????????????????
public bool DownloadFixtures { get; set; } = true;
public bool DownloadOdds { get; set; } = true;
public bool DownloadPredictions{ get; set; } = true;
@@ -20,56 +34,29 @@ namespace HorseRacingPredictor.Football
public bool DownloadStatistics { get; set; } = false;
public bool DownloadInjuries { get; set; } = false;
// ?? Endpoint supplementari (CSV separati) ?????????????????
/// <summary>Scarica statistiche giocatori per squadra/stagione.</summary>
public bool DownloadPlayerStats { get; set; } = false;
/// <summary>Scarica statistiche aggregate delle squadre per lega/stagione.</summary>
public bool DownloadTeamStats { get; set; } = false;
/// <summary>Scarica classifica marcatori della lega.</summary>
// Supplementary league-level data
public bool DownloadTopScorers { get; set; } = false;
/// <summary>Scarica classifica assistman della lega.</summary>
public bool DownloadTopAssists { get; set; } = false;
/// <summary>Scarica classifica cartellini (gialli e rossi) della lega.</summary>
public bool DownloadTopCards { get; set; } = false;
/// <summary>Scarica le rose attuali delle squadre.</summary>
public bool DownloadTeamStats { get; set; } = false;
public bool DownloadPlayerStats { get; set; } = false;
public bool DownloadSquads { get; set; } = false;
/// <summary>Scarica informazioni sugli allenatori delle squadre.</summary>
public bool DownloadCoaches { get; set; } = false;
/// <summary>Scarica lo storico trasferimenti delle squadre.</summary>
public bool DownloadTransfers { get; set; } = false;
/// <summary>
/// Indica se almeno un endpoint supplementare (CSV separato) è selezionato.
/// </summary>
/// <summary>Returns true if at least one supplementary endpoint is enabled.</summary>
public bool AnySupplementarySelected =>
DownloadPlayerStats || DownloadTeamStats || DownloadTopScorers ||
DownloadTopAssists || DownloadTopCards || DownloadSquads ||
DownloadTopScorers || DownloadTopAssists || DownloadTopCards ||
DownloadTeamStats || DownloadPlayerStats || DownloadSquads ||
DownloadCoaches || DownloadTransfers;
// ?? Filtri ????????????????????????????????????????????????
// ?? Filtri
/// <summary>
/// Se non vuoto, scarica fixture solo per queste leghe (league IDs).
/// Lista vuota = tutte le leghe.
/// </summary>
public List<int> LeagueIds { get; set; } = new();
/// <summary>
/// ID del bookmaker per le quote (default 8 = Bet365).
/// </summary>
public int BookmakerId { get; set; } = 8;
/// <summary>
/// Numero massimo di pagine da scaricare per le quote.
/// </summary>
public int OddsMaxPages { get; set; } = 3;
/// <summary>
/// Timezone IANA per le date delle fixture (es. "Europe/Rome").
/// </summary>
@@ -80,13 +67,6 @@ namespace HorseRacingPredictor.Football
/// </summary>
public int Season { get; set; } = 0;
/// <summary>
/// Numero massimo di fixture per cui scaricare dati aggiuntivi
/// (H2H, events, lineups, statistics) per evitare troppe chiamate.
/// 0 = nessun limite.
/// </summary>
public int MaxFixturesForDetails { get; set; } = 50;
/// <summary>
/// Ritardo in millisecondi tra una chiamata API e l'altra per rispettare il rate limit.
/// </summary>
@@ -114,13 +94,6 @@ namespace HorseRacingPredictor.Football
/// </summary>
public TimeSpan? TimeTo { get; set; }
/// <summary>
/// Opzioni per l'arricchimento tramite ricerca web.
/// Quando abilitato, per ogni partita viene eseguita una ricerca e i risultati
/// vengono aggiunti in una colonna dedicata del CSV.
/// </summary>
public WebSearch.WebSearchOptions WebSearch { get; set; } = new();
/// <summary>
/// Restituisce la stagione corrente calcolata (anno corrente o anno-1 se prima di luglio).
/// </summary>
@@ -139,30 +112,70 @@ namespace HorseRacingPredictor.Football
{
int calls = 0;
if (DownloadFixtures) calls += 1;
if (DownloadOdds) calls += OddsMaxPages;
if (DownloadOdds) calls += 1;
if (DownloadPredictions) calls += fixtureCount;
if (DownloadStandings && LeagueIds.Count > 0) calls += LeagueIds.Count;
else if (DownloadStandings) calls += 5; // stima
else if (DownloadStandings) calls += 5;
if (DownloadH2H) calls += fixtureCount;
if (DownloadEvents) calls += fixtureCount;
if (DownloadLineups) calls += fixtureCount;
if (DownloadStatistics) calls += fixtureCount;
if (DownloadInjuries) calls += 1;
if (CheckQuota) calls += 1; // status endpoint
// Supplementari (una chiamata per squadra coinvolta ? fixtureCount*2 unici, stima fixtureCount)
int teamEstimate = System.Math.Max(fixtureCount, 1);
int leagueEstimate = LeagueIds.Count > 0 ? LeagueIds.Count : 5;
if (DownloadPlayerStats) calls += teamEstimate; // 1 pagina per squadra (minimo)
if (DownloadTeamStats) calls += teamEstimate;
if (DownloadTopScorers) calls += leagueEstimate;
if (DownloadTopAssists) calls += leagueEstimate;
if (DownloadTopCards) calls += leagueEstimate * 2; // gialli + rossi
if (DownloadSquads) calls += teamEstimate;
if (DownloadCoaches) calls += teamEstimate;
if (DownloadTransfers) calls += teamEstimate;
if (CheckQuota) calls += 1;
return calls;
}
// ?? Preset leghe conosciute ??????????????????????????????????????????
/// <summary>
/// Lista predefinita delle principali leghe nazionali e competizioni internazionali europee.
/// ID basati su API-Football v3.
/// </summary>
public static readonly IReadOnlyList<LeaguePreset> WellKnownLeagues = new List<LeaguePreset>
{
// ?? Nazionali ????????????????????????????????????????????????????
new() { Id = 135, Name = "Serie A", Country = "Italy", Category = "Nazionale" },
new() { Id = 136, Name = "Serie B", Country = "Italy", Category = "Nazionale" },
new() { Id = 61, Name = "Ligue 1", Country = "France", Category = "Nazionale" },
new() { Id = 62, Name = "Ligue 2", Country = "France", Category = "Nazionale" },
new() { Id = 78, Name = "Bundesliga", Country = "Germany", Category = "Nazionale" },
new() { Id = 79, Name = "2. Bundesliga", Country = "Germany", Category = "Nazionale" },
new() { Id = 39, Name = "Premier League", Country = "England", Category = "Nazionale" },
new() { Id = 40, Name = "Championship", Country = "England", Category = "Nazionale" },
new() { Id = 140, Name = "La Liga", Country = "Spain", Category = "Nazionale" },
new() { Id = 141, Name = "La Liga 2", Country = "Spain", Category = "Nazionale" },
new() { Id = 88, Name = "Eredivisie", Country = "Netherlands", Category = "Nazionale" },
new() { Id = 94, Name = "Primeira Liga", Country = "Portugal", Category = "Nazionale" },
new() { Id = 144, Name = "Pro League", Country = "Belgium", Category = "Nazionale" },
new() { Id = 179, Name = "Premiership", Country = "Scotland", Category = "Nazionale" },
new() { Id = 106, Name = "Ekstraklasa", Country = "Poland", Category = "Nazionale" },
new() { Id = 197, Name = "Super League", Country = "Greece", Category = "Nazionale" },
new() { Id = 203, Name = "Süper Lig", Country = "Turkey", Category = "Nazionale" },
new() { Id = 235, Name = "Premier League", Country = "Russia", Category = "Nazionale" },
new() { Id = 98, Name = "Jupiler Pro League", Country = "Belgium", Category = "Nazionale" },
new() { Id = 218, Name = "Bundesliga", Country = "Austria", Category = "Nazionale" },
new() { Id = 207, Name = "Super League", Country = "Switzerland", Category = "Nazionale" },
new() { Id = 244, Name = "Allsvenskan", Country = "Sweden", Category = "Nazionale" },
new() { Id = 103, Name = "Eliteserien", Country = "Norway", Category = "Nazionale" },
new() { Id = 119, Name = "Superliga", Country = "Denmark", Category = "Nazionale" },
new() { Id = 113, Name = "Veikkausliiga", Country = "Finland", Category = "Nazionale" },
new() { Id = 172, Name = "Premier League", Country = "Ukraine", Category = "Nazionale" },
new() { Id = 333, Name = "Pro League", Country = "Saudi Arabia",Category = "Nazionale" },
new() { Id = 128, Name = "Liga Profesional", Country = "Argentina", Category = "Nazionale" },
new() { Id = 71, Name = "Brasileirão Série A", Country = "Brazil", Category = "Nazionale" },
new() { Id = 262, Name = "Liga MX", Country = "Mexico", Category = "Nazionale" },
// ?? Internazionali UEFA ??????????????????????????????????????????
new() { Id = 2, Name = "UEFA Champions League", Country = "World", Category = "Internazionale" },
new() { Id = 3, Name = "UEFA Europa League", Country = "World", Category = "Internazionale" },
new() { Id = 848, Name = "UEFA Europa Conference League", Country = "World", Category = "Internazionale" },
new() { Id = 4, Name = "UEFA Super Cup", Country = "World", Category = "Internazionale" },
new() { Id = 960, Name = "UEFA Nations League", Country = "World", Category = "Internazionale" },
new() { Id = 1, Name = "World Cup", Country = "World", Category = "Internazionale" },
new() { Id = 5, Name = "UEFA Euro", Country = "World", Category = "Internazionale" },
new() { Id = 6, Name = "Africa Cup of Nations", Country = "World", Category = "Internazionale" },
new() { Id = 7, Name = "Copa America", Country = "World", Category = "Internazionale" },
new() { Id = 15, Name = "FIFA Club World Cup", Country = "World", Category = "Internazionale" },
};
}
}
@@ -0,0 +1,334 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml;
namespace HorseRacingPredictor.Football
{
/// <summary>
/// Esporta una lista di <see cref="FootballMatchViewModel"/> in JSON o XML gerarchico.
/// I dati 1:N (eventi, formazioni, H2H, statistiche, infortuni, quote) sono annidati
/// all'interno del nodo/oggetto di ogni partita.
/// </summary>
public static class FootballExporter
{
// ── JSON ─────────────────────────────────────────────────────────────
public static void ExportJson(
IReadOnlyList<FootballMatchViewModel> matches,
string folder,
string filename,
Action<string> setStatus = null)
{
try
{
Directory.CreateDirectory(folder);
var path = Path.Combine(folder, filename.EndsWith(".json", StringComparison.OrdinalIgnoreCase)
? filename : filename + ".json");
var opts = new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
// Costruisce struttura serializzabile
var list = new List<object>();
foreach (var m in matches)
{
var obj = new
{
fixture = new
{
id = m.FixtureId,
dataOra = m.DataOra,
stato = m.Stato,
lega = m.Lega,
nazione = m.Nazione,
stagione = m.Stagione,
squadraCasa = m.SquadraCasa,
squadraOspite = m.SquadraOspite,
risultato = m.Risultato,
arbitro = m.Arbitro,
stadio = m.Stadio,
città = m.Città,
},
predizione = new
{
consiglio = m.PredizioneAdvice,
finaleBase = m.PredizioneFinaleBase,
percCasa = m.PredPercCasa,
percPari = m.PredPercPari,
percOspite = m.PredPercOspite,
goalHome = m.PredGoalsHome,
goalAway = m.PredGoalsAway,
formCasa = m.FormCasa,
formOspite = m.FormOspite,
attaccoCasa = m.AttaccoCasa,
difesaCasa = m.DifesaCasa,
attaccoOspite = m.AttaccoOspite,
difesaOspite = m.DifesaOspite,
poissonHome = m.PoissonHome,
poissonAway = m.PoissonAway,
poissonDraw = m.PoissonDraw,
under25 = m.Under25,
over25 = m.Over25,
goalGoal = m.GoalGoal,
noGoal = m.NoGoal,
},
classificaCasa = new
{
posizione = m.ClassificaCasa,
punti = m.PuntiCasa,
vittorie = m.VittorieCasa,
pareggi = m.PareggiCasa,
sconfitte = m.SconfitteCasa,
goalFatti = m.GoalFattiCasa,
goalSubiti = m.GoalSubitiCasa,
},
classificaOspite = new
{
posizione = m.ClassificaOspite,
punti = m.PuntiOspite,
vittorie = m.VittorieOspite,
pareggi = m.PareggiOspite,
sconfitte = m.SconfitteOspite,
goalFatti = m.GoalFattiOspite,
goalSubiti = m.GoalSubitiOspite,
},
infoWeb = m.InfoWebPartita,
eventi = m.Events,
formazioni = m.Lineups,
h2h = m.H2H,
statistiche= m.Statistics,
infortuni = m.Injuries,
quote = m.Odds,
};
list.Add(obj);
}
var json = JsonSerializer.Serialize(new { partite = list }, opts);
File.WriteAllText(path, json, Encoding.UTF8);
setStatus?.Invoke($"✅ JSON salvato: {path}");
}
catch (Exception ex)
{
setStatus?.Invoke($"❌ Errore export JSON: {ex.Message}");
}
}
// ── XML ──────────────────────────────────────────────────────────────
public static void ExportXml(
IReadOnlyList<FootballMatchViewModel> matches,
string folder,
string filename,
Action<string> setStatus = null)
{
try
{
Directory.CreateDirectory(folder);
var path = Path.Combine(folder, filename.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)
? filename : filename + ".xml");
var settings = new XmlWriterSettings
{
Indent = true,
IndentChars = " ",
Encoding = Encoding.UTF8
};
using var writer = XmlWriter.Create(path, settings);
writer.WriteStartDocument();
writer.WriteStartElement("partite");
foreach (var m in matches)
{
writer.WriteStartElement("partita");
// ── Fixture ──────────────────────────────────────────────
writer.WriteStartElement("fixture");
WriteXmlEl(writer, "id", m.FixtureId);
WriteXmlEl(writer, "dataOra", m.DataOra);
WriteXmlEl(writer, "stato", m.Stato);
WriteXmlEl(writer, "lega", m.Lega);
WriteXmlEl(writer, "nazione", m.Nazione);
WriteXmlEl(writer, "stagione", m.Stagione);
WriteXmlEl(writer, "squadraCasa", m.SquadraCasa);
WriteXmlEl(writer, "squadraOspite", m.SquadraOspite);
WriteXmlEl(writer, "risultato", m.Risultato);
WriteXmlEl(writer, "arbitro", m.Arbitro);
WriteXmlEl(writer, "stadio", m.Stadio);
WriteXmlEl(writer, "citta", m.Città);
writer.WriteEndElement();
// ── Predizione ───────────────────────────────────────────
writer.WriteStartElement("predizione");
WriteXmlEl(writer, "consiglio", m.PredizioneAdvice);
WriteXmlEl(writer, "finaleBase", m.PredizioneFinaleBase);
WriteXmlEl(writer, "percCasa", m.PredPercCasa);
WriteXmlEl(writer, "percPari", m.PredPercPari);
WriteXmlEl(writer, "percOspite", m.PredPercOspite);
WriteXmlEl(writer, "goalHome", m.PredGoalsHome);
WriteXmlEl(writer, "goalAway", m.PredGoalsAway);
WriteXmlEl(writer, "formCasa", m.FormCasa);
WriteXmlEl(writer, "formOspite", m.FormOspite);
WriteXmlEl(writer, "attaccoCasa", m.AttaccoCasa);
WriteXmlEl(writer, "difesaCasa", m.DifesaCasa);
WriteXmlEl(writer, "attaccoOspite", m.AttaccoOspite);
WriteXmlEl(writer, "difesaOspite", m.DifesaOspite);
WriteXmlEl(writer, "poissonHome", m.PoissonHome);
WriteXmlEl(writer, "poissonAway", m.PoissonAway);
WriteXmlEl(writer, "poissonDraw", m.PoissonDraw);
WriteXmlEl(writer, "under25", m.Under25);
WriteXmlEl(writer, "over25", m.Over25);
WriteXmlEl(writer, "goalGoal", m.GoalGoal);
WriteXmlEl(writer, "noGoal", m.NoGoal);
writer.WriteEndElement();
// ── Classifiche ──────────────────────────────────────────
WriteXmlStanding(writer, "classificaCasa", m.ClassificaCasa, m.PuntiCasa, m.VittorieCasa, m.PareggiCasa, m.SconfitteCasa, m.GoalFattiCasa, m.GoalSubitiCasa);
WriteXmlStanding(writer, "classificaOspite", m.ClassificaOspite, m.PuntiOspite, m.VittorieOspite, m.PareggiOspite, m.SconfitteOspite, m.GoalFattiOspite, m.GoalSubitiOspite);
// ── Info Web ─────────────────────────────────────────────
if (!string.IsNullOrWhiteSpace(m.InfoWebPartita))
WriteXmlEl(writer, "infoWeb", m.InfoWebPartita);
// ── 1:N ──────────────────────────────────────────────────
if (m.Events?.Count > 0)
{
writer.WriteStartElement("eventi");
foreach (var ev in m.Events)
{
writer.WriteStartElement("evento");
WriteXmlEl(writer, "minuto", ev.Minuto);
WriteXmlEl(writer, "tipo", ev.Tipo);
WriteXmlEl(writer, "dettaglio", ev.Dettaglio);
WriteXmlEl(writer, "squadra", ev.Squadra);
WriteXmlEl(writer, "giocatore", ev.Giocatore);
WriteXmlEl(writer, "assistito", ev.Assistito);
writer.WriteEndElement();
}
writer.WriteEndElement();
}
if (m.Lineups?.Count > 0)
{
writer.WriteStartElement("formazioni");
foreach (var l in m.Lineups)
{
writer.WriteStartElement("giocatore");
WriteXmlEl(writer, "squadra", l.Squadra);
WriteXmlEl(writer, "formazione", l.Formazione);
WriteXmlEl(writer, "numero", l.Numero);
WriteXmlEl(writer, "nome", l.Giocatore);
WriteXmlEl(writer, "posizione", l.Posizione);
WriteXmlEl(writer, "titolare", l.Titolare);
writer.WriteEndElement();
}
writer.WriteEndElement();
}
if (m.H2H?.Count > 0)
{
writer.WriteStartElement("h2h");
foreach (var h in m.H2H)
{
writer.WriteStartElement("incontro");
WriteXmlEl(writer, "data", h.Data);
WriteXmlEl(writer, "lega", h.Lega);
WriteXmlEl(writer, "casa", h.SquadraCasa);
WriteXmlEl(writer, "ospite", h.SquadraOsp);
WriteXmlEl(writer, "risultato", h.Risultato);
WriteXmlEl(writer, "vincitore", h.Vincitore);
writer.WriteEndElement();
}
writer.WriteEndElement();
}
if (m.Statistics?.Count > 0)
{
writer.WriteStartElement("statistiche");
foreach (var s in m.Statistics)
{
writer.WriteStartElement("statistica");
WriteXmlEl(writer, "squadra", s.Squadra);
WriteXmlEl(writer, "tipo", s.Tipo);
WriteXmlEl(writer, "valore", s.Valore);
writer.WriteEndElement();
}
writer.WriteEndElement();
}
if (m.Injuries?.Count > 0)
{
writer.WriteStartElement("infortuni");
foreach (var inj in m.Injuries)
{
writer.WriteStartElement("infortunio");
WriteXmlEl(writer, "squadra", inj.Squadra);
WriteXmlEl(writer, "giocatore", inj.Giocatore);
WriteXmlEl(writer, "tipo", inj.Tipo);
WriteXmlEl(writer, "gravita", inj.Gravità);
WriteXmlEl(writer, "rientro", inj.DataRientro);
writer.WriteEndElement();
}
writer.WriteEndElement();
}
if (m.Odds?.Count > 0)
{
writer.WriteStartElement("quote");
foreach (var o in m.Odds)
{
writer.WriteStartElement("quota");
WriteXmlEl(writer, "bookmaker", o.Bookmaker);
WriteXmlEl(writer, "mercato", o.Mercato);
WriteXmlEl(writer, "esito", o.Esito);
WriteXmlEl(writer, "valore", o.Quota);
writer.WriteEndElement();
}
writer.WriteEndElement();
}
writer.WriteEndElement(); // </partita>
}
writer.WriteEndElement(); // </partite>
writer.WriteEndDocument();
setStatus?.Invoke($"✅ XML salvato: {path}");
}
catch (Exception ex)
{
setStatus?.Invoke($"❌ Errore export XML: {ex.Message}");
}
}
// ── Helpers ──────────────────────────────────────────────────────────
private static void WriteXmlEl(XmlWriter w, string tag, string value)
{
if (string.IsNullOrWhiteSpace(value)) return;
w.WriteElementString(tag, value);
}
private static void WriteXmlStanding(XmlWriter w, string tag,
string pos, string punti, string vit, string par, string sco, string gf, string gs)
{
w.WriteStartElement(tag);
WriteXmlEl(w, "posizione", pos);
WriteXmlEl(w, "punti", punti);
WriteXmlEl(w, "vittorie", vit);
WriteXmlEl(w, "pareggi", par);
WriteXmlEl(w, "sconfitte", sco);
WriteXmlEl(w, "goalFatti", gf);
WriteXmlEl(w, "goalSubiti",gs);
w.WriteEndElement();
}
}
}
@@ -0,0 +1,376 @@
<UserControl x:Class="HorseRacingPredictor.Football.FootballGridView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:HorseRacingPredictor.Football">
<UserControl.Resources>
<local:BoolToVisibilityConverter x:Key="BoolVisConv"/>
<local:CollectionToVisibilityConverter x:Key="ColVisConv"/>
<!-- ── TreeView global style ─────────────────────────────────────── -->
<Style TargetType="TreeView">
<Setter Property="Background" Value="{DynamicResource BrSurfaceContainerLow}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Auto"/>
</Style>
<!-- ── TreeViewItem strip all arrow decoration ───────────────────── -->
<Style TargetType="TreeViewItem">
<Setter Property="IsExpanded" Value="False"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TreeViewItem">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<Border x:Name="Bd" Grid.Row="0"
Background="{TemplateBinding Background}"
BorderThickness="0">
<ContentPresenter x:Name="PART_Header"
ContentSource="Header"
HorizontalAlignment="Stretch"/>
</Border>
<ItemsPresenter x:Name="ItemsHost" Grid.Row="1"
Visibility="Collapsed"
Margin="0,0,0,0"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsExpanded" Value="True">
<Setter TargetName="ItemsHost" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background"
Value="{DynamicResource BrSecondaryContainer}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background"
Value="{DynamicResource BrRowHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ── Shared sub-DataGrid style ─────────────────────────────────── -->
<Style x:Key="SubDg" TargetType="DataGrid">
<Setter Property="Background" Value="{DynamicResource BrSurfaceContainer}"/>
<Setter Property="RowBackground" Value="{DynamicResource BrSurfaceContainer}"/>
<Setter Property="AlternatingRowBackground" Value="{DynamicResource BrSurfaceContainer}"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource BrOutlineVariant}"/>
<Setter Property="HeadersVisibility" Value="Column"/>
<Setter Property="RowHeaderWidth" Value="0"/>
<Setter Property="AutoGenerateColumns" Value="False"/>
<Setter Property="IsReadOnly" Value="True"/>
<Setter Property="CanUserAddRows" Value="False"/>
<Setter Property="CanUserDeleteRows" Value="False"/>
<Setter Property="CanUserResizeRows" Value="False"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="RowStyle">
<Setter.Value>
<Style TargetType="DataGridRow">
<Setter Property="Background" Value="{DynamicResource BrSurfaceContainer}"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource BrRowHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource BrSecondaryContainer}"/>
</Trigger>
</Style.Triggers>
</Style>
</Setter.Value>
</Setter>
<Setter Property="CellStyle">
<Setter.Value>
<Style TargetType="DataGridCell">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Setter Property="BorderThickness" Value="0"/>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
</Trigger>
</Style.Triggers>
</Style>
</Setter.Value>
</Setter>
<Setter Property="ColumnHeaderStyle">
<Setter.Value>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="{DynamicResource BrSurfaceContainerHigh}"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurfaceVariant}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Padding" Value="8,4"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
</Setter.Value>
</Setter>
</Style>
<!-- ── Section label style ───────────────────────────────────────── -->
<Style x:Key="SectionLabel" TargetType="TextBlock">
<Setter Property="FontSize" Value="11"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurfaceVariant}"/>
<Setter Property="Margin" Value="16,8,0,2"/>
</Style>
<!-- ════════════════════════════════════════════════════════════════
CATEGORY CHILD NODE TEMPLATE one expandable detail section
════════════════════════════════════════════════════════════════ -->
<DataTemplate x:Key="CategoryNodeTemplate" DataType="{x:Type local:DetailCategoryNode}">
<Border Margin="40,0,4,2"
Background="{DynamicResource BrSurfaceContainer}"
CornerRadius="8" BorderBrush="{DynamicResource BrOutlineVariant}"
BorderThickness="1">
<StackPanel>
<!-- Category header row with expand arrow -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Content="{Binding ExpandIcon}"
FontFamily="Segoe UI Symbol" FontSize="13"
Foreground="{DynamicResource BrPrimary}"
Background="Transparent" BorderThickness="0"
Cursor="Hand" Padding="10,6"
VerticalAlignment="Center"
Click="BtnCategoryExpand_Click"/>
<TextBlock Grid.Column="1"
Text="{Binding Summary}"
FontSize="12" FontWeight="SemiBold"
Foreground="{DynamicResource BrOnSurface}"
VerticalAlignment="Center" Margin="0,0,12,0"/>
</Grid>
<!-- Expanded content — only the populated DataGrid is visible -->
<StackPanel Visibility="{Binding IsExpanded, Converter={StaticResource BoolVisConv}}">
<!-- Events -->
<DataGrid Style="{StaticResource SubDg}"
ItemsSource="{Binding Events}"
Visibility="{Binding Events, Converter={StaticResource ColVisConv}}">
<DataGrid.Columns>
<DataGridTextColumn Header="Min" Binding="{Binding Minuto}" Width="50"/>
<DataGridTextColumn Header="Tipo" Binding="{Binding Tipo}" Width="100"/>
<DataGridTextColumn Header="Dettaglio" Binding="{Binding Dettaglio}" Width="140"/>
<DataGridTextColumn Header="Squadra" Binding="{Binding Squadra}" Width="150"/>
<DataGridTextColumn Header="Giocatore" Binding="{Binding Giocatore}" Width="180"/>
<DataGridTextColumn Header="Assistito" Binding="{Binding Assistito}" Width="180"/>
</DataGrid.Columns>
</DataGrid>
<!-- Lineups -->
<DataGrid Style="{StaticResource SubDg}"
ItemsSource="{Binding Lineups}"
Visibility="{Binding Lineups, Converter={StaticResource ColVisConv}}">
<DataGrid.Columns>
<DataGridTextColumn Header="Squadra" Binding="{Binding Squadra}" Width="150"/>
<DataGridTextColumn Header="Modulo" Binding="{Binding Formazione}" Width="80"/>
<DataGridTextColumn Header="N°" Binding="{Binding Numero}" Width="40"/>
<DataGridTextColumn Header="Giocatore" Binding="{Binding Giocatore}" Width="180"/>
<DataGridTextColumn Header="Posizione" Binding="{Binding Posizione}" Width="100"/>
<DataGridTextColumn Header="Titolare" Binding="{Binding Titolare}" Width="70"/>
</DataGrid.Columns>
</DataGrid>
<!-- H2H -->
<DataGrid Style="{StaticResource SubDg}"
ItemsSource="{Binding H2H}"
Visibility="{Binding H2H, Converter={StaticResource ColVisConv}}">
<DataGrid.Columns>
<DataGridTextColumn Header="Data" Binding="{Binding Data}" Width="90"/>
<DataGridTextColumn Header="Lega" Binding="{Binding Lega}" Width="130"/>
<DataGridTextColumn Header="Casa" Binding="{Binding SquadraCasa}" Width="150"/>
<DataGridTextColumn Header="Ospite" Binding="{Binding SquadraOsp}" Width="150"/>
<DataGridTextColumn Header="Risultato" Binding="{Binding Risultato}" Width="70"/>
<DataGridTextColumn Header="Vincitore" Binding="{Binding Vincitore}" Width="110"/>
</DataGrid.Columns>
</DataGrid>
<!-- Statistics -->
<DataGrid Style="{StaticResource SubDg}"
ItemsSource="{Binding Statistics}"
Visibility="{Binding Statistics, Converter={StaticResource ColVisConv}}">
<DataGrid.Columns>
<DataGridTextColumn Header="Squadra" Binding="{Binding Squadra}" Width="150"/>
<DataGridTextColumn Header="Tipo" Binding="{Binding Tipo}" Width="220"/>
<DataGridTextColumn Header="Valore" Binding="{Binding Valore}" Width="100"/>
</DataGrid.Columns>
</DataGrid>
<!-- Injuries -->
<DataGrid Style="{StaticResource SubDg}"
ItemsSource="{Binding Injuries}"
Visibility="{Binding Injuries, Converter={StaticResource ColVisConv}}">
<DataGrid.Columns>
<DataGridTextColumn Header="Squadra" Binding="{Binding Squadra}" Width="150"/>
<DataGridTextColumn Header="Giocatore" Binding="{Binding Giocatore}" Width="180"/>
<DataGridTextColumn Header="Tipo" Binding="{Binding Tipo}" Width="130"/>
<DataGridTextColumn Header="Gravità" Binding="{Binding Gravità}" Width="130"/>
<DataGridTextColumn Header="Rientro" Binding="{Binding DataRientro}" Width="110"/>
</DataGrid.Columns>
</DataGrid>
<!-- Odds -->
<DataGrid Style="{StaticResource SubDg}"
ItemsSource="{Binding Odds}"
Visibility="{Binding Odds, Converter={StaticResource ColVisConv}}">
<DataGrid.Columns>
<DataGridTextColumn Header="Bookmaker" Binding="{Binding Bookmaker}" Width="140"/>
<DataGridTextColumn Header="Mercato" Binding="{Binding Mercato}" Width="180"/>
<DataGridTextColumn Header="Esito" Binding="{Binding Esito}" Width="110"/>
<DataGridTextColumn Header="Quota" Binding="{Binding Quota}" Width="80"/>
</DataGrid.Columns>
</DataGrid>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
<!-- ════════════════════════════════════════════════════════════════
ROOT NODE TEMPLATE one fixture / match
════════════════════════════════════════════════════════════════ -->
<HierarchicalDataTemplate x:Key="FixtureNodeTemplate"
ItemsSource="{Binding Children}"
ItemTemplate="{StaticResource CategoryNodeTemplate}">
<!-- ── Fixture header card ──────────────────────────────────── -->
<Border Margin="0,4,0,0"
Background="{DynamicResource BrSurfaceContainerLow}"
CornerRadius="10" Padding="0,0,0,0"
BorderBrush="{DynamicResource BrOutlineVariant}"
BorderThickness="1">
<Border.Effect>
<DropShadowEffect BlurRadius="4" ShadowDepth="1" Opacity="0.08" Color="#000"/>
</Border.Effect>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Expand/collapse toggle -->
<Button Grid.Column="0"
Content="{Binding ExpandIcon}"
FontFamily="Segoe UI Symbol" FontSize="13"
Foreground="{DynamicResource BrPrimary}"
Background="Transparent" BorderThickness="0"
Cursor="Hand" Padding="10,0" VerticalAlignment="Center"
Visibility="{Binding HasChildren, Converter={StaticResource BoolVisConv}}"
Click="BtnExpand_Click"/>
<!-- Core match data -->
<Grid Grid.Column="1" Margin="4,10,16,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="58"/> <!-- Ora -->
<ColumnDefinition Width="130"/> <!-- Lega -->
<ColumnDefinition Width="*"/> <!-- Casa -->
<ColumnDefinition Width="80"/> <!-- Risultato -->
<ColumnDefinition Width="*"/> <!-- Ospite -->
<ColumnDefinition Width="80"/> <!-- Stato -->
<ColumnDefinition Width="90"/> <!-- Consiglio -->
<ColumnDefinition Width="46"/> <!-- 1 -->
<ColumnDefinition Width="46"/> <!-- X -->
<ColumnDefinition Width="46"/> <!-- 2 -->
<ColumnDefinition Width="60"/> <!-- Over2.5 -->
<ColumnDefinition Width="60"/> <!-- G/G -->
<ColumnDefinition Width="50"/> <!-- PosCasa -->
<ColumnDefinition Width="50"/> <!-- PosOsp -->
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="{Binding DataOra}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="1" Text="{Binding Lega}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" Margin="4,0"/>
<TextBlock Grid.Column="2" Text="{Binding SquadraCasa}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" FontWeight="Medium" HorizontalAlignment="Right" Margin="0,0,8,0"/>
<TextBlock Grid.Column="3" Text="{Binding Risultato}" VerticalAlignment="Center" HorizontalAlignment="Center"
FontWeight="Bold" Foreground="{DynamicResource BrPrimary}"/>
<TextBlock Grid.Column="4" Text="{Binding SquadraOspite}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" Margin="8,0,0,0"/>
<TextBlock Grid.Column="5" Text="{Binding Stato}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" FontSize="11"
Foreground="{DynamicResource BrOnSurfaceVariant}" Margin="4,0"/>
<TextBlock Grid.Column="6" Text="{Binding PredizioneAdvice}" VerticalAlignment="Center" TextTrimming="CharacterEllipsis" FontSize="11"
Foreground="{DynamicResource BrSecondary}" Margin="4,0"/>
<TextBlock Grid.Column="7" Text="{Binding PredPercCasa}" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="11"/>
<TextBlock Grid.Column="8" Text="{Binding PredPercPari}" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="11"/>
<TextBlock Grid.Column="9" Text="{Binding PredPercOspite}" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="11"/>
<TextBlock Grid.Column="10" Text="{Binding Over25}" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="11"/>
<TextBlock Grid.Column="11" Text="{Binding GoalGoal}" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="11"/>
<TextBlock Grid.Column="12" Text="{Binding ClassificaCasa}" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="11"/>
<TextBlock Grid.Column="13" Text="{Binding ClassificaOspite}" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="11"/>
</Grid>
</Grid>
</Border>
</HierarchicalDataTemplate>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- ── Column header bar ────────────────────────────────────────── -->
<Border Grid.Row="0"
Background="{DynamicResource BrSurfaceContainerLow}"
BorderBrush="{DynamicResource BrOutlineVariant}"
BorderThickness="0,0,0,1"
Padding="52,6,16,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="58"/>
<ColumnDefinition Width="130"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="90"/>
<ColumnDefinition Width="46"/>
<ColumnDefinition Width="46"/>
<ColumnDefinition Width="46"/>
<ColumnDefinition Width="60"/>
<ColumnDefinition Width="60"/>
<ColumnDefinition Width="50"/>
<ColumnDefinition Width="50"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Ora" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}"/>
<TextBlock Grid.Column="1" Text="Lega" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" Margin="4,0"/>
<TextBlock Grid.Column="2" Text="Casa" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" HorizontalAlignment="Right" Margin="0,0,8,0"/>
<TextBlock Grid.Column="3" Text="Risl." FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="4" Text="Ospite" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" Margin="8,0,0,0"/>
<TextBlock Grid.Column="5" Text="Stato" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" Margin="4,0"/>
<TextBlock Grid.Column="6" Text="Consiglio" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" Margin="4,0"/>
<TextBlock Grid.Column="7" Text="1" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="8" Text="X" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="9" Text="2" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="10" Text="O2.5" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="11" Text="G/G" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="12" Text="#C" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" HorizontalAlignment="Center"/>
<TextBlock Grid.Column="13" Text="#O" FontWeight="SemiBold" FontSize="12" Foreground="{DynamicResource BrOnSurfaceVariant}" HorizontalAlignment="Center"/>
</Grid>
</Border>
<!-- ── Tree view ────────────────────────────────────────────────── -->
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
<TreeView x:Name="tvMatches"
ItemTemplate="{StaticResource FixtureNodeTemplate}"
Padding="8,4,8,8"/>
</ScrollViewer>
</Grid>
</UserControl>
@@ -0,0 +1,181 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
namespace HorseRacingPredictor.Football
{
public partial class FootballGridView : UserControl
{
public FootballGridView()
{
InitializeComponent();
}
// ── Public API ───────────────────────────────────────────────────────
public ObservableCollection<MatchTreeNode> Matches { get; }
= new ObservableCollection<MatchTreeNode>();
public void Load(IEnumerable<FootballMatchViewModel> items)
{
Matches.Clear();
if (items != null)
foreach (var m in items)
Matches.Add(new MatchTreeNode(m));
tvMatches.ItemsSource = Matches;
}
public void Clear()
{
Matches.Clear();
tvMatches.ItemsSource = null;
}
public int MatchCount => Matches.Count;
// ── Expand toggle handler ────────────────────────────────────────────
private void BtnExpand_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button btn) return;
// Handle root match node expand
if (btn.DataContext is MatchTreeNode node)
{
node.IsExpanded = !node.IsExpanded;
if (tvMatches.ItemContainerGenerator.ContainerFromItem(node) is TreeViewItem tvi)
tvi.IsExpanded = node.IsExpanded;
}
}
private void BtnCategoryExpand_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button btn) return;
if (btn.DataContext is not DetailCategoryNode cat) return;
cat.IsExpanded = !cat.IsExpanded;
}
}
/// <summary>
/// Root tree node: wraps a fixture ViewModel and exposes per-category expandable child nodes.
/// </summary>
public class MatchTreeNode : FootballMatchViewModel
{
public ObservableCollection<DetailCategoryNode> Children { get; } = new();
public bool HasChildren => Children.Count > 0;
public MatchTreeNode() { }
public MatchTreeNode(FootballMatchViewModel vm)
{
FixtureId = vm.FixtureId;
LeagueId = vm.LeagueId;
HomeTeamId = vm.HomeTeamId;
AwayTeamId = vm.AwayTeamId;
DataOra = vm.DataOra;
Lega = vm.Lega;
Nazione = vm.Nazione;
Stagione = vm.Stagione;
SquadraCasa = vm.SquadraCasa;
SquadraOspite = vm.SquadraOspite;
Risultato = vm.Risultato;
Stato = vm.Stato;
Arbitro = vm.Arbitro;
Stadio = vm.Stadio;
Città = vm.Città;
PredizioneFinaleBase= vm.PredizioneFinaleBase;
PredizioneAdvice = vm.PredizioneAdvice;
PredPercCasa = vm.PredPercCasa;
PredPercPari = vm.PredPercPari;
PredPercOspite = vm.PredPercOspite;
PredGoalsHome = vm.PredGoalsHome;
PredGoalsAway = vm.PredGoalsAway;
FormCasa = vm.FormCasa;
FormOspite = vm.FormOspite;
AttaccoCasa = vm.AttaccoCasa;
DifesaCasa = vm.DifesaCasa;
AttaccoOspite = vm.AttaccoOspite;
DifesaOspite = vm.DifesaOspite;
PoissonHome = vm.PoissonHome;
PoissonAway = vm.PoissonAway;
PoissonDraw = vm.PoissonDraw;
Under25 = vm.Under25;
Over25 = vm.Over25;
GoalGoal = vm.GoalGoal;
NoGoal = vm.NoGoal;
ClassificaCasa = vm.ClassificaCasa;
PuntiCasa = vm.PuntiCasa;
VittorieCasa = vm.VittorieCasa;
PareggiCasa = vm.PareggiCasa;
SconfitteCasa = vm.SconfitteCasa;
GoalFattiCasa = vm.GoalFattiCasa;
GoalSubitiCasa = vm.GoalSubitiCasa;
ClassificaOspite = vm.ClassificaOspite;
PuntiOspite = vm.PuntiOspite;
VittorieOspite = vm.VittorieOspite;
PareggiOspite = vm.PareggiOspite;
SconfitteOspite = vm.SconfitteOspite;
GoalFattiOspite = vm.GoalFattiOspite;
GoalSubitiOspite = vm.GoalSubitiOspite;
// Build per-category child nodes
if (vm.Events.Count > 0)
Children.Add(new DetailCategoryNode("⚽ Eventi", vm.Events.Count) { Events = new(vm.Events) });
if (vm.Lineups.Count > 0)
Children.Add(new DetailCategoryNode("👥 Formazioni", vm.Lineups.Count) { Lineups = new(vm.Lineups) });
if (vm.H2H.Count > 0)
Children.Add(new DetailCategoryNode("🔄 Scontri H2H", vm.H2H.Count) { H2H = new(vm.H2H) });
if (vm.Statistics.Count > 0)
Children.Add(new DetailCategoryNode("📊 Statistiche", vm.Statistics.Count) { Statistics = new(vm.Statistics) });
if (vm.Injuries.Count > 0)
Children.Add(new DetailCategoryNode("🩹 Infortuni", vm.Injuries.Count) { Injuries = new(vm.Injuries) });
if (vm.Odds.Count > 0)
Children.Add(new DetailCategoryNode("💰 Quote", vm.Odds.Count) { Odds = new(vm.Odds) });
}
}
/// <summary>
/// A single expandable category child node shown beneath a match.
/// Only one collection is populated (the one matching the category).
/// </summary>
public class DetailCategoryNode : INotifyPropertyChanged
{
public string Label { get; }
public int Count { get; }
public string Summary => $"{Label} ({Count})";
private bool _isExpanded;
public bool IsExpanded
{
get => _isExpanded;
set { _isExpanded = value; OnPropertyChanged(); OnPropertyChanged(nameof(ExpandIcon)); }
}
public string ExpandIcon => IsExpanded ? "▼" : "▶";
// Only one of these will be non-null per instance
public ObservableCollection<MatchEventRow> Events { get; set; }
public ObservableCollection<LineupRow> Lineups { get; set; }
public ObservableCollection<H2HRow> H2H { get; set; }
public ObservableCollection<StatisticRow> Statistics { get; set; }
public ObservableCollection<InjuryRow> Injuries { get; set; }
public ObservableCollection<OddsRow> Odds { get; set; }
public DetailCategoryNode(string label, int count)
{
Label = label;
Count = count;
}
public event PropertyChangedEventHandler PropertyChanged;
private void OnPropertyChanged([CallerMemberName] string n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
}
@@ -0,0 +1,161 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace HorseRacingPredictor.Football
{
/// <summary>
/// ViewModel per una singola partita. Contiene tutti i dati 1:1 della fixture
/// e collezioni 1:N (eventi, formazioni, H2H, statistiche, infortuni, quote).
/// Implementa INotifyPropertyChanged per binding WPF.
/// </summary>
public class FootballMatchViewModel : INotifyPropertyChanged
{
// ── Core fixture ─────────────────────────────────────────────────────
public string FixtureId { get; set; }
public string LeagueId { get; set; }
public string HomeTeamId { get; set; }
public string AwayTeamId { get; set; }
public string DataOra { get; set; }
public string Lega { get; set; }
public string Nazione { get; set; }
public string Stagione { get; set; }
public string SquadraCasa { get; set; }
public string SquadraOspite { get; set; }
public string Risultato { get; set; } // "2 - 1"
public string Stato { get; set; } // Scheduled / FT / Live...
public string Arbitro { get; set; }
public string Stadio { get; set; }
public string Città { get; set; }
// ── Prediction ───────────────────────────────────────────────────────
public string PredizioneFinaleBase { get; set; }
public string PredizioneAdvice { get; set; }
public string PredPercCasa { get; set; }
public string PredPercPari { get; set; }
public string PredPercOspite { get; set; }
public string PredGoalsHome { get; set; }
public string PredGoalsAway { get; set; }
public string FormCasa { get; set; }
public string FormOspite { get; set; }
public string AttaccoCasa { get; set; }
public string DifesaCasa { get; set; }
public string AttaccoOspite { get; set; }
public string DifesaOspite { get; set; }
public string PoissonHome { get; set; }
public string PoissonAway { get; set; }
public string PoissonDraw { get; set; }
public string Under25 { get; set; }
public string Over25 { get; set; }
public string GoalGoal { get; set; }
public string NoGoal { get; set; }
// ── Standings casa ───────────────────────────────────────────────────
public string ClassificaCasa { get; set; }
public string PuntiCasa { get; set; }
public string VittorieCasa { get; set; }
public string PareggiCasa { get; set; }
public string SconfitteCasa { get; set; }
public string GoalFattiCasa { get; set; }
public string GoalSubitiCasa { get; set; }
// ── Standings ospite ─────────────────────────────────────────────────
public string ClassificaOspite { get; set; }
public string PuntiOspite { get; set; }
public string VittorieOspite { get; set; }
public string PareggiOspite { get; set; }
public string SconfitteOspite { get; set; }
public string GoalFattiOspite { get; set; }
public string GoalSubitiOspite { get; set; }
// ── Info web ─────────────────────────────────────────────────────────
public string InfoWebPartita { get; set; }
// ── Expand/collapse ──────────────────────────────────────────────────
private bool _isExpanded;
public bool IsExpanded
{
get => _isExpanded;
set { _isExpanded = value; OnPropertyChanged(); OnPropertyChanged(nameof(ExpandIcon)); }
}
public string ExpandIcon => IsExpanded ? "▼" : "▶";
/// <summary>True se almeno una collezione 1:N contiene dati.</summary>
public bool HasDetails =>
(Events?.Count > 0) ||
(Lineups?.Count > 0) ||
(H2H?.Count > 0) ||
(Statistics?.Count > 0) ||
(Injuries?.Count > 0) ||
(Odds?.Count > 0);
// ── Collezioni 1:N ───────────────────────────────────────────────────
public ObservableCollection<MatchEventRow> Events { get; set; } = new();
public ObservableCollection<LineupRow> Lineups { get; set; } = new();
public ObservableCollection<H2HRow> H2H { get; set; } = new();
public ObservableCollection<StatisticRow> Statistics { get; set; } = new();
public ObservableCollection<InjuryRow> Injuries { get; set; } = new();
public ObservableCollection<OddsRow> Odds { get; set; } = new();
public event PropertyChangedEventHandler PropertyChanged;
protected void OnPropertyChanged([CallerMemberName] string n = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n));
}
// ── Sub-row types ────────────────────────────────────────────────────────
public class MatchEventRow
{
public string Minuto { get; set; }
public string Tipo { get; set; }
public string Dettaglio{ get; set; }
public string Squadra { get; set; }
public string Giocatore{ get; set; }
public string Assistito{ get; set; }
}
public class LineupRow
{
public string Squadra { get; set; }
public string Formazione { get; set; }
public string Numero { get; set; }
public string Giocatore { get; set; }
public string Posizione { get; set; }
public string Titolare { get; set; }
}
public class H2HRow
{
public string Data { get; set; }
public string Lega { get; set; }
public string SquadraCasa { get; set; }
public string SquadraOsp { get; set; }
public string Risultato { get; set; }
public string Vincitore { get; set; }
}
public class StatisticRow
{
public string Squadra { get; set; }
public string Tipo { get; set; }
public string Valore { get; set; }
}
public class InjuryRow
{
public string Squadra { get; set; }
public string Giocatore { get; set; }
public string Tipo { get; set; }
public string Gravità { get; set; }
public string DataRientro { get; set; }
}
public class OddsRow
{
public string Bookmaker { get; set; }
public string Mercato { get; set; }
public string Esito { get; set; }
public string Quota { get; set; }
}
}
File diff suppressed because it is too large Load Diff
@@ -1,367 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using HorseRacingPredictor.Infrastructure;
namespace HorseRacingPredictor.Football.WebSearch
{
/// <summary>
/// Provider di ricerca web supportato.
/// </summary>
public enum WebSearchProvider
{
/// <summary>Bing Web Search API v7 (richiede chiave Azure Cognitive Services).</summary>
Bing,
/// <summary>Google Custom Search JSON API (richiede chiave + CX id).</summary>
Google,
/// <summary>SerpAPI aggregatore multi-motore (richiede chiave).</summary>
SerpApi,
/// <summary>SearXNG istanza self-hosted (nessuna chiave richiesta).</summary>
SearXng,
}
/// <summary>
/// Opzioni per la ricerca web per singola partita.
/// </summary>
public class WebSearchOptions
{
/// <summary>Abilita l'arricchimento tramite ricerca web.</summary>
public bool Enabled { get; set; } = false;
/// <summary>Provider da usare.</summary>
public WebSearchProvider Provider { get; set; } = WebSearchProvider.SearXng;
/// <summary>API key del provider scelto (non richiesta per SearXNG).</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Solo per Google Custom Search: identificatore del motore di ricerca (cx).
/// </summary>
public string GoogleCx { get; set; } = string.Empty;
/// <summary>
/// URL base dell'istanza SearXNG self-hosted (es. "http://192.168.30.23:8082").
/// </summary>
public string SearXNgUrl { get; set; } = "http://192.168.30.23:8082";
/// <summary>Numero massimo di risultati da includere per partita (120).</summary>
public int MaxResults { get; set; } = 10;
/// <summary>Ritardo in ms tra le ricerche per evitare rate-limit.</summary>
public int DelayMs { get; set; } = 300;
/// <summary>Lingua della ricerca (es. "it", "en").</summary>
public string Language { get; set; } = "it";
}
/// <summary>
/// Singolo risultato di una ricerca web.
/// </summary>
public class WebSearchResult
{
public string Title { get; init; } = string.Empty;
public string Snippet { get; init; } = string.Empty;
public string Url { get; init; } = string.Empty;
}
/// <summary>
/// Client per la ricerca web multi-provider.
/// Effettua query testuali e restituisce snippet pronti per essere inseriti nel CSV.
/// </summary>
public class WebSearchClient
{
private static readonly HttpClient _http = new()
{
Timeout = TimeSpan.FromSeconds(10)
};
private readonly WebSearchOptions _options;
public WebSearchClient(WebSearchOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
}
// ?? query builder ????????????????????????????????????????????????????????
/// <summary>
/// Costruisce la query di ricerca per una singola partita.
/// La query viene pensata per raccogliere il massimo contesto utile all'IA:
/// meteo, forma recente, formazioni probabili, infortuni, notizie, quote e pronostici.
/// </summary>
public static string BuildQuery(
string homeTeam, string awayTeam,
string league, string country,
DateTime date, string language = "it")
{
string dateStr = date.ToString("dd/MM/yyyy");
string matchCore = $"{homeTeam} vs {awayTeam}";
// Termini specifici per il calcio, ottimizzati per l'analisi IA.
// Per "all" si usano termini inglesi (lingua franca del web calcistico).
string[] contextTerms = language == "it"
? [
"pronostico", "formazioni", "infortuni", "squalifiche",
"forma recente", "statistiche", "meteo", "notizie",
"quote", "precedenti"
]
: [
"prediction", "lineups", "injuries", "suspensions",
"recent form", "statistics", "weather", "news",
"odds", "head to head"
];
// Combina tutto in un'unica query ottimizzata
return $"{matchCore} {league} {dateStr} {string.Join(" ", contextTerms)}";
}
// ?? public API ????????????????????????????????????????????????????????????
/// <summary>
/// Esegue la ricerca e restituisce uno snippet unico concatenato (max ~3000 caratteri),
/// pronto per essere inserito in una cella CSV.
/// </summary>
public async Task<string> SearchAsync(string query, CancellationToken ct = default)
{
// SearXNG non richiede API key
bool needsKey = _options.Provider != WebSearchProvider.SearXng;
if (needsKey && string.IsNullOrWhiteSpace(_options.ApiKey))
{
AppLogger.Warn("WebSearchClient", $"API key vuota per provider {_options.Provider} — ricerca saltata");
return string.Empty;
}
AppLogger.Debug("WebSearchClient", $"SearchAsync | provider={_options.Provider} | query='{query}'");
try
{
List<WebSearchResult> results = _options.Provider switch
{
WebSearchProvider.Bing => await SearchBingAsync(query, ct),
WebSearchProvider.Google => await SearchGoogleAsync(query, ct),
WebSearchProvider.SerpApi => await SearchSerpApiAsync(query, ct),
WebSearchProvider.SearXng => await SearchSearXNgAsync(query, ct),
_ => []
};
AppLogger.Debug("WebSearchClient", $"Risultati raw: {results.Count}");
if (results.Count == 0)
{
AppLogger.Warn("WebSearchClient", $"Nessun risultato per: '{query}'");
return string.Empty;
}
// Concatena titolo + snippet per ogni risultato, separati da " || "
var sb = new StringBuilder();
foreach (var r in results.Take(_options.MaxResults))
{
if (sb.Length > 0) sb.Append(" || ");
sb.Append($"[{r.Title}] {r.Snippet}");
if (sb.Length > 3000) break;
}
AppLogger.Debug("WebSearchClient", $"Snippet finale: {sb.Length} caratteri");
return sb.ToString();
}
catch (Exception ex)
{
AppLogger.Error("WebSearchClient", $"Eccezione | provider={_options.Provider} | query='{query}'", ex);
return $"[Errore ricerca: {ex.Message[..Math.Min(80, ex.Message.Length)]}]";
}
finally
{
if (_options.DelayMs > 0)
await Task.Delay(_options.DelayMs, ct);
}
}
// ?? Bing ?????????????????????????????????????????????????????????????????
private async Task<List<WebSearchResult>> SearchBingAsync(string query, CancellationToken ct)
{
int count = Math.Clamp(_options.MaxResults, 1, 10);
string mkt = _options.Language == "it" ? "it-IT" : "en-US";
string url = $"https://api.bing.microsoft.com/v7.0/search" +
$"?q={Uri.EscapeDataString(query)}&count={count}&mkt={mkt}";
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Add("Ocp-Apim-Subscription-Key", _options.ApiKey);
using var resp = await _http.SendAsync(req, ct);
resp.EnsureSuccessStatusCode();
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)).RootElement;
var results = new List<WebSearchResult>();
if (json.TryGetProperty("webPages", out var pages) &&
pages.TryGetProperty("value", out var values))
{
foreach (var item in values.EnumerateArray())
{
results.Add(new WebSearchResult
{
Title = item.TryGetProperty("name", out var t) ? t.GetString() ?? "" : "",
Snippet = item.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : "",
Url = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : "",
});
}
}
return results;
}
// ?? Google Custom Search ??????????????????????????????????????????????????
private async Task<List<WebSearchResult>> SearchGoogleAsync(string query, CancellationToken ct)
{
int count = Math.Clamp(_options.MaxResults, 1, 10);
string url = $"https://www.googleapis.com/customsearch/v1" +
$"?key={Uri.EscapeDataString(_options.ApiKey)}" +
$"&cx={Uri.EscapeDataString(_options.GoogleCx)}" +
$"&q={Uri.EscapeDataString(query)}&num={count}" +
$"&lr=lang_{_options.Language}";
using var resp = await _http.GetAsync(url, ct);
resp.EnsureSuccessStatusCode();
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)).RootElement;
var results = new List<WebSearchResult>();
if (json.TryGetProperty("items", out var items))
{
foreach (var item in items.EnumerateArray())
{
results.Add(new WebSearchResult
{
Title = item.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "",
Snippet = item.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : "",
Url = item.TryGetProperty("link", out var u) ? u.GetString() ?? "" : "",
});
}
}
return results;
}
// ?? SerpAPI ???????????????????????????????????????????????????????????????
private async Task<List<WebSearchResult>> SearchSerpApiAsync(string query, CancellationToken ct)
{
int count = Math.Clamp(_options.MaxResults, 1, 10);
string url = $"https://serpapi.com/search.json" +
$"?q={Uri.EscapeDataString(query)}&num={count}&hl={_options.Language}" +
$"&api_key={Uri.EscapeDataString(_options.ApiKey)}";
using var resp = await _http.GetAsync(url, ct);
resp.EnsureSuccessStatusCode();
var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)).RootElement;
var results = new List<WebSearchResult>();
if (json.TryGetProperty("organic_results", out var items))
{
foreach (var item in items.EnumerateArray())
{
results.Add(new WebSearchResult
{
Title = item.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "",
Snippet = item.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : "",
Url = item.TryGetProperty("link", out var u) ? u.GetString() ?? "" : "",
});
}
}
return results;
}
// ?? SearXNG ???????????????????????????????????????????????????????????????
/// <summary>
/// Ricerca su istanza SearXNG self-hosted via API JSON.
/// Usa le categorie "general" e "news" per massimizzare la copertura informativa.
/// Le query vengono eseguite in parallelo per le due categorie per ottenere
/// risultati diversificati (notizie recenti + contenuti generali).
/// </summary>
private async Task<List<WebSearchResult>> SearchSearXNgAsync(string query, CancellationToken ct)
{
string baseUrl = _options.SearXNgUrl.TrimEnd('/');
string lang = _options.Language;
string encoded = Uri.EscapeDataString(query);
// Lancia le due categorie in parallelo per velocizzare e diversificare i risultati
var generalTask = FetchSearXNgCategoryAsync(baseUrl, encoded, "general", lang, ct);
var newsTask = FetchSearXNgCategoryAsync(baseUrl, encoded, "news", lang, ct);
await Task.WhenAll(generalTask, newsTask);
// Unisci e deduplicata per URL, privilegiando "news" (più recente) come primo
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var merged = new List<WebSearchResult>();
foreach (var r in newsTask.Result.Concat(generalTask.Result))
{
if (seen.Add(r.Url))
merged.Add(r);
}
return merged;
}
private async Task<List<WebSearchResult>> FetchSearXNgCategoryAsync(
string baseUrl, string encodedQuery, string category, string lang, CancellationToken ct)
{
// SearXNG JSON API: /search?q=...&format=json&categories=...
// Il parametro language viene omesso quando si vuole cercare in tutte le lingue
string url = $"{baseUrl}/search" +
$"?q={encodedQuery}" +
$"&format=json" +
$"&categories={Uri.EscapeDataString(category)}" +
(lang == "all" ? "" : $"&language={Uri.EscapeDataString(lang)}");
AppLogger.Debug("WebSearchClient", $"SearXNG [{category}] GET: {url}");
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.TryAddWithoutValidation("User-Agent", "BettingPredictor/1.0 (AI enrichment)");
req.Headers.TryAddWithoutValidation("Accept", "application/json");
using var resp = await _http.SendAsync(req, ct);
AppLogger.Debug("WebSearchClient", $"SearXNG [{category}] risposta HTTP: {(int)resp.StatusCode} {resp.StatusCode}");
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync(ct);
AppLogger.Debug("WebSearchClient", $"SearXNG [{category}] body ricevuto: {body.Length} caratteri");
var json = JsonDocument.Parse(body).RootElement;
var results = new List<WebSearchResult>();
if (!json.TryGetProperty("results", out var items))
{
AppLogger.Warn("WebSearchClient", $"SearXNG [{category}]: proprietà 'results' assente nella risposta JSON");
return results;
}
foreach (var item in items.EnumerateArray())
{
string title = item.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
string snippet = item.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
string itemUrl = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : "";
if (item.TryGetProperty("publishedDate", out var pd) && pd.ValueKind == JsonValueKind.String)
{
string dateStr = pd.GetString();
if (!string.IsNullOrEmpty(dateStr))
snippet = $"[{dateStr[..Math.Min(10, dateStr.Length)]}] {snippet}";
}
if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(snippet))
results.Add(new WebSearchResult { Title = title, Snippet = snippet, Url = itemUrl });
}
AppLogger.Debug("WebSearchClient", $"SearXNG [{category}]: {results.Count} risultati parsati");
return results;
}
}
}
@@ -59,33 +59,5 @@ namespace HorseRacingPredictor
{
_footballApiKeyOverride = apiKey?.Trim();
}
// ?? WebSearch settings ?????????????????????????????????
public static string WebSearchProvider =>
Configuration["WebSearch:Provider"] ?? "Bing";
public static string WebSearchApiKey =>
!string.IsNullOrEmpty(_webSearchApiKeyOverride)
? _webSearchApiKeyOverride
: Configuration["WebSearch:ApiKey"] ?? string.Empty;
public static string WebSearchGoogleCx =>
Configuration["WebSearch:GoogleCx"] ?? string.Empty;
public static int WebSearchMaxResults =>
int.TryParse(Configuration["WebSearch:MaxResults"], out var v) ? v : 5;
public static int WebSearchDelayMs =>
int.TryParse(Configuration["WebSearch:DelayMs"], out var v) ? v : 500;
public static string WebSearchLanguage =>
Configuration["WebSearch:Language"] ?? "it";
private static string _webSearchApiKeyOverride;
public static void SetWebSearchApiKey(string key)
{
_webSearchApiKeyOverride = key?.Trim();
}
}
}
@@ -155,6 +155,85 @@ namespace HorseRacingPredictor.HorseRacing.Scraping
return dt;
}
/// <summary>
/// Scarica i dati delle corse restituendo un DataTable separato per ogni meeting.
/// Il dizionario associa il nome del meeting (Track_Country) al relativo DataTable.
/// </summary>
public async Task<Dictionary<string, DataTable>> ScrapeByMeetingAsync(DateTime date,
IProgress<int> progress = null,
IProgress<string> status = null,
CancellationToken ct = default)
{
var result = new Dictionary<string, DataTable>();
try
{
status?.Report("Punters: Accesso al palinsesto...");
progress?.Report(2);
var meetings = await DiscoverMeetingsAsync(date, ct);
if (meetings.Count == 0)
{
status?.Report("Punters: Nessun meeting trovato per le nazioni selezionate");
progress?.Report(100);
return result;
}
int totalRaces = meetings.Sum(m => m.Races.Count);
status?.Report($"Punters: {meetings.Count} meeting, {totalRaces} corse trovate");
progress?.Report(10);
int completed = 0;
foreach (var meeting in meetings)
{
var dt = CreateTable();
string key = $"{meeting.Track}_{meeting.Country}";
foreach (var race in meeting.Races)
{
ct.ThrowIfCancellationRequested();
status?.Report($"Punters: {meeting.Track} R{race.Number}/{meeting.Races.Count} " +
$"({completed + 1}/{totalRaces})");
try
{
await RandomDelayAsync(ct);
await FetchRaceRunners(dt, race.Url, meeting, race, ct);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(
$"Errore Punters {meeting.Track} R{race.Number}: {ex.Message}");
}
completed++;
int pct = 10 + (int)((double)completed / Math.Max(totalRaces, 1) * 88);
progress?.Report(Math.Min(pct, 98));
}
if (dt.Rows.Count > 0)
result[key] = dt;
}
progress?.Report(100);
status?.Report($"Punters: {result.Count} meeting scaricati separatamente");
}
catch (OperationCanceledException)
{
status?.Report("Scraping annullato");
}
catch (Exception ex)
{
status?.Report($"Errore Punters: {ex.Message}");
}
return result;
}
#region Discovery
/// <summary>
@@ -0,0 +1,49 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- MD3 Dark Theme - Color Tokens -->
<SolidColorBrush x:Key="BrPrimary" Color="#D0BCFF"/>
<SolidColorBrush x:Key="BrOnPrimary" Color="#381E72"/>
<SolidColorBrush x:Key="BrPrimaryContainer" Color="#4F378B"/>
<SolidColorBrush x:Key="BrOnPrimaryContainer" Color="#EADDFF"/>
<SolidColorBrush x:Key="BrSecondary" Color="#CCC2DC"/>
<SolidColorBrush x:Key="BrOnSecondary" Color="#332D41"/>
<SolidColorBrush x:Key="BrSecondaryContainer" Color="#4A4458"/>
<SolidColorBrush x:Key="BrOnSecondaryContainer" Color="#E8DEF8"/>
<SolidColorBrush x:Key="BrTertiary" Color="#EFB8C8"/>
<SolidColorBrush x:Key="BrTertiaryContainer" Color="#633B48"/>
<SolidColorBrush x:Key="BrOnTertiaryContainer" Color="#FFD8E4"/>
<SolidColorBrush x:Key="BrError" Color="#F2B8B5"/>
<SolidColorBrush x:Key="BrSurface" Color="#141218"/>
<SolidColorBrush x:Key="BrOnSurface" Color="#E6E0E9"/>
<SolidColorBrush x:Key="BrSurfaceVariant" Color="#49454F"/>
<SolidColorBrush x:Key="BrOnSurfaceVariant" Color="#CAC4D0"/>
<SolidColorBrush x:Key="BrSurfaceContainer" Color="#211F26"/>
<SolidColorBrush x:Key="BrSurfaceContainerHigh" Color="#2B2930"/>
<SolidColorBrush x:Key="BrSurfaceContainerHighest" Color="#36343B"/>
<SolidColorBrush x:Key="BrSurfaceContainerLow" Color="#1D1B20"/>
<SolidColorBrush x:Key="BrBackground" Color="#141218"/>
<SolidColorBrush x:Key="BrOutline" Color="#938F99"/>
<SolidColorBrush x:Key="BrOutlineVariant" Color="#49454F"/>
<!-- Legacy aliases -->
<SolidColorBrush x:Key="BrBase" Color="#141218"/>
<SolidColorBrush x:Key="BrMantle" Color="#211F26"/>
<SolidColorBrush x:Key="BrSurface0" Color="#1D1B20"/>
<SolidColorBrush x:Key="BrSurface1" Color="#211F26"/>
<SolidColorBrush x:Key="BrSurface2" Color="#2B2930"/>
<SolidColorBrush x:Key="BrOverlay0" Color="#CAC4D0"/>
<SolidColorBrush x:Key="BrText" Color="#E6E0E9"/>
<SolidColorBrush x:Key="BrSubtext0" Color="#CAC4D0"/>
<SolidColorBrush x:Key="BrBlue" Color="#D0BCFF"/>
<SolidColorBrush x:Key="BrGreen" Color="#A8D5A2"/>
<SolidColorBrush x:Key="BrRed" Color="#F2B8B5"/>
<SolidColorBrush x:Key="BrPeach" Color="#CCC2DC"/>
<SolidColorBrush x:Key="BrLavender" Color="#4F378B"/>
<SolidColorBrush x:Key="BrBorder" Color="#49454F"/>
<!-- Row hover tint (semi-transparent primary) -->
<SolidColorBrush x:Key="BrRowHover" Color="#1AD0BCFF"/>
</ResourceDictionary>
@@ -0,0 +1,69 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ================================================================
MD3 Dark Theme Catppuccin Mocha-inspired
Background: #1E1E2E Surface: #181825 Primary: #CBA6F7
================================================================ -->
<SolidColorBrush x:Key="BrPrimary" Color="#A67FF5"/>
<SolidColorBrush x:Key="BrOnPrimary" Color="#1C1030"/>
<SolidColorBrush x:Key="BrPrimaryContainer" Color="#4A3580"/>
<SolidColorBrush x:Key="BrOnPrimaryContainer" Color="#E8D8FF"/>
<SolidColorBrush x:Key="BrSecondary" Color="#A6ADC8"/>
<SolidColorBrush x:Key="BrOnSecondary" Color="#1E1E2E"/>
<SolidColorBrush x:Key="BrSecondaryContainer" Color="#363651"/>
<SolidColorBrush x:Key="BrOnSecondaryContainer" Color="#CDD6F4"/>
<SolidColorBrush x:Key="BrTertiary" Color="#74C7EC"/>
<SolidColorBrush x:Key="BrTertiaryContainer" Color="#1A3D50"/>
<SolidColorBrush x:Key="BrOnTertiaryContainer" Color="#C8EEFF"/>
<SolidColorBrush x:Key="BrError" Color="#F28B82"/>
<SolidColorBrush x:Key="BrErrorContainer" Color="#601410"/>
<SolidColorBrush x:Key="BrOnError" Color="#330F0C"/>
<!-- Surfaces -->
<SolidColorBrush x:Key="BrBackground" Color="#1E1E2E"/>
<SolidColorBrush x:Key="BrSurface" Color="#181825"/>
<SolidColorBrush x:Key="BrSurfaceVariant" Color="#313244"/>
<SolidColorBrush x:Key="BrSurfaceContainer" Color="#26263C"/>
<SolidColorBrush x:Key="BrSurfaceContainerHigh" Color="#2E2E44"/>
<SolidColorBrush x:Key="BrSurfaceContainerHighest" Color="#363650"/>
<SolidColorBrush x:Key="BrSurfaceContainerLow" Color="#1F1F31"/>
<!-- On-surfaces -->
<SolidColorBrush x:Key="BrOnSurface" Color="#CDD6F4"/>
<SolidColorBrush x:Key="BrOnSurfaceVariant" Color="#7F849C"/>
<!-- Outline -->
<SolidColorBrush x:Key="BrOutline" Color="#45475A"/>
<SolidColorBrush x:Key="BrOutlineVariant" Color="#313244"/>
<!-- Semantic colors (status badges) -->
<SolidColorBrush x:Key="BrSuccess" Color="#A6E3A1"/>
<SolidColorBrush x:Key="BrWarn" Color="#FAB387"/>
<SolidColorBrush x:Key="BrInfo" Color="#89B4FA"/>
<SolidColorBrush x:Key="BrLogBg" Color="#14141E"/>
<!-- Legacy aliases (keep names stable) -->
<SolidColorBrush x:Key="BrBase" Color="#1E1E2E"/>
<SolidColorBrush x:Key="BrMantle" Color="#181825"/>
<SolidColorBrush x:Key="BrSurface0" Color="#313244"/>
<SolidColorBrush x:Key="BrSurface1" Color="#45475A"/>
<SolidColorBrush x:Key="BrSurface2" Color="#585B70"/>
<SolidColorBrush x:Key="BrOverlay0" Color="#6C7086"/>
<SolidColorBrush x:Key="BrText" Color="#CDD6F4"/>
<SolidColorBrush x:Key="BrSubtext0" Color="#A6ADC8"/>
<SolidColorBrush x:Key="BrBlue" Color="#89B4FA"/>
<SolidColorBrush x:Key="BrGreen" Color="#A6E3A1"/>
<SolidColorBrush x:Key="BrRed" Color="#F38BA8"/>
<SolidColorBrush x:Key="BrPeach" Color="#FAB387"/>
<SolidColorBrush x:Key="BrLavender" Color="#B4BEFE"/>
<SolidColorBrush x:Key="BrBorder" Color="#45475A"/>
<!-- Row hover tint -->
<SolidColorBrush x:Key="BrRowHover" Color="#2A2A40"/>
</ResourceDictionary>
@@ -42,14 +42,13 @@ namespace HorseRacingPredictor
public bool FbDownloadStatistics { get; set; } = false;
public bool FbDownloadInjuries { get; set; } = false;
public List<int> FbLeagueIds { get; set; } = new();
public int FbBookmakerId { get; set; } = 8;
public int FbOddsMaxPages { get; set; } = 3;
public string FbTimezone { get; set; } = "Europe/Rome";
public int FbSeason { get; set; } = 0;
public int FbMaxFixturesForDetails { get; set; } = 50;
public int FbApiDelayMs { get; set; } = 300;
public bool FbCheckQuota { get; set; } = true;
public int FbMinRemainingQuota { get; set; } = 10;
public string FbTimeFrom { get; set; } = null;
public string FbTimeTo { get; set; } = null;
// ?? Football Supplementary Downloads (CSV separati) ??????
public bool FbDownloadPlayerStats { get; set; } = false;
@@ -61,17 +60,7 @@ namespace HorseRacingPredictor
public bool FbDownloadCoaches { get; set; } = false;
public bool FbDownloadTransfers { get; set; } = false;
// ?? Web Search (arricchimento IA) ???????????????????????????
public bool FbWebSearchEnabled { get; set; } = false;
public string FbWebSearchProvider { get; set; } = "SearXng";
public string FbWebSearchApiKey { get; set; } = string.Empty;
public string FbWebSearchGoogleCx { get; set; } = string.Empty;
public string FbWebSearchSearXNgUrl { get; set; } = "http://192.168.30.23:8082";
public int FbWebSearchMaxResults { get; set; } = 10;
public int FbWebSearchDelayMs { get; set; } = 300;
public string FbWebSearchLanguage { get; set; } = "it";
// ?? Racing ???????????????????????????????????????????????
// ?? Racing
public string RacingApiKey { get; set; } = string.Empty;
public string RcDataSource { get; set; } = "API - FormFav";
public string RcExportPath { get; set; } = string.Empty;
@@ -82,6 +71,10 @@ namespace HorseRacingPredictor
public string RcFormat { get; set; } = "CSV";
public string RcTimezone { get; set; } = "Australia/Sydney";
public List<string> RcCountries { get; set; } = new() { "au", "nz" };
public bool RcPuntersSplitCsv { get; set; } = false;
// ?? Aspetto ??????????????????????????????????????????
public bool DarkMode { get; set; } = false;
// ?? Persistence ??????????????????????????????????????
@@ -102,14 +95,13 @@ namespace HorseRacingPredictor
DownloadStatistics = FbDownloadStatistics,
DownloadInjuries = FbDownloadInjuries,
LeagueIds = new List<int>(FbLeagueIds),
BookmakerId = FbBookmakerId,
OddsMaxPages = FbOddsMaxPages,
Timezone = FbTimezone,
Season = FbSeason,
MaxFixturesForDetails = FbMaxFixturesForDetails,
ApiDelayMs = FbApiDelayMs,
CheckQuota = FbCheckQuota,
MinRemainingQuota = FbMinRemainingQuota,
TimeFrom = TimeSpan.TryParse(FbTimeFrom, out var tf) ? tf : (TimeSpan?)null,
TimeTo = TimeSpan.TryParse(FbTimeTo, out var tt) ? tt : (TimeSpan?)null,
// Supplementari
DownloadPlayerStats = FbDownloadPlayerStats,
DownloadTeamStats = FbDownloadTeamStats,
@@ -119,19 +111,6 @@ namespace HorseRacingPredictor
DownloadSquads = FbDownloadSquads,
DownloadCoaches = FbDownloadCoaches,
DownloadTransfers = FbDownloadTransfers,
// Web Search
WebSearch = new Football.WebSearch.WebSearchOptions
{
Enabled = FbWebSearchEnabled,
Provider = Enum.TryParse<Football.WebSearch.WebSearchProvider>(FbWebSearchProvider, out var p)
? p : Football.WebSearch.WebSearchProvider.SearXng,
ApiKey = FbWebSearchApiKey,
GoogleCx = FbWebSearchGoogleCx,
SearXNgUrl = FbWebSearchSearXNgUrl,
MaxResults = FbWebSearchMaxResults,
DelayMs = FbWebSearchDelayMs,
Language = FbWebSearchLanguage,
}
};
}
@@ -1,4 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
@@ -16,6 +18,27 @@ namespace HorseRacingPredictor.Infrastructure
Error,
}
/// <summary>
/// A single structured log entry exposed for in-memory/UI consumers.
/// </summary>
public sealed class LogEntry
{
public DateTime Timestamp { get; init; }
public LogLevel Level { get; init; }
public string Category { get; init; }
public string Message { get; init; }
public Exception Exception { get; init; }
public string LevelTag => Level switch
{
LogLevel.Debug => "DBG",
LogLevel.Info => "INF",
LogLevel.Warning => "WRN",
LogLevel.Error => "ERR",
_ => "???"
};
}
/// <summary>
/// Thread-safe, file-based application logger.
/// Writes to %AppData%\HorseRacingPredictor\logs\app-YYYY-MM-DD.log.
@@ -49,6 +72,24 @@ namespace HorseRacingPredictor.Infrastructure
private string _currentLogPath;
private DateTime _currentDate;
// ?? In-memory buffer + live event ????????????????????????????????????????
private const int MaxBufferSize = 2000;
private readonly ConcurrentQueue<LogEntry> _buffer = new();
private int _bufferCount = 0;
/// <summary>
/// Fired on the thread-pool whenever a new entry is logged.
/// UI subscribers must marshal to the UI thread themselves.
/// </summary>
public event EventHandler<LogEntry> EntryLogged;
/// <summary>Returns a snapshot of all buffered entries (newest last).</summary>
public IReadOnlyList<LogEntry> GetBufferedEntries()
{
return new List<LogEntry>(_buffer);
}
// ?? Constructor ??????????????????????????????????????????????????????????
private AppLogger()
@@ -81,6 +122,28 @@ namespace HorseRacingPredictor.Infrastructure
public static void Error(string category, string message, Exception ex = null)
=> Instance.Log(LogLevel.Error, category, message, ex);
/// <summary>
/// Log an outgoing API HTTP request.
/// </summary>
public static void LogApiRequest(string endpoint, string parameters = null)
{
var msg = string.IsNullOrWhiteSpace(parameters)
? $"→ REQUEST {endpoint}"
: $"→ REQUEST {endpoint} [{parameters}]";
Instance.Log(LogLevel.Info, "API", msg);
}
/// <summary>
/// Log an incoming API HTTP response.
/// </summary>
public static void LogApiResponse(string endpoint, int statusCode, string content, int maxLen = 2000)
{
var body = content ?? "";
var truncated = body.Length > maxLen ? body.Substring(0, maxLen) + $"…(+{body.Length - maxLen} chars)" : body;
var msg = $"← RESPONSE {endpoint} [{statusCode}] {truncated}";
Instance.Log(LogLevel.Info, "API", msg);
}
// ?? Core write ????????????????????????????????????????????????????????????
public void Log(LogLevel level, string category, string message, Exception ex = null)
@@ -102,6 +165,20 @@ namespace HorseRacingPredictor.Infrastructure
}
WriteToFile(line);
// In-memory buffer for UI (capped at MaxBufferSize)
var entry = new LogEntry { Timestamp = now, Level = level, Category = category, Message = message, Exception = ex };
_buffer.Enqueue(entry);
if (Interlocked.Increment(ref _bufferCount) > MaxBufferSize)
{
_buffer.TryDequeue(out _);
Interlocked.Decrement(ref _bufferCount);
}
// Fire event (non-blocking, thread-pool)
var handler = EntryLogged;
if (handler != null)
System.Threading.ThreadPool.QueueUserWorkItem(_ => handler(this, entry));
}
/// <summary>Returns the path of the current log file.</summary>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Data;
using System.IO;
using System.Linq;
@@ -9,6 +10,8 @@ using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using HorseRacingPredictor.Infrastructure;
namespace HorseRacingPredictor
{
@@ -26,9 +29,71 @@ namespace HorseRacingPredictor
private readonly Dictionary<string, CheckBox> _fbEndpointCheckboxes = new();
private readonly Dictionary<string, CheckBox> _fbSupplementaryCheckboxes = new();
// ?? Log tab ??????????????????????????????????????????????????
private readonly ObservableCollection<LogEntryViewModel> _logEntries = new();
private sealed class LogEntryViewModel
{
public string LevelTag { get; init; }
public string TimestampStr { get; init; }
public string Category { get; init; }
public string DisplayMessage { get; init; }
public Brush LevelColor { get; init; }
private static readonly Brush BrushOk = new SolidColorBrush(Color.FromRgb(0x38, 0x81, 0x53)); // green
private static readonly Brush BrushWarn = new SolidColorBrush(Color.FromRgb(0xD6, 0x8F, 0x00)); // amber
private static readonly Brush BrushErr = new SolidColorBrush(Color.FromRgb(0xC5, 0x33, 0x33)); // red
private static readonly Brush BrushInfo = new SolidColorBrush(Color.FromRgb(0x1E, 0x6E, 0xC8)); // blue
public static LogEntryViewModel From(LogEntry e)
{
var color = e.Level switch
{
LogLevel.Error => BrushErr,
LogLevel.Warning => BrushWarn,
LogLevel.Info => BrushInfo,
_ => BrushOk,
};
var msg = e.Exception != null
? $"{e.Message} | {e.Exception.GetType().Name}: {e.Exception.Message}"
: e.Message;
return new LogEntryViewModel
{
LevelTag = e.LevelTag,
TimestampStr = e.Timestamp.ToString("HH:mm:ss.fff"),
Category = e.Category,
DisplayMessage = msg,
LevelColor = color,
};
}
}
// ?????????????????????????????????????????????????????????????
public MainWindow()
{
InitializeComponent();
// Bind log list
logList.ItemsSource = _logEntries;
// Load existing buffered entries
foreach (var e in AppLogger.Instance.GetBufferedEntries())
_logEntries.Add(LogEntryViewModel.From(e));
// Subscribe to future entries
AppLogger.Instance.EntryLogged += (_, entry) =>
{
Dispatcher.BeginInvoke(() =>
{
_logEntries.Add(LogEntryViewModel.From(entry));
if (lblLogCount != null)
lblLogCount.Text = $"{_logEntries.Count} voci";
// Auto-scroll
if (pageLog?.Visibility == Visibility.Visible)
logScrollViewer?.ScrollToEnd();
});
};
_footballManager = new Football.Main();
_racingManager = new HorseRacing.Main(DefaultRacingApiKey);
BuildCountryCheckboxes();
@@ -37,17 +102,6 @@ namespace HorseRacingPredictor
BuildFbLeagueFilter();
PopulateTimezoneComboBoxes();
PopulateTimeFilterComboBoxes();
// Wire preview update events
txtFbPrefix.TextChanged += (s, e) => UpdateFbPreview();
txtFbSuffix.TextChanged += (s, e) => UpdateFbPreview();
chkFbIncludeDate.Checked += (s, e) => UpdateFbPreview();
chkFbIncludeDate.Unchecked += (s, e) => UpdateFbPreview();
cmbFbDateFormat.SelectionChanged += (s, e) => UpdateFbPreview();
cmbFbFormat.SelectionChanged += (s, e) => UpdateFbPreview();
dpFootball.SelectedDateChanged += (s, e) => UpdateFbPreview();
chkFbWebSearch.Checked += (s, e) => UpdateWebSearchPanelVisibility();
chkFbWebSearch.Unchecked += (s, e) => UpdateWebSearchPanelVisibility();
txtRcPrefix.TextChanged += (s, e) => UpdateRcPreview();
txtRcSuffix.TextChanged += (s, e) => UpdateRcPreview();
@@ -248,6 +302,7 @@ namespace HorseRacingPredictor
if (pageFootball != null) pageFootball.Visibility = name == "football" ? Visibility.Visible : Visibility.Collapsed;
if (pageRacing != null) pageRacing.Visibility = name == "racing" ? Visibility.Visible : Visibility.Collapsed;
if (pageSettings != null) pageSettings.Visibility = name == "settings" ? Visibility.Visible : Visibility.Collapsed;
if (pageLog != null) pageLog.Visibility = name == "log" ? Visibility.Visible : Visibility.Collapsed;
// Update title and subtitle
if (lblTitle != null)
@@ -266,6 +321,12 @@ namespace HorseRacingPredictor
lblTitle.Text = "Impostazioni";
lblSubtitle.Text = "Configurazione API, esportazione e parametri";
break;
case "log":
lblTitle.Text = "Log";
lblSubtitle.Text = "Registro eventi in tempo reale";
if (lblLogFile != null)
lblLogFile.Text = AppLogger.Instance.CurrentLogPath;
break;
}
}
}
@@ -273,6 +334,22 @@ namespace HorseRacingPredictor
private void navFootball_Checked(object sender, RoutedEventArgs e) => ShowPage("football");
private void navRacing_Checked(object sender, RoutedEventArgs e) => ShowPage("racing");
private void navSettings_Checked(object sender, RoutedEventArgs e) => ShowPage("settings");
private void navLog_Checked(object sender, RoutedEventArgs e) => ShowPage("log");
private void btnLogClear_Click(object sender, RoutedEventArgs e)
{
_logEntries.Clear();
if (lblLogCount != null) lblLogCount.Text = "0 voci";
}
private void btnLogOpenFile_Click(object sender, RoutedEventArgs e)
{
var path = AppLogger.Instance.CurrentLogPath;
if (File.Exists(path))
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
else
MessageBox.Show("File di log non trovato.", "Log", MessageBoxButton.OK, MessageBoxImage.Information);
}
// ???????????????????? FOOTBALL ????????????????????
@@ -282,60 +359,7 @@ namespace HorseRacingPredictor
await DownloadFootballAsync(date);
}
private async void btnBrowseCsvFb_Click(object sender, RoutedEventArgs e)
{
using (var dlg = new System.Windows.Forms.FolderBrowserDialog())
{
dlg.Description = "Seleziona la cartella con i file CSV calcio";
if (dlg.ShowDialog() != System.Windows.Forms.DialogResult.OK) return;
try
{
lblStatusFb.Text = "Caricamento file CSV…";
pbFootball.Value = 0;
btnExportFbCsv.IsEnabled = false;
btnBrowseCsvFb.IsEnabled = false;
var selectedPath = dlg.SelectedPath;
var progress = new Progress<int>(v => pbFootball.Value = v);
var status = new Progress<string>(s => lblStatusFb.Text = s);
var result = await Task.Run(() => LoadCsvFiles(selectedPath, progress, status));
if (result.table == null)
{
lblStatusFb.Text = result.message;
return;
}
InjectRowNumbers(result.table);
_footballData = result.table;
dgFootball.ItemsSource = _footballData?.DefaultView;
UpdateFootballStatCards();
if (_footballData.Rows.Count > 0)
{
btnExportFbCsv.IsEnabled = true;
lblStatusFb.Text = $"Caricati {_footballData.Rows.Count} righe da {result.fileCount} file CSV";
}
else
{
lblStatusFb.Text = "Nessun dato trovato nei file CSV";
}
}
catch (Exception ex)
{
MessageBox.Show($"Errore durante il caricamento CSV:\n{ex.Message}",
"Errore", MessageBoxButton.OK, MessageBoxImage.Error);
lblStatusFb.Text = "Errore nel caricamento CSV";
pbFootball.Value = 0;
}
finally
{
btnBrowseCsvFb.IsEnabled = true;
}
}
}
// btnBrowseCsvFb removed API-Football only source
private async Task DownloadFootballAsync(DateTime date)
{
@@ -356,47 +380,18 @@ namespace HorseRacingPredictor
var progress = new Progress<int>(v => pbFootball.Value = v);
var status = new Progress<string>(s => lblStatusFb.Text = s);
var table = await Task.Run(() =>
_footballManager.GetTodayFixtures(date, options, progress, status));
var matches = await _footballManager.GetTodayFixturesAsViewModels(date, options, progress, status);
_footballData = table;
// Ensure the start time column exists and populate it (no timezone label)
InjectRomeStartTimeColumn(_footballData, "Inizio");
// Nascondi le colonne interne (ID di supporto) dal DataGrid
dgFootball.ItemsSource = _footballData?.DefaultView;
dgFootball.AutoGeneratingColumn += (s, e) =>
{
if (e.PropertyName is "LeagueId" or "HomeTeamId" or "AwayTeamId" or "_timeMinutes")
e.Cancel = true;
};
_footballData = null; // no longer used for display
fbGridView.Load(matches);
// Update stat cards
UpdateFootballStatCards();
if (_footballData != null && _footballData.Rows.Count > 0)
if (matches.Count > 0)
{
btnExportFbCsv.IsEnabled = true;
lblStatusFb.Text = $"Scaricate {_footballData.Rows.Count} partite";
// Scarica dati supplementari (CSV separati) se selezionati
if (options.AnySupplementarySelected)
{
string exportFolder = !string.IsNullOrWhiteSpace(txtFbExportPath.Text)
? txtFbExportPath.Text.Trim()
: Environment.GetFolderPath(Environment.SpecialFolder.Desktop);
lblStatusFb.Text = "Scaricamento dati supplementari…";
var suppFiles = await Task.Run(() =>
_footballManager.DownloadSupplementaryData(
_footballData, date, exportFolder, options, progress, status));
if (suppFiles.Count > 0)
lblStatusFb.Text = $"Scaricate {_footballData.Rows.Count} partite + {suppFiles.Count} CSV supplementari";
else
lblStatusFb.Text = $"Scaricate {_footballData.Rows.Count} partite (nessun dato supplementare trovato)";
}
lblStatusFb.Text = $"Scaricate {matches.Count} partite";
}
else
{
@@ -419,31 +414,34 @@ namespace HorseRacingPredictor
private void btnExportFbCsv_Click(object sender, RoutedEventArgs e)
{
var format = (cmbFbFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "CSV";
var filename = BuildFilename(txtFbPrefix?.Text, chkFbIncludeDate?.IsChecked == true ? GetSelectedDateString(cmbFbDateFormat, dpFootball.SelectedDate ?? DateTime.Today) : null, txtFbSuffix?.Text, null, $"Partite_{dpFootball.SelectedDate:yyyy-MM-dd}.{format.ToLower()}");
filename = EnsureFileExtension(SanitizeFileName(filename), "." + format.ToLower());
var matches = fbGridView.Matches;
if (matches == null || matches.Count == 0) return;
switch (format.ToUpper())
{
case "CSV":
ExportToCsv(_footballData, txtFbExportPath.Text, filename, s => lblStatusFb.Text = s);
break;
case "JSON":
ExportToJson(_footballData, txtFbExportPath.Text, filename, s => lblStatusFb.Text = s);
break;
case "XML":
ExportToXml(_footballData, txtFbExportPath.Text, filename, s => lblStatusFb.Text = s);
break;
default:
ExportToCsv(_footballData, txtFbExportPath.Text, filename, s => lblStatusFb.Text = s);
break;
}
// show total rows extracted
lblStatusFb.Text = _footballData == null ? "Nessuna riga" : $"Righe estratte: {_footballData.Rows.Count}";
var folder = string.IsNullOrWhiteSpace(txtFbExportPath?.Text)
? Environment.GetFolderPath(Environment.SpecialFolder.Desktop)
: txtFbExportPath.Text.Trim();
var format = (cmbFbFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "JSON";
var fbDate = dpFootball?.SelectedDate ?? DateTime.Today;
var datePart = chkFbIncludeDate?.IsChecked == true ? GetSelectedDateString(cmbFbDateFormat, fbDate) : null;
var defaultName = $"Calcio_{fbDate:yyyy-MM-dd}.{format.ToLower()}";
var filename = BuildFilename(txtFbPrefix?.Text, datePart, txtFbSuffix?.Text, null, defaultName);
filename = SanitizeFileName(filename);
filename = EnsureFileExtension(filename, "." + format.ToLower());
if (format == "XML")
Football.FootballExporter.ExportXml(
(System.Collections.Generic.IReadOnlyList<Football.FootballMatchViewModel>)matches,
folder, filename,
s => lblStatusFb.Text = s);
else
Football.FootballExporter.ExportJson(
(System.Collections.Generic.IReadOnlyList<Football.FootballMatchViewModel>)matches,
folder, filename,
s => lblStatusFb.Text = s);
}
// ???????????????????? HORSE RACING ????????????????????
private readonly Dictionary<string, CheckBox> _countryCheckboxes = new Dictionary<string, CheckBox>();
private void BuildCountryCheckboxes()
@@ -545,18 +543,15 @@ namespace HorseRacingPredictor
private void ApplyDataSourceVisibility()
{
// Football: toggle API vs CSV controls
if (btnDownloadFb != null && btnBrowseCsvFb != null)
// Football: sorgente unica API-Football — controlli sempre visibili
if (btnDownloadFb != null)
{
bool fbIsApi = IsFbApiSource();
dpFootball.Visibility = fbIsApi ? Visibility.Visible : Visibility.Collapsed;
btnDownloadFb.Visibility = fbIsApi ? Visibility.Visible : Visibility.Collapsed;
btnBrowseCsvFb.Visibility = fbIsApi ? Visibility.Collapsed : Visibility.Visible;
dpFootball.Visibility = Visibility.Visible;
btnDownloadFb.Visibility = Visibility.Visible;
}
// Football: show/hide API-only settings (Endpoint, Supplementary, Advanced)
if (pnlFbApiOptions != null)
pnlFbApiOptions.Visibility = IsFbApiSource() ? Visibility.Visible : Visibility.Collapsed;
pnlFbApiOptions.Visibility = Visibility.Visible;
// Racing: toggle API/Scraping vs CSV controls
if (btnDownloadRc != null && btnBrowseCsvRc != null)
@@ -568,11 +563,7 @@ namespace HorseRacingPredictor
}
}
private bool IsFbApiSource()
{
var src = (cmbFbDataSource?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "";
return src.StartsWith("API", StringComparison.OrdinalIgnoreCase);
}
private bool IsFbApiSource() => true; // Sorgente unica: API-Football
private bool IsRcApiSource()
{
@@ -919,6 +910,7 @@ namespace HorseRacingPredictor
var progress = new Progress<int>(v => pbRacing.Value = v);
var status = new Progress<string>(s => lblStatusRc.Text = s);
// 1. Leggi tutti i CSV in una tabella di staging (non mostrata)
var result = await Task.Run(() => LoadCsvFiles(selectedPath, progress, status));
if (result.table == null)
@@ -927,22 +919,49 @@ namespace HorseRacingPredictor
return;
}
// Add row numbers
InjectRowNumbers(result.table);
var staging = result.table;
var importDate = result.dateHint ?? DateTime.Today;
_racingData = result.table;
dgRacing.ItemsSource = _racingData?.DefaultView;
// 2. Prepara la tabella di output con le stesse colonne (+ web search se abilitato)
var outputTable = staging.Clone(); // stessa struttura, zero righe
InjectRowNumbers(outputTable); // aggiunge colonna "No" se non c'è
// 3. Mostra la griglia vuota subito
_racingData = outputTable;
dgRacing.ItemsSource = _racingData.DefaultView;
UpdateRacingStatCards();
if (_racingData.Rows.Count > 0)
int total = staging.Rows.Count;
// 4. Riga per riga: aggiungi alla griglia
for (int i = 0; i < total; i++)
{
btnExportRcCsv.IsEnabled = true;
lblStatusRc.Text = $"Caricati {_racingData.Rows.Count} cavalli da {result.fileCount} file CSV";
}
else
var srcRow = staging.Rows[i];
// Importa la riga nella tabella di output
var destRow = outputTable.NewRow();
foreach (DataColumn col in staging.Columns)
{
lblStatusRc.Text = "Nessun cavallo trovato nei file CSV";
if (outputTable.Columns.Contains(col.ColumnName))
destRow[col.ColumnName] = srcRow[col];
}
// Numero di riga
if (outputTable.Columns.Contains("No"))
destRow["No"] = i + 1;
pbRacing.Value = (int)((double)(i + 1) / total * 100);
outputTable.Rows.Add(destRow);
// Aggiorna il contatore nella status bar ogni 10 righe per non saturare il dispatcher
if (i % 10 == 0 || i == total - 1)
UpdateRacingStatCards();
}
btnExportRcCsv.IsEnabled = _racingData.Rows.Count > 0;
lblStatusRc.Text = _racingData.Rows.Count > 0
? $"Caricati {_racingData.Rows.Count} cavalli da {result.fileCount} file CSV"
: "Nessun cavallo trovato nei file CSV";
pbRacing.Value = 100;
UpdateRacingStatCards();
}
catch (Exception ex)
{
@@ -958,7 +977,7 @@ namespace HorseRacingPredictor
}
}
private (DataTable table, int fileCount, string message) LoadCsvFiles(
private (DataTable table, int fileCount, string message, DateTime? dateHint) LoadCsvFiles(
string folderPath, IProgress<int> progress, IProgress<string> status)
{
var csvFiles = Directory.GetFiles(folderPath, "*.csv", SearchOption.AllDirectories)
@@ -966,7 +985,18 @@ namespace HorseRacingPredictor
.ToList();
if (csvFiles.Count == 0)
return (null, 0, "Nessun file CSV trovato nella cartella selezionata");
return (null, 0, "Nessun file CSV trovato nella cartella selezionata", null);
// Extract date hint from first filename matching yyyyMMdd-* pattern
DateTime? dateHint = null;
foreach (var f in csvFiles)
{
var dm = Regex.Match(Path.GetFileName(f), @"^(\d{8})-");
if (dm.Success && DateTime.TryParseExact(dm.Groups[1].Value, "yyyyMMdd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var d))
{ dateHint = d; break; }
}
var table = new DataTable();
table.Columns.Add("Meeting", typeof(string));
@@ -1027,7 +1057,7 @@ namespace HorseRacingPredictor
status?.Report($"Lettura CSV… {processed}/{csvFiles.Count}");
}
return (table, csvFiles.Count, null);
return (table, csvFiles.Count, null, dateHint);
}
/// <summary>
@@ -1137,8 +1167,55 @@ namespace HorseRacingPredictor
if (scraper.Countries.Count == 0)
scraper.Countries = new List<string> { "GB", "IE" };
bool splitCsv = chkRcPuntersSplitCsv?.IsChecked == true;
if (splitCsv)
{
// Scarica un CSV per meeting e salvali direttamente su disco
var meetings = await scraper.ScrapeByMeetingAsync(date, progress, status, ct);
// Determina la cartella di destinazione
string exportFolder = txtRcExportPath?.Text.Trim() ?? "";
if (string.IsNullOrEmpty(exportFolder) || !Directory.Exists(exportFolder))
{
exportFolder = BrowseFolder("Seleziona la cartella dove salvare i CSV di Punters");
if (string.IsNullOrEmpty(exportFolder))
{
lblStatusRc.Text = "Operazione annullata";
return;
}
}
int saved = 0;
foreach (var kvp in meetings)
{
string safeName = SanitizeFileName($"{date:yyyyMMdd}_{kvp.Key}.csv");
string filePath = Path.Combine(exportFolder, safeName);
WriteCsvFile(kvp.Value, filePath);
saved++;
}
// Merge in _racingData for display
if (meetings.Count > 0)
{
var merged = meetings.Values.First().Clone();
foreach (var dt2 in meetings.Values)
foreach (DataRow r in dt2.Rows)
merged.ImportRow(r);
table = merged;
}
else
{
table = new DataTable();
}
lblStatusRc.Text = $"Salvati {saved} CSV nella cartella {exportFolder}";
}
else
{
table = await scraper.ScrapeAsync(date, progress, status, ct);
}
}
else
{
// === API (FormFav / RacingAPI) ===
@@ -1295,6 +1372,30 @@ namespace HorseRacingPredictor
// ???????????????????? FOLDER BROWSE ????????????????????
private static void WriteCsvFile(DataTable data, string filePath)
{
var sb = new StringBuilder();
var headers = new string[data.Columns.Count];
for (int i = 0; i < data.Columns.Count; i++)
headers[i] = data.Columns[i].ColumnName;
sb.AppendLine(string.Join(";", headers));
foreach (DataRow row in data.Rows)
{
var vals = new string[data.Columns.Count];
for (int i = 0; i < data.Columns.Count; i++)
{
var v = row[i]?.ToString() ?? "";
if (v.Contains(";") || v.Contains("\""))
v = "\"" + v.Replace("\"", "\"\"") + "\"";
vals[i] = v;
}
sb.AppendLine(string.Join(";", vals));
}
File.WriteAllText(filePath, sb.ToString(), Encoding.UTF8);
}
private void btnBrowseFbExport_Click(object sender, RoutedEventArgs e)
{
var path = BrowseFolder("Seleziona la cartella di esportazione per Calcio");
@@ -1331,13 +1432,6 @@ namespace HorseRacingPredictor
txtApiKey.Text = s.ApiKey;
// Applica la API key alla configurazione runtime
AppConfig.SetFootballApiKey(s.ApiKey);
SetComboBoxSelectionByContent(cmbFbDataSource, s.FbDataSource);
txtFbExportPath.Text = s.FbExportPath;
txtFbPrefix.Text = s.FbPrefix;
txtFbSuffix.Text = s.FbSuffix;
chkFbIncludeDate.IsChecked = s.FbIncludeDate;
SetComboBoxSelectionByContent(cmbFbDateFormat, s.FbDateFormat);
SetComboBoxSelectionByContent(cmbFbFormat, s.FbFormat);
// Football Download Options (dynamic popups)
SetFbEndpoint("Fixtures", s.FbDownloadFixtures);
@@ -1362,27 +1456,23 @@ namespace HorseRacingPredictor
SetFbSupplementary("Transfers", s.FbDownloadTransfers);
UpdateFbSupplementarySummary();
txtFbBookmakerId.Text = s.FbBookmakerId.ToString();
txtFbOddsMaxPages.Text = s.FbOddsMaxPages.ToString();
txtFbMaxFixtures.Text = s.FbMaxFixturesForDetails.ToString();
if (cmbFbTimezone != null) SetTimezoneSelection(cmbFbTimezone, s.FbTimezone);
txtFbLeagueIds.Text = s.FbLeagueIds.Count > 0 ? string.Join(",", s.FbLeagueIds) : "";
PopulateFbLeagueCheckboxes();
chkFbCheckQuota.IsChecked = s.FbCheckQuota;
txtFbMinQuota.Text = s.FbMinRemainingQuota.ToString();
txtFbApiDelay.Text = s.FbApiDelayMs.ToString();
SetComboBoxSelection(cmbFbTimeFrom, s.FbTimeFrom ?? "--");
SetComboBoxSelection(cmbFbTimeTo, s.FbTimeTo ?? "--");
// Web Search
chkFbWebSearch.IsChecked = s.FbWebSearchEnabled;
SetComboBoxSelectionByContent(cmbFbWebSearchProvider, ProviderToDisplayName(s.FbWebSearchProvider));
txtFbWebSearchApiKey.Text = s.FbWebSearchApiKey;
txtFbWebSearchGoogleCx.Text = s.FbWebSearchGoogleCx;
txtFbWebSearchSearXNgUrl.Text = string.IsNullOrEmpty(s.FbWebSearchSearXNgUrl)
? "http://192.168.30.23:8082" : s.FbWebSearchSearXNgUrl;
txtFbWebSearchMaxResults.Text = s.FbWebSearchMaxResults.ToString();
txtFbWebSearchDelayMs.Text = s.FbWebSearchDelayMs.ToString();
SetComboBoxSelectionByContent(cmbFbWebSearchLanguage, s.FbWebSearchLanguage);
UpdateWebSearchPanelVisibility();
// Football export
txtFbExportPath.Text = s.FbExportPath;
txtFbPrefix.Text = s.FbPrefix;
txtFbSuffix.Text = s.FbSuffix;
chkFbIncludeDate.IsChecked = s.FbIncludeDate;
SetComboBoxSelectionByContent(cmbFbDateFormat, s.FbDateFormat);
SetComboBoxSelectionByContent(cmbFbFormat, s.FbFormat);
UpdateFbPreview();
// Racing
txtRcExportPath.Text = s.RcExportPath;
@@ -1394,10 +1484,14 @@ namespace HorseRacingPredictor
txtRacingApiKey.Text = string.IsNullOrEmpty(s.RacingApiKey) ? DefaultRacingApiKey : s.RacingApiKey;
SetComboBoxSelectionByContent(cmbRcDataSource, s.RcDataSource);
chkRcPuntersSplitCsv.IsChecked = s.RcPuntersSplitCsv;
if (cmbRcTimezone != null) SetTimezoneSelection(cmbRcTimezone, s.RcTimezone);
SetSelectedCountries(s.RcCountries.ToArray());
UpdateFbPreview();
// Aspetto
tglDarkMode.IsChecked = s.DarkMode;
ThemeManager.Apply(s.DarkMode);
UpdateRcPreview();
ApplyRacingSettings();
ApplyDataSourceVisibility();
@@ -1419,6 +1513,19 @@ namespace HorseRacingPredictor
}
}
private void SetComboBoxSelection(ComboBox combo, string value)
{
if (combo == null) return;
for (int i = 0; i < combo.Items.Count; i++)
{
if (string.Equals(combo.Items[i]?.ToString(), value, StringComparison.OrdinalIgnoreCase))
{
combo.SelectedIndex = i;
return;
}
}
}
private static string EnsureFileExtension(string fileName, string extension)
{
if (string.IsNullOrWhiteSpace(fileName)) return "";
@@ -1470,9 +1577,11 @@ namespace HorseRacingPredictor
{
try
{
var format = (cmbFbFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "CSV";
var datePart = chkFbIncludeDate?.IsChecked == true ? GetSelectedDateString(cmbFbDateFormat, dpFootball.SelectedDate ?? DateTime.Today) : null;
var name = BuildFilename(txtFbPrefix?.Text, datePart, txtFbSuffix?.Text, null, $"Partite_{(dpFootball.SelectedDate ?? DateTime.Today):yyyy-MM-dd}.{format.ToLower()}");
var format = (cmbFbFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "JSON";
var fbDate = dpFootball?.SelectedDate ?? DateTime.Today;
var datePart = chkFbIncludeDate?.IsChecked == true ? GetSelectedDateString(cmbFbDateFormat, fbDate) : null;
var defaultName = $"Calcio_{fbDate:yyyy-MM-dd}.{format.ToLower()}";
var name = BuildFilename(txtFbPrefix?.Text, datePart, txtFbSuffix?.Text, null, defaultName);
name = SanitizeFileName(name);
name = EnsureFileExtension(name, "." + format.ToLower());
if (txtFbPreview != null) txtFbPreview.Text = name;
@@ -1641,13 +1750,7 @@ namespace HorseRacingPredictor
var s = new UserSettings
{
ApiKey = txtApiKey.Text.Trim(),
FbDataSource = (cmbFbDataSource?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "API - API-Football",
FbExportPath = txtFbExportPath.Text.Trim(),
FbPrefix = txtFbPrefix.Text.Trim(),
FbSuffix = txtFbSuffix.Text.Trim(),
FbIncludeDate = chkFbIncludeDate.IsChecked == true,
FbDateFormat = (cmbFbDateFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "yyyy-MM-dd",
FbFormat = (cmbFbFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "CSV",
FbDataSource = "API - Football",
// Football Download Options (from dynamic popups)
FbDownloadFixtures = GetFbEndpoint("Fixtures"),
FbDownloadOdds = GetFbEndpoint("Odds"),
@@ -1658,32 +1761,19 @@ namespace HorseRacingPredictor
FbDownloadLineups = GetFbEndpoint("Lineups"),
FbDownloadStatistics = GetFbEndpoint("Statistics"),
FbDownloadInjuries = GetFbEndpoint("Injuries"),
// Football Supplementary Download Options (from dynamic popups)
FbDownloadPlayerStats = GetFbSupplementary("PlayerStats"),
FbDownloadTeamStats = GetFbSupplementary("TeamStats"),
FbDownloadTopScorers = GetFbSupplementary("TopScorers"),
FbDownloadTopAssists = GetFbSupplementary("TopAssists"),
FbDownloadTopCards = GetFbSupplementary("TopCards"),
FbDownloadSquads = GetFbSupplementary("Squads"),
FbDownloadCoaches = GetFbSupplementary("Coaches"),
FbDownloadTransfers = GetFbSupplementary("Transfers"),
FbBookmakerId = int.TryParse(txtFbBookmakerId.Text.Trim(), out var bId) ? bId : 8,
FbOddsMaxPages = int.TryParse(txtFbOddsMaxPages.Text.Trim(), out var omp) ? omp : 3,
FbMaxFixturesForDetails = int.TryParse(txtFbMaxFixtures.Text.Trim(), out var mf) ? mf : 50,
FbTimezone = (cmbFbTimezone?.SelectedItem as string)?.Trim() ?? "Europe/Rome",
FbLeagueIds = ParseIntList(txtFbLeagueIds?.Text),
FbCheckQuota = chkFbCheckQuota.IsChecked == true,
FbMinRemainingQuota = int.TryParse(txtFbMinQuota.Text.Trim(), out var mq) ? mq : 10,
FbApiDelayMs = int.TryParse(txtFbApiDelay.Text.Trim(), out var ad) ? ad : 300,
// Web Search
FbWebSearchEnabled = chkFbWebSearch.IsChecked == true,
FbWebSearchProvider = DisplayNameToProvider((cmbFbWebSearchProvider?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "SearXNG (self-hosted)"),
FbWebSearchApiKey = txtFbWebSearchApiKey.Text.Trim(),
FbWebSearchGoogleCx = txtFbWebSearchGoogleCx.Text.Trim(),
FbWebSearchSearXNgUrl = txtFbWebSearchSearXNgUrl.Text.Trim(),
FbWebSearchMaxResults = int.TryParse(txtFbWebSearchMaxResults.Text.Trim(), out var mr) ? Math.Clamp(mr, 1, 20) : 10,
FbWebSearchDelayMs = int.TryParse(txtFbWebSearchDelayMs.Text.Trim(), out var wd) ? wd : 300,
FbWebSearchLanguage = (cmbFbWebSearchLanguage?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "it",
FbTimeFrom = (cmbFbTimeFrom?.SelectedItem as string) is string tf2 && tf2 != "--" ? tf2 : null,
FbTimeTo = (cmbFbTimeTo?.SelectedItem as string) is string tt2 && tt2 != "--" ? tt2 : null,
FbExportPath = txtFbExportPath.Text.Trim(),
FbPrefix = txtFbPrefix.Text.Trim(),
FbSuffix = txtFbSuffix.Text.Trim(),
FbIncludeDate = chkFbIncludeDate.IsChecked == true,
FbDateFormat = (cmbFbDateFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "yyyy-MM-dd",
FbFormat = (cmbFbFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "JSON",
// Racing
RcExportPath = txtRcExportPath.Text.Trim(),
RcPrefix = txtRcPrefix.Text.Trim(),
@@ -1694,11 +1784,12 @@ namespace HorseRacingPredictor
RacingApiKey = txtRacingApiKey.Text.Trim(),
RcDataSource = (cmbRcDataSource?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "API - FormFav",
RcTimezone = (cmbRcTimezone?.SelectedItem as string)?.Trim() ?? "Australia/Sydney",
RcCountries = new List<string>(GetSelectedCountries())
RcCountries = new List<string>(GetSelectedCountries()),
RcPuntersSplitCsv = chkRcPuntersSplitCsv.IsChecked == true,
DarkMode = tglDarkMode.IsChecked == true,
};
s.Save();
UpdateFbPreview();
UpdateRcPreview();
ApplyRacingSettings();
ApplyDataSourceVisibility();
@@ -1720,13 +1811,8 @@ namespace HorseRacingPredictor
/// </summary>
private void UpdateFootballStatCards()
{
int rows = _footballData?.DefaultView?.Count ?? _footballData?.Rows.Count ?? 0;
int cols = _footballData?.Columns.Count ?? 0;
// Escludi colonne helper interne dal conteggio
if (_footballData?.Columns.Contains("_timeMinutes") == true) cols--;
int rows = fbGridView?.MatchCount ?? 0;
lblFbCardCount.Text = rows.ToString();
lblFbCardCols.Text = cols > 0 ? cols.ToString() : "--";
lblFbCardFormat.Text = (cmbFbFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "CSV";
if (rows > 0)
{
@@ -1763,6 +1849,125 @@ namespace HorseRacingPredictor
}
}
/// <summary>
/// Converte la DataTable scaricata da API-Football in una lista di FootballMatchViewModel.
/// I dati 1:1 sono mappati direttamente; i dati 1:N (quote, eventi, ecc.) sono raccolti
/// in ObservableCollection se le relative colonne sono presenti.
/// </summary>
private static List<Football.FootballMatchViewModel> BuildFootballViewModels(System.Data.DataTable table)
{
var list = new List<Football.FootballMatchViewModel>();
if (table == null) return list;
static string Col(System.Data.DataRow r, string name)
{
if (!r.Table.Columns.Contains(name)) return null;
var v = r[name];
return v == DBNull.Value || v == null ? null : v.ToString()?.Trim();
}
foreach (System.Data.DataRow row in table.Rows)
{
var vm = new Football.FootballMatchViewModel
{
FixtureId = Col(row, "FixtureId"),
DataOra = Col(row, "Inizio") ?? Col(row, "DataOra") ?? Col(row, "Date"),
Lega = Col(row, "Lega") ?? Col(row, "League"),
Nazione = Col(row, "Nazione") ?? Col(row, "Country"),
Stagione = Col(row, "Stagione") ?? Col(row, "Season"),
SquadraCasa = Col(row, "SquadraCasa") ?? Col(row, "HomeTeam"),
SquadraOspite = Col(row, "SquadraOspite") ?? Col(row, "AwayTeam"),
Risultato = Col(row, "Risultato") ?? Col(row, "Score"),
Stato = Col(row, "Stato") ?? Col(row, "Status"),
Arbitro = Col(row, "Arbitro") ?? Col(row, "Referee"),
Stadio = Col(row, "Stadio") ?? Col(row, "Venue"),
Città = Col(row, "Città") ?? Col(row, "City"),
PredizioneAdvice = Col(row, "PredAdvice") ?? Col(row, "Advice"),
PredizioneFinaleBase= Col(row, "PredWinner") ?? Col(row, "Winner"),
PredPercCasa = Col(row, "PredHomePct") ?? Col(row, "HomePct"),
PredPercPari = Col(row, "PredDrawPct") ?? Col(row, "DrawPct"),
PredPercOspite = Col(row, "PredAwayPct") ?? Col(row, "AwayPct"),
PredGoalsHome = Col(row, "PredGoalsHome"),
PredGoalsAway = Col(row, "PredGoalsAway"),
FormCasa = Col(row, "FormHome") ?? Col(row, "FormCasa"),
FormOspite = Col(row, "FormAway") ?? Col(row, "FormOspite"),
AttaccoCasa = Col(row, "AttackHome"),
DifesaCasa = Col(row, "DefenseHome"),
AttaccoOspite = Col(row, "AttackAway"),
DifesaOspite = Col(row, "DefenseAway"),
PoissonHome = Col(row, "PoissonHome"),
PoissonAway = Col(row, "PoissonAway"),
PoissonDraw = Col(row, "PoissonDraw"),
Under25 = Col(row, "Under25"),
Over25 = Col(row, "Over25"),
GoalGoal = Col(row, "GoalGoal") ?? Col(row, "BothScore"),
NoGoal = Col(row, "NoGoal"),
ClassificaCasa = Col(row, "StandingHome") ?? Col(row, "RankHome"),
PuntiCasa = Col(row, "PtsHome"),
VittorieCasa = Col(row, "WHome"),
PareggiCasa = Col(row, "DHome"),
SconfitteCasa = Col(row, "LHome"),
GoalFattiCasa = Col(row, "GFHome"),
GoalSubitiCasa = Col(row, "GAHome"),
ClassificaOspite = Col(row, "StandingAway") ?? Col(row, "RankAway"),
PuntiOspite = Col(row, "PtsAway"),
VittorieOspite = Col(row, "WAway"),
PareggiOspite = Col(row, "DAway"),
SconfitteOspite = Col(row, "LAway"),
GoalFattiOspite = Col(row, "GFAway"),
GoalSubitiOspite = Col(row, "GAAway"),
InfoWebPartita = Col(row, "WebSearchResult") ?? Col(row, "WebInfo"),
};
// Quote: serializzate come "Bookmaker|Mercato|Esito|Quota" separate da ";"
var oddsRaw = Col(row, "Odds") ?? Col(row, "OddsRaw");
if (!string.IsNullOrEmpty(oddsRaw))
{
foreach (var part in oddsRaw.Split(';', System.StringSplitOptions.RemoveEmptyEntries))
{
var seg = part.Split('|');
if (seg.Length >= 4)
vm.Odds.Add(new Football.OddsRow
{
Bookmaker = seg[0].Trim(),
Mercato = seg[1].Trim(),
Esito = seg[2].Trim(),
Quota = seg[3].Trim(),
});
}
}
// Quote colonne dedicate (es. Quota_1, Quota_X, Quota_2)
foreach (System.Data.DataColumn col in table.Columns)
{
if (col.ColumnName.StartsWith("Quota_", System.StringComparison.OrdinalIgnoreCase)
|| col.ColumnName.StartsWith("Odd_", System.StringComparison.OrdinalIgnoreCase))
{
var val = Col(row, col.ColumnName);
if (!string.IsNullOrEmpty(val))
{
var market = col.ColumnName.Split('_');
vm.Odds.Add(new Football.OddsRow
{
Bookmaker = "API-Football",
Mercato = market.Length > 2 ? market[1] : "Match Winner",
Esito = market.Length > 2 ? market[2] : market.Length > 1 ? market[1] : col.ColumnName,
Quota = val,
});
}
}
}
list.Add(vm);
}
return list;
}
/// <summary>
/// Costruisce un FootballDownloadOptions leggendo i valori dalla UI.
/// </summary>
@@ -1779,84 +1984,15 @@ namespace HorseRacingPredictor
DownloadLineups = GetFbEndpoint("Lineups"),
DownloadStatistics = GetFbEndpoint("Statistics"),
DownloadInjuries = GetFbEndpoint("Injuries"),
// Supplementari
DownloadPlayerStats = GetFbSupplementary("PlayerStats"),
DownloadTeamStats = GetFbSupplementary("TeamStats"),
DownloadTopScorers = GetFbSupplementary("TopScorers"),
DownloadTopAssists = GetFbSupplementary("TopAssists"),
DownloadTopCards = GetFbSupplementary("TopCards"),
DownloadSquads = GetFbSupplementary("Squads"),
DownloadCoaches = GetFbSupplementary("Coaches"),
DownloadTransfers = GetFbSupplementary("Transfers"),
BookmakerId = int.TryParse(txtFbBookmakerId.Text.Trim(), out var bId) ? bId : 8,
OddsMaxPages = int.TryParse(txtFbOddsMaxPages.Text.Trim(), out var omp) ? omp : 3,
MaxFixturesForDetails = int.TryParse(txtFbMaxFixtures.Text.Trim(), out var mf) ? mf : 50,
Timezone = (cmbFbTimezone?.SelectedItem as string)?.Trim() ?? "Europe/Rome",
LeagueIds = ParseIntList(txtFbLeagueIds?.Text),
CheckQuota = chkFbCheckQuota.IsChecked == true,
MinRemainingQuota = int.TryParse(txtFbMinQuota.Text.Trim(), out var mq) ? mq : 10,
ApiDelayMs = int.TryParse(txtFbApiDelay.Text.Trim(), out var ad) ? ad : 300,
TimeFrom = GetSelectedTimeFilter(cmbFbTimeFrom),
TimeTo = GetSelectedTimeFilter(cmbFbTimeTo),
WebSearch = new Football.WebSearch.WebSearchOptions
{
Enabled = chkFbWebSearch?.IsChecked == true,
Provider = Enum.TryParse<Football.WebSearch.WebSearchProvider>(
DisplayNameToProvider((cmbFbWebSearchProvider?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? ""),
out var wsp) ? wsp : Football.WebSearch.WebSearchProvider.SearXng,
ApiKey = txtFbWebSearchApiKey?.Text.Trim() ?? "",
GoogleCx = txtFbWebSearchGoogleCx?.Text.Trim() ?? "",
SearXNgUrl = txtFbWebSearchSearXNgUrl?.Text.Trim() ?? "http://192.168.30.23:8082",
MaxResults = int.TryParse(txtFbWebSearchMaxResults?.Text.Trim(), out var wmr) ? Math.Clamp(wmr, 1, 20) : 10,
DelayMs = int.TryParse(txtFbWebSearchDelayMs?.Text.Trim(), out var wdms) ? wdms : 300,
Language = (cmbFbWebSearchLanguage?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "it",
}
};
}
// ———————————— WEB SEARCH HELPERS ————————————
private static string ProviderToDisplayName(string provider) => provider switch
{
"SearXng" => "SearXNG (self-hosted)",
"Bing" => "Bing Web Search API",
"Google" => "Google Custom Search",
"SerpApi" => "SerpAPI",
_ => "SearXNG (self-hosted)"
};
private static string DisplayNameToProvider(string displayName) => displayName switch
{
"SearXNG (self-hosted)" => "SearXng",
"Bing Web Search API" => "Bing",
"Google Custom Search" => "Google",
"SerpAPI" => "SerpApi",
_ => "SearXng"
};
private void UpdateWebSearchPanelVisibility()
{
bool enabled = chkFbWebSearch?.IsChecked == true;
if (pnlFbWebSearch != null)
pnlFbWebSearch.Visibility = enabled ? Visibility.Visible : Visibility.Collapsed;
string provider = (cmbFbWebSearchProvider?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "";
bool isSearXng = provider == "SearXNG (self-hosted)";
bool isGoogle = provider == "Google Custom Search";
if (pnlFbWebSearchSearXNg != null)
pnlFbWebSearchSearXNg.Visibility = isSearXng ? Visibility.Visible : Visibility.Collapsed;
if (pnlFbWebSearchApiKey != null)
pnlFbWebSearchApiKey.Visibility = isSearXng ? Visibility.Collapsed : Visibility.Visible;
if (pnlFbWebSearchGoogleCx != null)
pnlFbWebSearchGoogleCx.Visibility = isGoogle ? Visibility.Visible : Visibility.Collapsed;
}
private void cmbFbWebSearchProvider_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
UpdateWebSearchPanelVisibility();
}
// ————————————————————————————————————————
/// <summary>
@@ -1874,6 +2010,9 @@ namespace HorseRacingPredictor
return result;
}
private void tglDarkMode_Checked(object sender, RoutedEventArgs e) => ThemeManager.Apply(true);
private void tglDarkMode_Unchecked(object sender, RoutedEventArgs e) => ThemeManager.Apply(false);
private static TimeSpan? GetSelectedTimeFilter(ComboBox cmb)
{
var text = cmb?.SelectedItem as string;
@@ -0,0 +1,49 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- MD3 Dark Theme - Color Tokens -->
<SolidColorBrush x:Key="BrPrimary" Color="#D0BCFF"/>
<SolidColorBrush x:Key="BrOnPrimary" Color="#381E72"/>
<SolidColorBrush x:Key="BrPrimaryContainer" Color="#4F378B"/>
<SolidColorBrush x:Key="BrOnPrimaryContainer" Color="#EADDFF"/>
<SolidColorBrush x:Key="BrSecondary" Color="#CCC2DC"/>
<SolidColorBrush x:Key="BrOnSecondary" Color="#332D41"/>
<SolidColorBrush x:Key="BrSecondaryContainer" Color="#4A4458"/>
<SolidColorBrush x:Key="BrOnSecondaryContainer" Color="#E8DEF8"/>
<SolidColorBrush x:Key="BrTertiary" Color="#EFB8C8"/>
<SolidColorBrush x:Key="BrTertiaryContainer" Color="#633B48"/>
<SolidColorBrush x:Key="BrOnTertiaryContainer" Color="#FFD8E4"/>
<SolidColorBrush x:Key="BrError" Color="#F2B8B5"/>
<SolidColorBrush x:Key="BrSurface" Color="#141218"/>
<SolidColorBrush x:Key="BrOnSurface" Color="#E6E0E9"/>
<SolidColorBrush x:Key="BrSurfaceVariant" Color="#49454F"/>
<SolidColorBrush x:Key="BrOnSurfaceVariant" Color="#CAC4D0"/>
<SolidColorBrush x:Key="BrSurfaceContainer" Color="#211F26"/>
<SolidColorBrush x:Key="BrSurfaceContainerHigh" Color="#2B2930"/>
<SolidColorBrush x:Key="BrSurfaceContainerHighest" Color="#36343B"/>
<SolidColorBrush x:Key="BrSurfaceContainerLow" Color="#1D1B20"/>
<SolidColorBrush x:Key="BrBackground" Color="#141218"/>
<SolidColorBrush x:Key="BrOutline" Color="#938F99"/>
<SolidColorBrush x:Key="BrOutlineVariant" Color="#49454F"/>
<!-- Legacy aliases -->
<SolidColorBrush x:Key="BrBase" Color="#141218"/>
<SolidColorBrush x:Key="BrMantle" Color="#211F26"/>
<SolidColorBrush x:Key="BrSurface0" Color="#1D1B20"/>
<SolidColorBrush x:Key="BrSurface1" Color="#211F26"/>
<SolidColorBrush x:Key="BrSurface2" Color="#2B2930"/>
<SolidColorBrush x:Key="BrOverlay0" Color="#CAC4D0"/>
<SolidColorBrush x:Key="BrText" Color="#E6E0E9"/>
<SolidColorBrush x:Key="BrSubtext0" Color="#CAC4D0"/>
<SolidColorBrush x:Key="BrBlue" Color="#D0BCFF"/>
<SolidColorBrush x:Key="BrGreen" Color="#A8D5A2"/>
<SolidColorBrush x:Key="BrRed" Color="#F2B8B5"/>
<SolidColorBrush x:Key="BrPeach" Color="#CCC2DC"/>
<SolidColorBrush x:Key="BrLavender" Color="#4F378B"/>
<SolidColorBrush x:Key="BrBorder" Color="#49454F"/>
<!-- Row hover tint (semi-transparent primary) -->
<SolidColorBrush x:Key="BrRowHover" Color="#1AD0BCFF"/>
</ResourceDictionary>
@@ -0,0 +1,470 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- Elevation drop shadows (MD3 tonal elevation complement) -->
<DropShadowEffect x:Key="Elevation1" BlurRadius="3" ShadowDepth="1" Opacity="0.12" Color="#000000"/>
<DropShadowEffect x:Key="Elevation2" BlurRadius="6" ShadowDepth="2" Opacity="0.14" Color="#000000"/>
<DropShadowEffect x:Key="Elevation3" BlurRadius="12" ShadowDepth="4" Opacity="0.16" Color="#000000"/>
<DropShadowEffect x:Key="SubtleDropShadow" BlurRadius="6" ShadowDepth="2" Opacity="0.12" Color="#000000"/>
<!-- =============================================================
NAVIGATION RAIL BUTTON (MD3 pill active indicator)
============================================================= -->
<Style x:Key="NavBtn" TargetType="RadioButton">
<Setter Property="Width" Value="80"/>
<Setter Property="Height" Value="72"/>
<Setter Property="Margin" Value="0,2"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurfaceVariant}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<Grid HorizontalAlignment="Center" Width="56" Height="32">
<!-- Active pill indicator -->
<Border x:Name="Pill" CornerRadius="16"
Background="Transparent"
HorizontalAlignment="Center" VerticalAlignment="Center"
Width="56" Height="32"/>
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<TextBlock x:Name="NavLabel"
Text="{TemplateBinding Tag}"
FontFamily="Roboto, Segoe UI" FontSize="12" FontWeight="Medium"
Foreground="{TemplateBinding Foreground}"
HorizontalAlignment="Center" TextAlignment="Center"
Margin="0,4,0,0"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Pill" Property="Background" Value="{DynamicResource BrRowHover}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Pill" Property="Background" Value="{DynamicResource BrSecondaryContainer}"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSecondaryContainer}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- =============================================================
FILLED BUTTON (MD3 pill shape, height 40px)
============================================================= -->
<Style x:Key="AccentBtn" TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource BrOnPrimary}"/>
<Setter Property="Background" Value="{DynamicResource BrPrimary}"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="24,0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
Effect="{StaticResource Elevation1}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.92"/>
<Setter TargetName="Bd" Property="Effect" Value="{StaticResource Elevation2}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.88"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.38"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Tonal button alias used by some pages -->
<Style x:Key="ToolBtn" TargetType="Button">
<Setter Property="Foreground" Value="{DynamicResource BrOnSecondaryContainer}"/>
<Setter Property="Background" Value="{DynamicResource BrSecondaryContainer}"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="24,0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="4"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.92"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.38"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- =============================================================
OUTLINED TEXT FIELD (MD3 4px corner, 2px focus border)
============================================================= -->
<Style x:Key="FlatTb" TargetType="TextBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Setter Property="CaretBrush" Value="{DynamicResource BrPrimary}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource BrPrimaryContainer}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BrOutline}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="16,0"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd" CornerRadius="4"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<ScrollViewer x:Name="PART_ContentHost" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BrPrimary}"/>
<Setter TargetName="Bd" Property="BorderThickness" Value="2"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BrOnSurface}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- =============================================================
PASSWORD BOX (MD3 outlined)
============================================================= -->
<Style x:Key="FlatPb" TargetType="PasswordBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Setter Property="CaretBrush" Value="{DynamicResource BrPrimary}"/>
<Setter Property="SelectionBrush" Value="{DynamicResource BrPrimaryContainer}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BrOutline}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="16,0"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="PasswordBox">
<Border x:Name="Bd" CornerRadius="4"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<ScrollViewer x:Name="PART_ContentHost" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BrPrimary}"/>
<Setter TargetName="Bd" Property="BorderThickness" Value="2"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- PROGRESS BAR -->
<!-- =============================================================
PROGRESS BAR (MD3 linear indicator)
============================================================= -->
<Style x:Key="ModernPb" TargetType="ProgressBar">
<Setter Property="Height" Value="4"/>
<Setter Property="Background" Value="{DynamicResource BrSurfaceVariant}"/>
<Setter Property="Foreground" Value="{DynamicResource BrPrimary}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Grid>
<Border CornerRadius="2" Background="{TemplateBinding Background}"/>
<Border x:Name="PART_Indicator" CornerRadius="2"
Background="{TemplateBinding Foreground}"
HorizontalAlignment="Left"/>
<Border x:Name="PART_Track"/>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- =============================================================
DATAGRID (MD3 surface tones)
============================================================= -->
<Style x:Key="ModernDg" TargetType="DataGrid">
<Setter Property="Background" Value="{DynamicResource BrSurfaceContainerLow}"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="RowBackground" Value="{DynamicResource BrSurfaceContainerLow}"/>
<Setter Property="AlternatingRowBackground" Value="{DynamicResource BrSurfaceContainer}"/>
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
<Setter Property="HorizontalGridLinesBrush" Value="{DynamicResource BrOutlineVariant}"/>
<Setter Property="HeadersVisibility" Value="Column"/>
<Setter Property="RowHeaderWidth" Value="0"/>
<Setter Property="AutoGenerateColumns" Value="True"/>
<Setter Property="IsReadOnly" Value="True"/>
<Setter Property="SelectionMode" Value="Single"/>
<Setter Property="CanUserAddRows" Value="False"/>
<Setter Property="CanUserDeleteRows" Value="False"/>
<Setter Property="CanUserResizeRows" Value="False"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="14"/>
</Style>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="{DynamicResource BrSurfaceContainerHigh}"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurfaceVariant}"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Padding" Value="16,12"/>
<Setter Property="BorderBrush" Value="{DynamicResource BrOutlineVariant}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
</Style>
<Style TargetType="DataGridCell">
<Setter Property="Padding" Value="16,10"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
<Border Padding="{TemplateBinding Padding}" Background="{TemplateBinding Background}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource BrSecondaryContainer}"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSecondaryContainer}"/>
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="DataGridRow">
<Setter Property="Margin" Value="0"/>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource BrSecondaryContainer}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource BrRowHover}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- =============================================================
CARD (MD3 Surface Container, 12px radius, Elevation 1)
============================================================= -->
<Style x:Key="StatCard" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource BrSurfaceContainer}"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="16"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Effect" Value="{StaticResource Elevation1}"/>
</Style>
<Style x:Key="ApiCard" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource BrSurfaceContainerHigh}"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="20,16"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Effect" Value="{StaticResource Elevation1}"/>
<Setter Property="Margin" Value="0,0,0,16"/>
</Style>
<Style x:Key="CardStyle" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource BrSurfaceContainer}"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="16"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Effect" Value="{StaticResource Elevation1}"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
<!-- =============================================================
SECTION TITLE (MD3 Title Medium)
============================================================= -->
<Style x:Key="SectionTitle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
<!-- =============================================================
SCROLLBAR (MD3 slim, OutlineVariant thumb)
============================================================= -->
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="8"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid>
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border CornerRadius="4" Margin="2"
Background="{DynamicResource BrOutlineVariant}"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="Height" Value="8"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- =============================================================
LISTBOX (transparent background)
============================================================= -->
<Style TargetType="ListBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
</Style>
<!-- =============================================================
COMBOBOXITEM - foreground/background from active theme
============================================================= -->
<Style TargetType="ComboBoxItem">
<Setter Property="Background" Value="{DynamicResource BrSurface}"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="14"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="{DynamicResource BrSurfaceContainerHighest}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="{DynamicResource BrPrimaryContainer}"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnPrimaryContainer}"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- =============================================================
COMBOBOX - themed box and popup
============================================================= -->
<Style TargetType="ComboBox">
<Setter Property="Background" Value="{DynamicResource BrSurface}"/>
<Setter Property="Foreground" Value="{DynamicResource BrOnSurface}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BrOutline}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Height" Value="40"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="32"/>
</Grid.ColumnDefinitions>
<ContentPresenter Grid.Column="0"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
Margin="{TemplateBinding Padding}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
HorizontalAlignment="Left"/>
<Path Grid.Column="1"
Data="M 0,0 L 4,4 L 8,0"
Stroke="{DynamicResource BrOnSurfaceVariant}" StrokeThickness="1.5"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<ToggleButton Grid.Column="0" Grid.ColumnSpan="2"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
Background="Transparent" BorderThickness="0" Opacity="0"
Focusable="False"/>
</Grid>
<Popup x:Name="PART_Popup"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True"
Placement="Bottom"
PlacementTarget="{Binding RelativeSource={RelativeSource TemplatedParent}}"
Width="{Binding ActualWidth, RelativeSource={RelativeSource TemplatedParent}}">
<Border Background="{DynamicResource BrSurface}"
BorderBrush="{DynamicResource BrOutlineVariant}"
BorderThickness="1" CornerRadius="4"
Margin="0,2,0,0"
MaxHeight="300">
<Border.Effect>
<DropShadowEffect BlurRadius="8" ShadowDepth="2" Opacity="0.15" Color="#000000"/>
</Border.Effect>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsPresenter/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BrOnSurface}"/>
</Trigger>
<Trigger Property="IsDropDownOpen" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BrPrimary}"/>
<Setter TargetName="Bd" Property="BorderThickness" Value="2"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
@@ -0,0 +1,49 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- MD3 Light Theme - Color Tokens -->
<SolidColorBrush x:Key="BrPrimary" Color="#6750A4"/>
<SolidColorBrush x:Key="BrOnPrimary" Color="#FFFFFF"/>
<SolidColorBrush x:Key="BrPrimaryContainer" Color="#EADDFF"/>
<SolidColorBrush x:Key="BrOnPrimaryContainer" Color="#21005D"/>
<SolidColorBrush x:Key="BrSecondary" Color="#625B71"/>
<SolidColorBrush x:Key="BrOnSecondary" Color="#FFFFFF"/>
<SolidColorBrush x:Key="BrSecondaryContainer" Color="#E8DEF8"/>
<SolidColorBrush x:Key="BrOnSecondaryContainer" Color="#1D192B"/>
<SolidColorBrush x:Key="BrTertiary" Color="#7D5260"/>
<SolidColorBrush x:Key="BrTertiaryContainer" Color="#FFD8E4"/>
<SolidColorBrush x:Key="BrOnTertiaryContainer" Color="#31111D"/>
<SolidColorBrush x:Key="BrError" Color="#B3261E"/>
<SolidColorBrush x:Key="BrSurface" Color="#FEF7FF"/>
<SolidColorBrush x:Key="BrOnSurface" Color="#1C1B1F"/>
<SolidColorBrush x:Key="BrSurfaceVariant" Color="#E7E0EC"/>
<SolidColorBrush x:Key="BrOnSurfaceVariant" Color="#49454F"/>
<SolidColorBrush x:Key="BrSurfaceContainer" Color="#F3EDF7"/>
<SolidColorBrush x:Key="BrSurfaceContainerHigh" Color="#ECE6F0"/>
<SolidColorBrush x:Key="BrSurfaceContainerHighest" Color="#E6E0E9"/>
<SolidColorBrush x:Key="BrSurfaceContainerLow" Color="#F7F2FA"/>
<SolidColorBrush x:Key="BrBackground" Color="#FEF7FF"/>
<SolidColorBrush x:Key="BrOutline" Color="#79747E"/>
<SolidColorBrush x:Key="BrOutlineVariant" Color="#CAC4D0"/>
<!-- Legacy aliases -->
<SolidColorBrush x:Key="BrBase" Color="#FEF7FF"/>
<SolidColorBrush x:Key="BrMantle" Color="#F3EDF7"/>
<SolidColorBrush x:Key="BrSurface0" Color="#F7F2FA"/>
<SolidColorBrush x:Key="BrSurface1" Color="#F3EDF7"/>
<SolidColorBrush x:Key="BrSurface2" Color="#ECE6F0"/>
<SolidColorBrush x:Key="BrOverlay0" Color="#49454F"/>
<SolidColorBrush x:Key="BrText" Color="#1C1B1F"/>
<SolidColorBrush x:Key="BrSubtext0" Color="#49454F"/>
<SolidColorBrush x:Key="BrBlue" Color="#6750A4"/>
<SolidColorBrush x:Key="BrGreen" Color="#386A20"/>
<SolidColorBrush x:Key="BrRed" Color="#B3261E"/>
<SolidColorBrush x:Key="BrPeach" Color="#625B71"/>
<SolidColorBrush x:Key="BrLavender" Color="#EADDFF"/>
<SolidColorBrush x:Key="BrBorder" Color="#CAC4D0"/>
<!-- Row hover tint (semi-transparent primary) -->
<SolidColorBrush x:Key="BrRowHover" Color="#1A6750A4"/>
</ResourceDictionary>
@@ -0,0 +1,33 @@
using System;
using System.Windows;
namespace HorseRacingPredictor
{
/// <summary>
/// Swaps the active MD3 theme (light / dark) at runtime by replacing
/// the first merged dictionary in Application.Resources with either
/// LightTheme.xaml or DarkTheme.xaml.
/// </summary>
internal static class ThemeManager
{
private static readonly Uri LightUri = new("/Styles/LightTheme.xaml", UriKind.Relative);
private static readonly Uri DarkUri = new("/Styles/DarkTheme.xaml", UriKind.Relative);
public static bool IsDark { get; private set; }
/// <summary>Applies the requested theme. Call from the UI thread.</summary>
public static void Apply(bool dark)
{
IsDark = dark;
var mergedDicts = Application.Current.Resources.MergedDictionaries;
// The theme dictionary is always at index 0 (see App.xaml)
var themeDict = new ResourceDictionary { Source = dark ? DarkUri : LightUri };
if (mergedDicts.Count > 0)
mergedDicts[0] = themeDict;
else
mergedDicts.Insert(0, themeDict);
}
}
}
+329 -101
View File
@@ -1,67 +1,154 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- =========================================================
CATPPUCCIN MOCHA PALETTE (shared)
========================================================= -->
<Color x:Key="CBase">#1E1E2E</Color>
<Color x:Key="CMantle">#181825</Color>
<Color x:Key="CCrust">#11111B</Color>
<Color x:Key="CSurface0">#313244</Color>
<Color x:Key="CSurface1">#45475A</Color>
<Color x:Key="CSurface2">#585B70</Color>
<Color x:Key="COverlay0">#6C7086</Color>
<Color x:Key="CText">#CDD6F4</Color>
<Color x:Key="CSubtext0">#A6ADC8</Color>
<Color x:Key="CSubtext1">#BAC2DE</Color>
<Color x:Key="CBlue">#89B4FA</Color>
<Color x:Key="CGreen">#A6E3A1</Color>
<Color x:Key="CRed">#F38BA8</Color>
<Color x:Key="CPeach">#FAB387</Color>
<Color x:Key="CLavender">#B4BEFE</Color>
<!-- =============================================================
MATERIAL DESIGN 3 TOKEN SYSTEM (Light Mode)
Reference: https://m3.material.io/styles/color/roles
============================================================= -->
<SolidColorBrush x:Key="BrBase" Color="{StaticResource CBase}"/>
<SolidColorBrush x:Key="BrMantle" Color="{StaticResource CMantle}"/>
<SolidColorBrush x:Key="BrSurface0" Color="{StaticResource CSurface0}"/>
<SolidColorBrush x:Key="BrSurface1" Color="{StaticResource CSurface1}"/>
<SolidColorBrush x:Key="BrSurface2" Color="{StaticResource CSurface2}"/>
<SolidColorBrush x:Key="BrOverlay0" Color="{StaticResource COverlay0}"/>
<SolidColorBrush x:Key="BrText" Color="{StaticResource CText}"/>
<SolidColorBrush x:Key="BrSubtext0" Color="{StaticResource CSubtext0}"/>
<SolidColorBrush x:Key="BrBlue" Color="{StaticResource CBlue}"/>
<SolidColorBrush x:Key="BrGreen" Color="{StaticResource CGreen}"/>
<SolidColorBrush x:Key="BrRed" Color="{StaticResource CRed}"/>
<SolidColorBrush x:Key="BrPeach" Color="{StaticResource CPeach}"/>
<SolidColorBrush x:Key="BrLavender" Color="{StaticResource CLavender}"/>
<!-- Primary palette -->
<Color x:Key="CPrimary">#6750A4</Color>
<Color x:Key="COnPrimary">#FFFFFF</Color>
<Color x:Key="CPrimaryContainer">#EADDFF</Color>
<Color x:Key="COnPrimaryContainer">#21005D</Color>
<!-- Acrylic-like background (semi-transparent fallback) -->
<SolidColorBrush x:Key="AcrylicBackgroundBrush" Color="#0F000000"/>
<!-- Secondary palette -->
<Color x:Key="CSecondary">#625B71</Color>
<Color x:Key="COnSecondary">#FFFFFF</Color>
<Color x:Key="CSecondaryContainer">#E8DEF8</Color>
<Color x:Key="COnSecondaryContainer">#1D192B</Color>
<!-- Subtle shadow effect for elevation -->
<DropShadowEffect x:Key="SubtleDropShadow" BlurRadius="12" ShadowDepth="2" Color="#50000000"/>
<!-- Tertiary palette -->
<Color x:Key="CTertiary">#7D5260</Color>
<Color x:Key="COnTertiary">#FFFFFF</Color>
<Color x:Key="CTertiaryContainer">#FFD8E4</Color>
<Color x:Key="COnTertiaryContainer">#31111D</Color>
<!-- NAV BUTTON STYLE (icon-only sidebar) -->
<!-- Error -->
<Color x:Key="CError">#B3261E</Color>
<Color x:Key="COnError">#FFFFFF</Color>
<Color x:Key="CErrorContainer">#F9DEDC</Color>
<!-- Surface / Background -->
<Color x:Key="CSurface">#FEF7FF</Color>
<Color x:Key="COnSurface">#1C1B1F</Color>
<Color x:Key="CSurfaceVariant">#E7E0EC</Color>
<Color x:Key="COnSurfaceVariant">#49454F</Color>
<Color x:Key="CSurfaceContainer">#F3EDF7</Color>
<Color x:Key="CSurfaceContainerHigh">#ECE6F0</Color>
<Color x:Key="CSurfaceContainerHighest">#E6E0E9</Color>
<Color x:Key="CSurfaceContainerLow">#F7F2FA</Color>
<Color x:Key="CBackground">#FEF7FF</Color>
<Color x:Key="COnBackground">#1C1B1F</Color>
<!-- Outline -->
<Color x:Key="COutline">#79747E</Color>
<Color x:Key="COutlineVariant">#CAC4D0</Color>
<!-- Misc -->
<Color x:Key="CInversePrimary">#D0BCFF</Color>
<Color x:Key="CShadow">#000000</Color>
<!-- Legacy color keys (kept for compatibility) -->
<Color x:Key="CBase">#FEF7FF</Color>
<Color x:Key="CMantle">#F3EDF7</Color>
<Color x:Key="CCrust">#ECE6F0</Color>
<Color x:Key="CSurface0">#F7F2FA</Color>
<Color x:Key="CSurface1">#F3EDF7</Color>
<Color x:Key="CSurface2">#ECE6F0</Color>
<Color x:Key="COverlay0">#49454F</Color>
<Color x:Key="CText">#1C1B1F</Color>
<Color x:Key="CSubtext0">#49454F</Color>
<Color x:Key="CSubtext1">#625B71</Color>
<Color x:Key="CBlue">#6750A4</Color>
<Color x:Key="CGreen">#7D5260</Color>
<Color x:Key="CRed">#B3261E</Color>
<Color x:Key="CPeach">#625B71</Color>
<Color x:Key="CLavender">#EADDFF</Color>
<!-- MD3 Brushes -->
<SolidColorBrush x:Key="BrPrimary" Color="{StaticResource CPrimary}"/>
<SolidColorBrush x:Key="BrOnPrimary" Color="{StaticResource COnPrimary}"/>
<SolidColorBrush x:Key="BrPrimaryContainer" Color="{StaticResource CPrimaryContainer}"/>
<SolidColorBrush x:Key="BrOnPrimaryContainer" Color="{StaticResource COnPrimaryContainer}"/>
<SolidColorBrush x:Key="BrSecondary" Color="{StaticResource CSecondary}"/>
<SolidColorBrush x:Key="BrOnSecondary" Color="{StaticResource COnSecondary}"/>
<SolidColorBrush x:Key="BrSecondaryContainer" Color="{StaticResource CSecondaryContainer}"/>
<SolidColorBrush x:Key="BrOnSecondaryContainer" Color="{StaticResource COnSecondaryContainer}"/>
<SolidColorBrush x:Key="BrTertiary" Color="{StaticResource CTertiary}"/>
<SolidColorBrush x:Key="BrTertiaryContainer" Color="{StaticResource CTertiaryContainer}"/>
<SolidColorBrush x:Key="BrOnTertiaryContainer" Color="{StaticResource COnTertiaryContainer}"/>
<SolidColorBrush x:Key="BrError" Color="{StaticResource CError}"/>
<SolidColorBrush x:Key="BrSurface" Color="{StaticResource CSurface}"/>
<SolidColorBrush x:Key="BrOnSurface" Color="{StaticResource COnSurface}"/>
<SolidColorBrush x:Key="BrSurfaceVariant" Color="{StaticResource CSurfaceVariant}"/>
<SolidColorBrush x:Key="BrOnSurfaceVariant" Color="{StaticResource COnSurfaceVariant}"/>
<SolidColorBrush x:Key="BrSurfaceContainer" Color="{StaticResource CSurfaceContainer}"/>
<SolidColorBrush x:Key="BrSurfaceContainerHigh" Color="{StaticResource CSurfaceContainerHigh}"/>
<SolidColorBrush x:Key="BrSurfaceContainerHighest" Color="{StaticResource CSurfaceContainerHighest}"/>
<SolidColorBrush x:Key="BrSurfaceContainerLow" Color="{StaticResource CSurfaceContainerLow}"/>
<SolidColorBrush x:Key="BrBackground" Color="{StaticResource CBackground}"/>
<SolidColorBrush x:Key="BrOutline" Color="{StaticResource COutline}"/>
<SolidColorBrush x:Key="BrOutlineVariant" Color="{StaticResource COutlineVariant}"/>
<!-- Legacy brush aliases used by existing XAML -->
<SolidColorBrush x:Key="BrBase" Color="{StaticResource CBackground}"/>
<SolidColorBrush x:Key="BrMantle" Color="{StaticResource CSurfaceContainer}"/>
<SolidColorBrush x:Key="BrSurface0" Color="{StaticResource CSurfaceContainerLow}"/>
<SolidColorBrush x:Key="BrSurface1" Color="{StaticResource CSurfaceContainer}"/>
<SolidColorBrush x:Key="BrSurface2" Color="{StaticResource CSurfaceContainerHigh}"/>
<SolidColorBrush x:Key="BrOverlay0" Color="{StaticResource COnSurfaceVariant}"/>
<SolidColorBrush x:Key="BrText" Color="{StaticResource COnSurface}"/>
<SolidColorBrush x:Key="BrSubtext0" Color="{StaticResource COnSurfaceVariant}"/>
<SolidColorBrush x:Key="BrBlue" Color="{StaticResource CPrimary}"/>
<SolidColorBrush x:Key="BrGreen" Color="{StaticResource CTertiary}"/>
<SolidColorBrush x:Key="BrRed" Color="{StaticResource CError}"/>
<SolidColorBrush x:Key="BrPeach" Color="{StaticResource CSecondary}"/>
<SolidColorBrush x:Key="BrLavender" Color="{StaticResource CPrimaryContainer}"/>
<SolidColorBrush x:Key="BrBorder" Color="{StaticResource COutlineVariant}"/>
<!-- Elevation drop shadows (MD3 tonal elevation complement) -->
<DropShadowEffect x:Key="Elevation1" BlurRadius="3" ShadowDepth="1" Opacity="0.12" Color="{StaticResource CShadow}"/>
<DropShadowEffect x:Key="Elevation2" BlurRadius="6" ShadowDepth="2" Opacity="0.14" Color="{StaticResource CShadow}"/>
<DropShadowEffect x:Key="Elevation3" BlurRadius="12" ShadowDepth="4" Opacity="0.16" Color="{StaticResource CShadow}"/>
<DropShadowEffect x:Key="SubtleDropShadow" BlurRadius="6" ShadowDepth="2" Opacity="0.12" Color="{StaticResource CShadow}"/>
<!-- =============================================================
NAVIGATION RAIL BUTTON (MD3 pill active indicator)
============================================================= -->
<Style x:Key="NavBtn" TargetType="RadioButton">
<Setter Property="Width" Value="48"/>
<Setter Property="Height" Value="48"/>
<Setter Property="Margin" Value="6,4"/>
<Setter Property="Width" Value="80"/>
<Setter Property="Height" Value="72"/>
<Setter Property="Margin" Value="0,2"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Foreground" Value="{StaticResource BrSubtext0}"/>
<Setter Property="Foreground" Value="{StaticResource BrOnSurfaceVariant}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="RadioButton">
<Grid>
<Border x:Name="Bg" CornerRadius="10" Background="{TemplateBinding Background}"/>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<Grid HorizontalAlignment="Center" Width="56" Height="32">
<!-- Active pill indicator -->
<Border x:Name="Pill" CornerRadius="16"
Background="Transparent"
HorizontalAlignment="Center" VerticalAlignment="Center"
Width="56" Height="32"/>
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Grid>
<TextBlock x:Name="NavLabel"
Text="{TemplateBinding Tag}"
FontFamily="Roboto, Segoe UI" FontSize="12" FontWeight="Medium"
Foreground="{TemplateBinding Foreground}"
HorizontalAlignment="Center" TextAlignment="Center"
Margin="0,4,0,0"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bg" Property="Background" Value="#28283A"/>
<Setter TargetName="Pill" Property="Background" Value="#1A6750A4"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bg" Property="Background" Value="{StaticResource BrBlue}"/>
<Setter Property="Foreground" Value="#181825"/>
<Setter TargetName="Pill" Property="Background" Value="{StaticResource BrSecondaryContainer}"/>
<Setter Property="Foreground" Value="{StaticResource BrOnSecondaryContainer}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -69,27 +156,68 @@
</Setter>
</Style>
<!-- ACCENT BUTTON -->
<!-- =============================================================
FILLED BUTTON (MD3 pill shape, height 40px)
============================================================= -->
<Style x:Key="AccentBtn" TargetType="Button">
<Setter Property="Foreground" Value="#181825"/>
<Setter Property="FontFamily" Value="Segoe UI Semibold"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="18,7"/>
<Setter Property="Foreground" Value="{StaticResource BrOnPrimary}"/>
<Setter Property="Background" Value="{StaticResource BrPrimary}"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="24,0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="8"
<Border x:Name="Bd" CornerRadius="100"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
Effect="{StaticResource Elevation1}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.92"/>
<Setter TargetName="Bd" Property="Effect" Value="{StaticResource Elevation2}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.88"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.38"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Tonal button alias used by some pages -->
<Style x:Key="ToolBtn" TargetType="Button">
<Setter Property="Foreground" Value="{StaticResource BrOnSecondaryContainer}"/>
<Setter Property="Background" Value="{StaticResource BrSecondaryContainer}"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="24,0"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="100"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.85"/>
<Setter TargetName="Bd" Property="Opacity" Value="0.92"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="Bd" Property="Opacity" Value="0.40"/>
<Setter TargetName="Bd" Property="Opacity" Value="0.38"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -97,29 +225,38 @@
</Setter>
</Style>
<!-- FLAT TEXTBOX -->
<!-- =============================================================
OUTLINED TEXT FIELD (MD3 4px corner, 2px focus border)
============================================================= -->
<Style x:Key="FlatTb" TargetType="TextBox">
<Setter Property="Background" Value="{StaticResource BrSurface0}"/>
<Setter Property="Foreground" Value="{StaticResource BrText}"/>
<Setter Property="CaretBrush" Value="{StaticResource BrText}"/>
<Setter Property="BorderBrush" Value="{StaticResource BrSurface1}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource BrOnSurface}"/>
<Setter Property="CaretBrush" Value="{StaticResource BrPrimary}"/>
<Setter Property="SelectionBrush" Value="{StaticResource BrPrimaryContainer}"/>
<Setter Property="BorderBrush" Value="{StaticResource BrOutline}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="16,0"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="Bd" CornerRadius="6"
<Border x:Name="Bd" CornerRadius="4"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<ScrollViewer x:Name="PART_ContentHost"/>
<ScrollViewer x:Name="PART_ContentHost" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource BrBlue}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource BrPrimary}"/>
<Setter TargetName="Bd" Property="BorderThickness" Value="2"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource BrOnSurface}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -127,29 +264,35 @@
</Setter>
</Style>
<!-- PASSWORD BOX -->
<!-- =============================================================
PASSWORD BOX (MD3 outlined)
============================================================= -->
<Style x:Key="FlatPb" TargetType="PasswordBox">
<Setter Property="Background" Value="{StaticResource BrSurface0}"/>
<Setter Property="Foreground" Value="{StaticResource BrText}"/>
<Setter Property="CaretBrush" Value="{StaticResource BrText}"/>
<Setter Property="BorderBrush" Value="{StaticResource BrSurface1}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="{StaticResource BrOnSurface}"/>
<Setter Property="CaretBrush" Value="{StaticResource BrPrimary}"/>
<Setter Property="SelectionBrush" Value="{StaticResource BrPrimaryContainer}"/>
<Setter Property="BorderBrush" Value="{StaticResource BrOutline}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="16,0"/>
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="PasswordBox">
<Border x:Name="Bd" CornerRadius="6"
<Border x:Name="Bd" CornerRadius="4"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Padding="{TemplateBinding Padding}">
<ScrollViewer x:Name="PART_ContentHost"/>
<ScrollViewer x:Name="PART_ContentHost" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource BrBlue}"/>
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource BrPrimary}"/>
<Setter TargetName="Bd" Property="BorderThickness" Value="2"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -158,10 +301,13 @@
</Style>
<!-- PROGRESS BAR -->
<!-- =============================================================
PROGRESS BAR (MD3 linear indicator)
============================================================= -->
<Style x:Key="ModernPb" TargetType="ProgressBar">
<Setter Property="Height" Value="4"/>
<Setter Property="Background" Value="{StaticResource BrSurface0}"/>
<Setter Property="Foreground" Value="{StaticResource BrBlue}"/>
<Setter Property="Background" Value="{StaticResource BrSurfaceVariant}"/>
<Setter Property="Foreground" Value="{StaticResource BrPrimary}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Template">
<Setter.Value>
@@ -178,15 +324,17 @@
</Setter>
</Style>
<!-- DATAGRID -->
<!-- =============================================================
DATAGRID (MD3 surface tones)
============================================================= -->
<Style x:Key="ModernDg" TargetType="DataGrid">
<Setter Property="Background" Value="#282A3A"/>
<Setter Property="Foreground" Value="{StaticResource BrText}"/>
<Setter Property="Background" Value="{StaticResource BrSurfaceContainerLow}"/>
<Setter Property="Foreground" Value="{StaticResource BrOnSurface}"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="RowBackground" Value="#282A3A"/>
<Setter Property="AlternatingRowBackground" Value="#2D2F42"/>
<Setter Property="RowBackground" Value="{StaticResource BrSurfaceContainerLow}"/>
<Setter Property="AlternatingRowBackground" Value="{StaticResource BrSurfaceContainer}"/>
<Setter Property="GridLinesVisibility" Value="Horizontal"/>
<Setter Property="HorizontalGridLinesBrush" Value="#37394E"/>
<Setter Property="HorizontalGridLinesBrush" Value="{StaticResource BrOutlineVariant}"/>
<Setter Property="HeadersVisibility" Value="Column"/>
<Setter Property="RowHeaderWidth" Value="0"/>
<Setter Property="AutoGenerateColumns" Value="True"/>
@@ -195,23 +343,25 @@
<Setter Property="CanUserAddRows" Value="False"/>
<Setter Property="CanUserDeleteRows" Value="False"/>
<Setter Property="CanUserResizeRows" Value="False"/>
<Setter Property="FontFamily" Value="Segoe UI"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="14"/>
</Style>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="#23243A"/>
<Setter Property="Foreground" Value="{StaticResource BrBlue}"/>
<Setter Property="FontFamily" Value="Segoe UI Semibold"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="BorderBrush" Value="#37394E"/>
<Setter Property="Background" Value="{StaticResource BrSurfaceContainerHigh}"/>
<Setter Property="Foreground" Value="{StaticResource BrOnSurfaceVariant}"/>
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Padding" Value="16,12"/>
<Setter Property="BorderBrush" Value="{StaticResource BrOutlineVariant}"/>
<Setter Property="BorderThickness" Value="0,0,0,1"/>
</Style>
<Style TargetType="DataGridCell">
<Setter Property="Padding" Value="8,6"/>
<Setter Property="Padding" Value="16,10"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource BrOnSurface}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="DataGridCell">
@@ -223,8 +373,8 @@
</Setter>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="#3C3F58"/>
<Setter Property="Foreground" Value="{StaticResource BrBlue}"/>
<Setter Property="Background" Value="{StaticResource BrSecondaryContainer}"/>
<Setter Property="Foreground" Value="{StaticResource BrOnSecondaryContainer}"/>
</Trigger>
</Style.Triggers>
</Style>
@@ -233,17 +383,95 @@
<Setter Property="Margin" Value="0"/>
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="#3C3F58"/>
<Setter Property="Background" Value="{StaticResource BrSecondaryContainer}"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#1A6750A4"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- Card style for lists / rows -->
<!-- =============================================================
CARD (MD3 Surface Container, 12px radius, Elevation 1)
============================================================= -->
<Style x:Key="StatCard" TargetType="Border">
<Setter Property="Background" Value="{StaticResource BrSurfaceContainer}"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="16"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Effect" Value="{StaticResource Elevation1}"/>
</Style>
<Style x:Key="ApiCard" TargetType="Border">
<Setter Property="Background" Value="{StaticResource BrSurfaceContainerHigh}"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="20,16"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Effect" Value="{StaticResource Elevation1}"/>
<Setter Property="Margin" Value="0,0,0,16"/>
</Style>
<Style x:Key="CardStyle" TargetType="Border">
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Background" Value="#23232A"/>
<Setter Property="Padding" Value="12"/>
<Setter Property="Margin" Value="6"/>
<Setter Property="Background" Value="{StaticResource BrSurfaceContainer}"/>
<Setter Property="CornerRadius" Value="12"/>
<Setter Property="Padding" Value="16"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Effect" Value="{StaticResource Elevation1}"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
<!-- =============================================================
SECTION TITLE (MD3 Title Medium)
============================================================= -->
<Style x:Key="SectionTitle" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Roboto, Segoe UI"/>
<Setter Property="FontSize" Value="16"/>
<Setter Property="FontWeight" Value="Medium"/>
<Setter Property="Foreground" Value="{StaticResource BrOnSurface}"/>
<Setter Property="Margin" Value="0,0,0,8"/>
</Style>
<!-- =============================================================
SCROLLBAR (MD3 slim, OutlineVariant thumb)
============================================================= -->
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="8"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid>
<Track x:Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border CornerRadius="4" Margin="2"
Background="{StaticResource BrOutlineVariant}"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="Height" Value="8"/>
</Trigger>
</Style.Triggers>
</Style>
<!-- =============================================================
LISTBOX (transparent background)
============================================================= -->
<Style TargetType="ListBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource BrOnSurface}"/>
</Style>
</ResourceDictionary>