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; } = [];
+22
View File
@@ -0,0 +1,22 @@
using System;
using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
namespace PalladiumWallet.App.ViewModels;
/// <summary>
/// bool → pennello: gli indirizzi del wallet (input/output "nostri") sono
/// evidenziati in verde, gli altri usano il colore di testo predefinito.
/// </summary>
public sealed class MineColorConverter : IValueConverter
{
public static readonly MineColorConverter Instance = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is true ? Brushes.MediumSeaGreen : AvaloniaProperty.UnsetValue;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using PalladiumWallet.App.Localization;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.App.ViewModels;
/// <summary>Riga input/output per le tabelle della finestra di dettaglio.</summary>
public sealed record TxIoRow(string Position, string Address, string Amount, bool IsMine);
/// <summary>
/// ViewModel della finestra di dettaglio transazione: prende un
/// <see cref="TransactionDetails"/> (assemblato dal server) e ne ricava tutte
/// le stringhe già formattate e localizzate per la vista. È di sola lettura.
/// </summary>
public sealed class TransactionDetailsViewModel
{
private readonly string _unit;
public Loc Loc { get; }
public TransactionDetailsViewModel(TransactionDetails d, Loc loc, string unit)
{
Loc = loc;
_unit = unit;
Txid = d.Txid;
StatusText = BuildStatus(d, loc);
DateText = d.BlockTime is { } t
? t.ToLocalTime().ToString("dd/MM/yyyy HH:mm")
: loc["tx.mempool"];
var counterparties = d.CounterpartyAddresses;
CounterpartyHeader = d.IsIncoming ? loc["tx.from"] : loc["tx.to"];
CounterpartyText = counterparties.Count > 0
? string.Join(Environment.NewLine, counterparties)
: "—";
// Debito (uscita verso terzi) o Credito (entrata netta sui nostri output).
AmountHeader = d.IsIncoming ? loc["tx.credit"] : loc["tx.debit"];
AmountText = d.IsIncoming
? Signed(d.ReceivedSats)
: Signed(-d.SentToOthersSats);
FeeText = d.FeeSats is { } fee
? (d.IsIncoming ? Abs(fee) : Signed(-fee))
: "—";
NetText = Signed(d.NetSats);
TotalSizeText = $"{d.TotalSize} byte";
VirtualSizeText = $"{d.VirtualSize} byte";
FeeRateText = d.FeeRateSatPerVb is { } r
? r.ToString("0.0", System.Globalization.CultureInfo.InvariantCulture) + " sat/vB"
: "—";
VersionText = d.Version.ToString();
LockTimeText = d.LockTime.ToString();
RbfText = loc[d.RbfSignaled ? "tx.yes" : "tx.no"];
VerifiedText = d.Verified ? "✓ SPV" : "—";
Inputs = new ObservableCollection<TxIoRow>(d.Inputs.Select((i, n) => new TxIoRow(
i.IsCoinbase ? "coinbase" : $"{Shorten(i.PrevTxid)}:{i.PrevIndex}",
i.Address ?? "—",
i.AmountSats is { } a ? CoinAmount.FormatIn(a, unit) : "—",
i.IsMine)));
Outputs = new ObservableCollection<TxIoRow>(d.Outputs.Select(o => new TxIoRow(
$"#{o.Index}",
o.Address ?? $"({o.ScriptType})",
CoinAmount.FormatIn(o.AmountSats, unit),
o.IsMine)));
}
public string Txid { get; }
public string StatusText { get; }
public string DateText { get; }
public string CounterpartyHeader { get; }
public string CounterpartyText { get; }
public string AmountHeader { get; }
public string AmountText { get; }
public string FeeText { get; }
public string NetText { get; }
public string TotalSizeText { get; }
public string VirtualSizeText { get; }
public string FeeRateText { get; }
public string VersionText { get; }
public string LockTimeText { get; }
public string RbfText { get; }
public string VerifiedText { get; }
public ObservableCollection<TxIoRow> Inputs { get; }
public ObservableCollection<TxIoRow> Outputs { get; }
private static string BuildStatus(TransactionDetails d, Loc loc)
{
if (d.Confirmations <= 0)
return loc["tx.status.mempool"];
return $"{d.Confirmations} {loc["tx.status.confirmations"]} ({loc["tx.status.block"]} {d.Height})";
}
private string Signed(long sats)
{
var sign = sats > 0 ? "+" : sats < 0 ? "-" : "";
return sign + CoinAmount.FormatIn(Math.Abs(sats), _unit);
}
private string Abs(long sats) => CoinAmount.FormatIn(Math.Abs(sats), _unit);
private static string Shorten(string txid) =>
txid.Length > 16 ? $"{txid[..8]}…{txid[^4..]}" : txid;
}