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
+143 -13
View File
@@ -192,19 +192,25 @@
<!-- 1. Storico -->
<TabItem Header="{Binding Loc[tab.history]}">
<ListBox ItemsSource="{Binding History}" Margin="4">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HistoryRow">
<Grid ColumnDefinitions="90,160,*,70">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<SelectableTextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12"/>
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="Green"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid RowDefinitions="Auto,*" Margin="4">
<TextBlock Grid.Row="0" Text="{Binding Loc[history.hint]}"
Foreground="Gray" FontSize="11" Margin="6,2"/>
<ListBox Grid.Row="1" ItemsSource="{Binding History}"
DoubleTapped="OnHistoryRowDoubleTapped">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HistoryRow">
<Grid ColumnDefinitions="90,160,*,70" Cursor="Hand">
<TextBlock Grid.Column="0" Text="{Binding Conferma}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Importo}" FontFamily="monospace"/>
<TextBlock Grid.Column="2" Text="{Binding Txid}"
FontFamily="monospace" FontSize="12"
TextTrimming="CharacterEllipsis"/>
<TextBlock Grid.Column="3" Text="{Binding Verificata}" Foreground="Green"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<!-- 2. Invia -->
@@ -425,6 +431,130 @@
</Border>
</Border>
<!-- ============ OVERLAY DETTAGLIO TRANSAZIONE ============ -->
<!-- Overlay in-app (come indirizzo): appare subito con lo spinner, i dati
arrivano dal server in background; chiusura istantanea. -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="#99000000"
Tapped="OnTxDetailsOverlayBackdropTapped"
IsVisible="{Binding IsTxDetailsOpen}">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderBrush="Gray" BorderThickness="1" CornerRadius="8"
Width="640" MaxHeight="600" Padding="0"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Panel Margin="24">
<!-- Caricamento -->
<StackPanel IsVisible="{Binding IsTxDetailsLoading}"
HorizontalAlignment="Center" VerticalAlignment="Center" Spacing="14">
<ProgressBar IsIndeterminate="True" Width="220"/>
<TextBlock Text="{Binding Loc[tx.loading]}" Foreground="Gray"
HorizontalAlignment="Center"/>
</StackPanel>
<!-- Contenuto (IsVisible sul contenitore esterno, così resta
legato al VM principale; il DataContext interno è TxDetails) -->
<Grid IsVisible="{Binding !IsTxDetailsLoading}">
<DockPanel DataContext="{Binding TxDetails}">
<TextBlock DockPanel.Dock="Top" Text="{Binding Loc[tx.title]}"
FontSize="18" FontWeight="Bold" Margin="0,0,0,12"/>
<Button DockPanel.Dock="Bottom" Content="{Binding Loc[tx.close]}"
HorizontalAlignment="Right" Margin="0,12,0,0"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).CloseTransactionDetailsCommand}"/>
<ScrollViewer>
<StackPanel Spacing="14">
<Grid ColumnDefinitions="170,*" RowSpacing="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/><RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.status]}"/>
<SelectableTextBlock Grid.Row="0" Grid.Column="1" FontSize="13" TextWrapping="Wrap" Text="{Binding StatusText}"/>
<TextBlock Grid.Row="1" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.date]}"/>
<SelectableTextBlock Grid.Row="1" Grid.Column="1" FontSize="13" Text="{Binding DateText}"/>
<TextBlock Grid.Row="2" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding CounterpartyHeader}"/>
<SelectableTextBlock Grid.Row="2" Grid.Column="1" FontSize="13" FontFamily="monospace" TextWrapping="Wrap" Text="{Binding CounterpartyText}"/>
<TextBlock Grid.Row="3" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding AmountHeader}"/>
<SelectableTextBlock Grid.Row="3" Grid.Column="1" FontSize="13" FontFamily="monospace" Text="{Binding AmountText}"/>
<TextBlock Grid.Row="4" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.fee]}"/>
<SelectableTextBlock Grid.Row="4" Grid.Column="1" FontSize="13" FontFamily="monospace" Text="{Binding FeeText}"/>
<TextBlock Grid.Row="5" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.feerate]}"/>
<SelectableTextBlock Grid.Row="5" Grid.Column="1" FontSize="13" FontFamily="monospace" Text="{Binding FeeRateText}"/>
<TextBlock Grid.Row="6" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.net]}"/>
<SelectableTextBlock Grid.Row="6" Grid.Column="1" FontSize="13" FontFamily="monospace" FontWeight="Bold" Text="{Binding NetText}"/>
<TextBlock Grid.Row="7" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.id]}"/>
<SelectableTextBlock Grid.Row="7" Grid.Column="1" FontSize="12" FontFamily="monospace" TextWrapping="Wrap" Text="{Binding Txid}"/>
<TextBlock Grid.Row="8" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.size.total]}"/>
<SelectableTextBlock Grid.Row="8" Grid.Column="1" FontSize="13" Text="{Binding TotalSizeText}"/>
<TextBlock Grid.Row="9" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.size.virtual]}"/>
<SelectableTextBlock Grid.Row="9" Grid.Column="1" FontSize="13" Text="{Binding VirtualSizeText}"/>
<TextBlock Grid.Row="10" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.rbf]}"/>
<SelectableTextBlock Grid.Row="10" Grid.Column="1" FontSize="13" Text="{Binding RbfText}"/>
<TextBlock Grid.Row="11" Grid.Column="0" Foreground="Gray" FontSize="12" Text="{Binding Loc[tx.verified]}"/>
<SelectableTextBlock Grid.Row="11" Grid.Column="1" FontSize="13" Foreground="Green" Text="{Binding VerifiedText}"/>
</Grid>
<TextBlock Text="{Binding Loc[tx.inputs]}" FontWeight="Bold" Margin="0,4,0,0"/>
<ItemsControl ItemsSource="{Binding Inputs}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TxIoRow">
<Grid ColumnDefinitions="170,*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Position}"
FontFamily="monospace" FontSize="11" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Address}"
FontFamily="monospace" FontSize="11" TextTrimming="CharacterEllipsis"
Foreground="{Binding IsMine, Converter={x:Static vm:MineColorConverter.Instance}}"/>
<TextBlock Grid.Column="2" Text="{Binding Amount}"
FontFamily="monospace" FontSize="11" Margin="8,0,0,0"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding Loc[tx.outputs]}" FontWeight="Bold" Margin="0,4,0,0"/>
<ItemsControl ItemsSource="{Binding Outputs}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TxIoRow">
<Grid ColumnDefinitions="60,*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Position}"
FontFamily="monospace" FontSize="11" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Address}"
FontFamily="monospace" FontSize="11" TextTrimming="CharacterEllipsis"
Foreground="{Binding IsMine, Converter={x:Static vm:MineColorConverter.Instance}}"/>
<TextBlock Grid.Column="2" Text="{Binding Amount}"
FontFamily="monospace" FontSize="11" Margin="8,0,0,0"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</DockPanel>
</Grid>
</Panel>
</Border>
</Border>
<!-- ============ OVERLAY IMPOSTAZIONI SERVER ============ -->
<!-- Stesso pattern dell'overlay indirizzo: apertura/chiusura istantanee.
Accessibile da Impostazioni → Server. -->
+18
View File
@@ -37,6 +37,23 @@ public partial class MainWindow : Window
vm.OpenFromPath(path);
}
private void OnHistoryRowDoubleTapped(object? sender, TappedEventArgs e)
{
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
if (lb.SelectedItem is not HistoryRow row) return;
// Overlay in-app: appare subito con lo spinner, i dati arrivano dal
// server in background. Niente top-level window (lenta da aprire/chiudere).
_ = vm.ShowTransactionDetailsAsync(row.Txid);
}
private void OnTxDetailsOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.CloseTransactionDetailsCommand.Execute(null);
}
private void OnAddressListTapped(object? sender, TappedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm || vm.SelectedAddressRow is not { } row)
@@ -115,6 +132,7 @@ public partial class MainWindow : Window
{
if (e.Key == Key.Escape && DataContext is MainWindowViewModel vm)
{
if (vm.IsTxDetailsOpen) { vm.CloseTransactionDetailsCommand.Execute(null); e.Handled = true; return; }
if (vm.AddressInfo is not null) { vm.AddressInfo = null; e.Handled = true; return; }
if (vm.IsServerSettingsOpen) { vm.IsServerSettingsOpen = false; e.Handled = true; return; }
if (vm.IsSettingsOpen) { vm.IsSettingsOpen = false; e.Handled = true; return; }