feat(app): transaction detail overlay with full on-chain data

Double-clicking a history row opens an in-app overlay (same pattern as
address/server/settings overlays) that shows all available data for a
transaction. The overlay appears immediately with a spinner; data is
fetched from the server in the background and rendered once ready.
Backdrop tap and Esc close it; a pending fetch is cancelled.

New Core type — TransactionInspector (Core/Wallet/):
- FetchAsync() downloads the raw tx, all parent txs (parallel requests,
  one round-trip instead of N sequential), and the block header
- Reconstructs inputs (amounts, addresses, is-mine), outputs, fee,
  virtual size, RBF flag, block timestamp, confirmations

New App types:
- TransactionDetails record: full parsed data (fee, net, I/O lists,
  counterparty addresses, coinbase detection…)
- TransactionDetailsViewModel: pre-formats all strings in the current
  locale and unit (status, date, signed amounts, sat/vB rate…)
- TxIoRow record: one row in the input/output tables
- MineColorConverter: bool → brush (MediumSeaGreen for own addresses)

ViewModel changes:
- ShowTransactionDetailsAsync / CloseTransactionDetailsCommand
- BuildTransactionDetailsAsync (also public for future RBF/CPFP use)
- CancellationTokenSource so opening a new tx cancels the previous fetch
- All server work runs on Task.Run to keep the UI thread free

XAML: history rows get DoubleTapped handler, Hand cursor, hint label,
txid now uses TextTrimming instead of SelectableTextBlock.

i18n: 24 new tx.* / history.hint keys in all 6 languages.
This commit is contained in:
2026-06-12 11:47:23 +02:00
parent 4c7e8696cb
commit 4735490759
7 changed files with 633 additions and 13 deletions
+117
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
@@ -345,6 +346,122 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand]
private void CloseAddressInfo() => AddressInfo = null;
// ---- overlay dettaglio transazione ----
/// <summary>Overlay dettaglio transazione aperto.</summary>
[ObservableProperty]
private bool isTxDetailsOpen;
/// <summary>Caricamento dei dati in corso (mostra lo spinner nell'overlay).</summary>
[ObservableProperty]
private bool isTxDetailsLoading;
/// <summary>Dati della transazione mostrata; null finché non sono pronti.</summary>
[ObservableProperty]
private TransactionDetailsViewModel? txDetails;
private CancellationTokenSource? _txDetailsCts;
/// <summary>
/// Apre l'overlay dettaglio transazione: appare subito con lo spinner e i
/// dati arrivano dal server in background. Overlay in-app (come indirizzo e
/// impostazioni) per apertura/chiusura istantanee, senza una top-level window.
/// </summary>
public async Task ShowTransactionDetailsAsync(string txid)
{
if (_client is null || !_client.IsConnected)
{
StatusMessage = Loc.Tr("tx.needconnection");
return;
}
_txDetailsCts?.Cancel();
_txDetailsCts = new CancellationTokenSource();
var ct = _txDetailsCts.Token;
TxDetails = null;
IsTxDetailsLoading = true;
IsTxDetailsOpen = true;
var details = await BuildTransactionDetailsAsync(txid, ct);
// L'utente potrebbe aver chiuso l'overlay (o aperto un'altra tx) mentre
// caricava: non sovrascrivere lo stato in quel caso.
if (ct.IsCancellationRequested || !IsTxDetailsOpen)
return;
if (details is null)
{
IsTxDetailsOpen = false;
IsTxDetailsLoading = false;
return;
}
TxDetails = details;
IsTxDetailsLoading = false;
}
[RelayCommand]
private void CloseTransactionDetails()
{
_txDetailsCts?.Cancel();
IsTxDetailsOpen = false;
IsTxDetailsLoading = false;
TxDetails = null;
}
/// <summary>
/// Recupera dal server tutti i dati della transazione <paramref name="txid"/>
/// (importi, fee, indirizzi, dimensioni, data) e li impacchetta per la
/// finestra di dettaglio. Restituisce null se non c'è connessione o in errore.
/// </summary>
public async Task<TransactionDetailsViewModel?> BuildTransactionDetailsAsync(
string txid, CancellationToken ct = default)
{
if (_client is null || !_client.IsConnected)
{
StatusMessage = Loc.Tr("tx.needconnection");
return null;
}
if (_doc?.Cache is not { } cache)
return null;
// Snapshot dei dati sull'UI thread, poi tutto il lavoro (round-trip al
// server + parsing delle transazioni) va su un thread pool: altrimenti i
// continuation dei fetch riprendono sull'UI thread e bloccano la finestra
// (spinner fermo, chiusura ritardata).
var client = _client;
var network = PalladiumNetworks.For(Net);
var row = cache.History.FirstOrDefault(t => t.Txid == txid);
var owned = cache.Addresses.Select(a => a.Address).ToHashSet();
var tipHeight = cache.TipHeight;
var height = row?.Height ?? 0;
var delta = row?.DeltaSats ?? 0;
var verified = row?.Verified ?? false;
var transactions = _lastTransactions;
var loc = _loc;
var unit = _config.Unit;
try
{
return await Task.Run(async () =>
{
var details = await TransactionInspector.FetchAsync(
client, network, txid, tipHeight, height, owned, delta, verified, transactions, ct);
return new TransactionDetailsViewModel(details, loc, unit);
}, ct);
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception ex)
{
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
return null;
}
}
// ---- rubrica contatti ----
public ObservableCollection<ContactEntry> Contacts { get; } = [];