diff --git a/src/App/Localization/Loc.cs b/src/App/Localization/Loc.cs
new file mode 100644
index 0000000..76aa154
--- /dev/null
+++ b/src/App/Localization/Loc.cs
@@ -0,0 +1,166 @@
+using System.Collections.Generic;
+using System.ComponentModel;
+
+namespace PalladiumWallet.App.Localization;
+
+///
+/// Localizzazione UI (blueprint §14): dizionario chiave → [it, en], con
+/// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua
+/// notifica "Item[]" e tutte le binding si aggiornano.
+///
+public sealed class Loc : INotifyPropertyChanged
+{
+ public static Loc Instance { get; } = new();
+
+ public static readonly string[] Languages = ["it", "en"];
+ public static readonly string[] LanguageNames = ["Italiano", "English"];
+
+ public string Language { get; private set; } = "it";
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public void SetLanguage(string language)
+ {
+ if (Language == language || System.Array.IndexOf(Languages, language) < 0)
+ return;
+ Language = language;
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]"));
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Language)));
+ }
+
+ public string this[string key] =>
+ Strings.TryGetValue(key, out var values)
+ ? values[Language == "en" ? 1 : 0]
+ : key;
+
+ public static string Tr(string key) => Instance[key];
+
+ private static readonly Dictionary Strings = new()
+ {
+ // Menu
+ ["menu.file"] = ["_File", "_File"],
+ ["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…"],
+ ["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…"],
+ ["menu.file.close"] = ["Chiudi wallet", "Close wallet"],
+ ["menu.net"] = ["_Rete", "_Network"],
+ ["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)"],
+ ["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates"],
+ ["menu.settings"] = ["_Impostazioni", "_Settings"],
+ ["settings.unit.short"] = ["Unità", "Unit"],
+
+ // Wizard
+ ["wiz.net"] = ["Rete:", "Network:"],
+ ["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet"],
+ ["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet"],
+ ["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed"],
+ ["wiz.open.title"] = ["Apri il wallet", "Open the wallet"],
+ ["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)"],
+ ["wiz.open.ok"] = ["Apri", "Open"],
+ ["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)"],
+ ["wiz.seed.warning"] = [
+ "Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.",
+ "Write the words on paper, in order. Whoever holds them controls the funds; if you lose them, funds are unrecoverable."],
+ ["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next"],
+ ["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed"],
+ ["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces"],
+ ["wiz.words.title"] = ["Ripristina da seed", "Restore from seed"],
+ ["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)"],
+ ["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase"],
+ ["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip"],
+ ["wiz.password.title"] = ["Password del file wallet", "Wallet file password"],
+ ["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)"],
+ ["wiz.password.create"] = ["Crea il wallet", "Create wallet"],
+ ["wiz.back"] = ["Indietro", "Back"],
+ ["wiz.next"] = ["Avanti", "Next"],
+
+ // Pannello wallet
+ ["wallet.close"] = ["Chiudi wallet", "Close wallet"],
+ ["wallet.server"] = ["Server:", "Server:"],
+ ["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync"],
+ ["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port"],
+ ["wallet.discover"] = ["Cerca altri server", "Discover servers"],
+ ["wallet.resetcert"] = ["Reset cert.", "Reset certs"],
+ ["tab.receive"] = ["Ricevi", "Receive"],
+ ["tab.history"] = ["Storico", "History"],
+ ["tab.addresses"] = ["Indirizzi", "Addresses"],
+ ["tab.send"] = ["Invia", "Send"],
+ ["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:"],
+ ["receive.hint"] = [
+ "Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
+ "Payments received here will appear in the history at the next synchronization."],
+ ["addr.type"] = ["Tipo", "Type"],
+ ["addr.index"] = ["Indice", "Index"],
+ ["addr.address"] = ["Indirizzo", "Address"],
+ ["addr.balance"] = ["Saldo", "Balance"],
+ ["addr.receive"] = ["ricezione", "receive"],
+ ["addr.change"] = ["change", "change"],
+ ["send.to"] = ["Indirizzo destinatario", "Recipient address"],
+ ["send.amount"] = ["Importo", "Amount"],
+ ["send.all"] = ["Invia tutto", "Send all"],
+ ["send.feerate"] = ["fee sat/vB:", "fee sat/vB:"],
+ ["send.prepare"] = ["Prepara transazione", "Prepare transaction"],
+ ["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST"],
+
+ // Stato connessione
+ ["conn.none"] = ["non connesso", "not connected"],
+ ["conn.disconnected"] = ["disconnesso", "disconnected"],
+ ["conn.reconnecting"] = ["riconnessione…", "reconnecting…"],
+ ["conn.error"] = ["errore di connessione", "connection error"],
+ ["conn.certchanged"] = ["certificato cambiato", "certificate changed"],
+ ["conn.connectedto"] = ["connesso a", "connected to"],
+ ["conn.connectingto"] = ["connessione a", "connecting to"],
+
+ // Messaggi di stato principali
+ ["msg.welcome.existing"] = [
+ "Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.",
+ "Found an existing wallet on this network: open it, or create another one."],
+ ["msg.welcome.new"] = [
+ "Benvenuto: crea un nuovo wallet o ripristina da seed.",
+ "Welcome: create a new wallet or restore from seed."],
+ ["msg.open.password"] = [
+ "Inserisci la password del file (lascia vuoto se non impostata).",
+ "Enter the file password (leave empty if not set)."],
+ ["msg.seed.write"] = [
+ "Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.",
+ "Write the 12 words ON PAPER, in order. They are the only backup of the wallet."],
+ ["msg.seed.retype"] = [
+ "Reinserisci le 12 parole per confermare di averle scritte.",
+ "Re-enter the 12 words to confirm you wrote them down."],
+ ["msg.seed.mismatch"] = [
+ "Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
+ "The words do not match: check what you wrote on paper."],
+ ["msg.words.enter"] = [
+ "Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
+ "Enter the BIP39 mnemonic (12 or 24 words separated by spaces)."],
+ ["msg.words.invalid"] = [
+ "Mnemonica non valida (parole o checksum errati): ricontrolla.",
+ "Invalid mnemonic (wrong words or checksum): check again."],
+ ["msg.passphrase.info"] = [
+ "Passphrase BIP39 opzionale: cambia completamente il wallet. Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. Lascia vuoto per non usarla.",
+ "Optional BIP39 passphrase: it derives a completely different wallet. If you use it, note it SEPARATELY from the seed; if lost, funds are unrecoverable. Leave empty to skip."],
+ ["msg.password.info"] = [
+ "Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.",
+ "Encryption password for the wallet file on disk (recommended). It does not replace the seed: it only protects the file."],
+ ["msg.wrongpassword"] = ["Password errata.", "Wrong password."],
+ ["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…"],
+ ["msg.synced"] = ["Sincronizzato", "Synchronized"],
+ ["msg.synced.detail"] = [
+ "transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
+ "SPV-verified transactions. Real-time updates active."],
+ ["msg.height"] = ["altezza", "height"],
+ ["msg.pending"] = ["in attesa di conferma", "pending confirmation"],
+ ["msg.notspendable"] = ["non ancora spendibile", "not yet spendable"],
+ ["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved."],
+ ["msg.certreset"] = [
+ "Certificati SSL azzerati: riprova la connessione.",
+ "SSL certificates cleared: retry the connection."],
+ ["msg.error"] = ["Errore", "Error"],
+
+ // Finestra impostazioni
+ ["settings.title"] = ["Impostazioni", "Settings"],
+ ["settings.language"] = ["Lingua", "Language"],
+ ["settings.unit"] = ["Unità degli importi", "Amount unit"],
+ ["settings.ok"] = ["Salva", "Save"],
+ ["settings.cancel"] = ["Annulla", "Cancel"],
+ };
+}
diff --git a/src/App/ViewModels/MainWindowViewModel.cs b/src/App/ViewModels/MainWindowViewModel.cs
index b036119..1099ee9 100644
--- a/src/App/ViewModels/MainWindowViewModel.cs
+++ b/src/App/ViewModels/MainWindowViewModel.cs
@@ -8,6 +8,7 @@ using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using NBitcoin;
+using PalladiumWallet.App.Localization;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
@@ -42,6 +43,60 @@ public partial class MainWindowViewModel : ViewModelBase
/// Notifica arrivata durante una sync: si risincronizza appena finita.
private bool _resyncRequested;
+ /// Configurazione globale (§8): lingua e unità.
+ private AppConfig _config = AppConfig.Load();
+
+ /// Stringhe localizzate, bindabili da XAML come Loc[chiave].
+ public Loc Loc => Loc.Instance;
+
+ /// Unità corrente per il campo importo del pannello Invia.
+ public string UnitLabel => _config.Unit;
+
+ public AppConfig CurrentConfig => _config;
+
+ // Spunte del menu Impostazioni (ToggleType Radio).
+ public bool IsLangIt => _config.Language == "it";
+ public bool IsLangEn => _config.Language == "en";
+ public bool IsUnitPlm => _config.Unit == "PLM";
+ public bool IsUnitMilli => _config.Unit == "mPLM";
+ public bool IsUnitMicro => _config.Unit == "µPLM";
+ public bool IsUnitSat => _config.Unit == "sat";
+
+ [RelayCommand]
+ private void SetLanguage(string language)
+ {
+ _config.Language = language;
+ ApplySettings(_config);
+ }
+
+ [RelayCommand]
+ private void SetUnit(string unit)
+ {
+ _config.Unit = unit;
+ ApplySettings(_config);
+ }
+
+ /// Applica e persiste le impostazioni (§8).
+ public void ApplySettings(AppConfig config)
+ {
+ _config = config;
+ _config.Save();
+ Loc.SetLanguage(config.Language);
+ OnPropertyChanged(nameof(UnitLabel));
+ OnPropertyChanged(nameof(IsLangIt));
+ OnPropertyChanged(nameof(IsLangEn));
+ OnPropertyChanged(nameof(IsUnitPlm));
+ OnPropertyChanged(nameof(IsUnitMilli));
+ OnPropertyChanged(nameof(IsUnitMicro));
+ OnPropertyChanged(nameof(IsUnitSat));
+ ApplyCache(_doc?.Cache);
+ StatusMessage = Loc.Tr("msg.settings.saved");
+ }
+
+ /// Formatta un importo nell'unità scelta nelle impostazioni.
+ private string Fmt(long sats, bool withLabel = true) =>
+ CoinAmount.FormatIn(sats, _config.Unit, withLabel);
+
/// File in attesa di password (apertura da menu File → Apri).
private string? _pendingOpenPath;
@@ -133,7 +188,7 @@ public partial class MainWindowViewModel : ViewModelBase
private bool useSsl;
[ObservableProperty]
- private string connectionStatus = "non connesso";
+ private string connectionStatus = Loc.Tr("conn.none");
[ObservableProperty]
private bool isConnected;
@@ -198,7 +253,7 @@ public partial class MainWindowViewModel : ViewModelBase
}
else if (_autoReconnect)
{
- ConnectionStatus = "riconnessione…";
+ ConnectionStatus = Loc.Tr("conn.reconnecting");
await ConnectAndSync();
}
}
@@ -216,8 +271,8 @@ public partial class MainWindowViewModel : ViewModelBase
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = "";
WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net));
StatusMessage = WalletFileExists
- ? "Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro."
- : "Benvenuto: crea un nuovo wallet o ripristina da seed.";
+ ? Loc.Tr("msg.welcome.existing")
+ : Loc.Tr("msg.welcome.new");
RefreshServers();
}
@@ -250,7 +305,7 @@ public partial class MainWindowViewModel : ViewModelBase
{
PasswordInput = "";
SetupStep = StepOpen;
- StatusMessage = "Inserisci la password del file (lascia vuoto se non impostata).";
+ StatusMessage = Loc.Tr("msg.open.password");
}
[RelayCommand]
@@ -259,7 +314,7 @@ public partial class MainWindowViewModel : ViewModelBase
_isRestoreFlow = false;
MnemonicInput = Bip39.Generate(MnemonicLength.Twelve).ToString();
SetupStep = StepShowSeed;
- StatusMessage = "Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.";
+ StatusMessage = Loc.Tr("msg.seed.write");
}
[RelayCommand]
@@ -268,7 +323,7 @@ public partial class MainWindowViewModel : ViewModelBase
_isRestoreFlow = true;
MnemonicInput = "";
SetupStep = StepWords;
- StatusMessage = "Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).";
+ StatusMessage = Loc.Tr("msg.words.enter");
}
[RelayCommand]
@@ -276,7 +331,7 @@ public partial class MainWindowViewModel : ViewModelBase
{
ConfirmMnemonicInput = "";
SetupStep = StepConfirmSeed;
- StatusMessage = "Reinserisci le 12 parole per confermare di averle scritte.";
+ StatusMessage = Loc.Tr("msg.seed.retype");
}
[RelayCommand]
@@ -286,7 +341,7 @@ public partial class MainWindowViewModel : ViewModelBase
ConfirmMnemonicInput.Split(' ', StringSplitOptions.RemoveEmptyEntries));
if (!string.Equals(normalized, MnemonicInput, StringComparison.OrdinalIgnoreCase))
{
- StatusMessage = "Le parole non corrispondono: ricontrolla quello che hai scritto su carta.";
+ StatusMessage = Loc.Tr("msg.seed.mismatch");
return;
}
GoToPassphraseStep();
@@ -297,7 +352,7 @@ public partial class MainWindowViewModel : ViewModelBase
{
if (!Bip39.TryParse(MnemonicInput, out _))
{
- StatusMessage = "Mnemonica non valida (parole o checksum errati): ricontrolla.";
+ StatusMessage = Loc.Tr("msg.words.invalid");
return;
}
GoToPassphraseStep();
@@ -307,9 +362,7 @@ public partial class MainWindowViewModel : ViewModelBase
{
PassphraseInput = "";
SetupStep = StepPassphrase;
- StatusMessage = "Passphrase BIP39 opzionale: cambia completamente il wallet. " +
- "Se la usi, annotala A PARTE dal seed; se la perdi i fondi sono irrecuperabili. " +
- "Lascia vuoto per non usarla.";
+ StatusMessage = Loc.Tr("msg.passphrase.info");
}
[RelayCommand]
@@ -317,8 +370,7 @@ public partial class MainWindowViewModel : ViewModelBase
{
PasswordInput = "";
SetupStep = StepPassword;
- StatusMessage = "Password di cifratura del file wallet su disco (consigliata). " +
- "Non sostituisce il seed: serve solo a proteggere il file.";
+ StatusMessage = Loc.Tr("msg.password.info");
}
[RelayCommand]
@@ -373,7 +425,7 @@ public partial class MainWindowViewModel : ViewModelBase
}
catch (WrongPasswordException)
{
- StatusMessage = "Password errata.";
+ StatusMessage = Loc.Tr("msg.wrongpassword");
}
catch (Exception ex)
{
@@ -415,7 +467,7 @@ public partial class MainWindowViewModel : ViewModelBase
if (IsWalletOpen)
CloseWallet();
_pendingOpenPath = null;
- StatusMessage = "Crea un nuovo wallet o ripristina da seed. ATTENZIONE: sovrascrive il wallet di default della rete selezionata.";
+ StatusMessage = Loc.Tr("msg.welcome.new");
}
private void OpenLoaded(WalletDocument doc, HdAccount account, string path, string? password)
@@ -433,7 +485,7 @@ public partial class MainWindowViewModel : ViewModelBase
+ (doc.IsWatchOnly ? " · watch-only" : "");
ApplyCache(doc.Cache);
IsWalletOpen = true;
- StatusMessage = "Wallet aperto: connessione al server…";
+ StatusMessage = Loc.Tr("msg.opened");
// Come Electrum: ci si connette da soli al server selezionato,
// senza aspettare un click.
_ = ConnectAndSync();
@@ -456,20 +508,20 @@ public partial class MainWindowViewModel : ViewModelBase
_account.GetReceiveAddress(i).ToString(), "—", "—"));
return;
}
- BalanceText = CoinAmount.Format(cache.ConfirmedSats, Profile.CoinUnit);
+ BalanceText = Fmt(cache.ConfirmedSats);
// Saldo in attesa: somma delle tx in mempool (può essere negativo per
// gli invii in uscita non ancora confermati). Non è spendibile finché
// non conferma: la TransactionFactory usa solo UTXO confermati.
var pending = cache.History.Where(t => t.Height <= 0).Sum(t => t.DeltaSats);
UnconfirmedText = pending != 0
- ? $"in attesa di conferma: {(pending > 0 ? "+" : "")}{CoinAmount.Format(pending)} — non ancora spendibile"
+ ? $"{Loc.Tr("msg.pending")}: {(pending > 0 ? "+" : "")}{Fmt(pending)} — {Loc.Tr("msg.notspendable")}"
: "";
ReceiveAddress = _account.GetReceiveAddress(cache.NextReceiveIndex).ToString();
History.Clear();
foreach (var tx in cache.History)
History.Add(new HistoryRow(
tx.Height > 0 ? tx.Height.ToString() : "mempool",
- (tx.DeltaSats >= 0 ? "+" : "") + CoinAmount.Format(tx.DeltaSats),
+ (tx.DeltaSats >= 0 ? "+" : "") + Fmt(tx.DeltaSats, withLabel: false),
tx.Txid,
tx.Verified ? "✓ SPV" : "—"));
@@ -479,7 +531,7 @@ public partial class MainWindowViewModel : ViewModelBase
a.IsChange ? "change" : "ricezione",
a.Index,
a.Address,
- a.BalanceSats > 0 ? CoinAmount.Format(a.BalanceSats) : (a.TxCount > 0 ? "0" : "—"),
+ a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
a.TxCount > 0 ? a.TxCount.ToString() : "—"));
}
@@ -502,18 +554,18 @@ public partial class MainWindowViewModel : ViewModelBase
if (_client is null || !_client.IsConnected)
{
var (host, port) = ParseServer();
- ConnectionStatus = $"connessione a {host}:{port}…";
+ ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…";
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
_client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins);
_client.NotificationReceived += OnServerNotification;
_client.Disconnected += _ => Dispatcher.UIThread.Post(() =>
{
IsConnected = false;
- ConnectionStatus = "disconnesso";
+ ConnectionStatus = Loc.Tr("conn.disconnected");
});
IsConnected = true;
_autoReconnect = true;
- ConnectionStatus = $"connesso a {host}:{port}{(UseSsl ? " (TLS)" : "")}";
+ ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {host}:{port}{(UseSsl ? " (TLS)" : "")}";
// Synchronizer per connessione: conserva la cache di tx e
// prove verificate, così le risincronizzazioni sono incrementali.
_synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit);
@@ -541,20 +593,20 @@ public partial class MainWindowViewModel : ViewModelBase
};
WalletStore.Save(_doc, _walletPath!, _password);
ApplyCache(_doc.Cache);
- StatusMessage = $"Sincronizzato: altezza {result.TipHeight}, " +
- $"{result.History.Count} transazioni verificate SPV. Aggiornamento in tempo reale attivo.";
+ StatusMessage = $"{Loc.Tr("msg.synced")}: {Loc.Tr("msg.height")} {result.TipHeight}, " +
+ $"{result.History.Count} {Loc.Tr("msg.synced.detail")}";
} while (_resyncRequested);
}
catch (CertificatePinMismatchException ex)
{
IsConnected = false;
- ConnectionStatus = "certificato cambiato";
+ ConnectionStatus = Loc.Tr("conn.certchanged");
StatusMessage = ex.Message;
}
catch (Exception ex)
{
IsConnected = _client?.IsConnected == true;
- ConnectionStatus = IsConnected ? ConnectionStatus : "errore di connessione";
+ ConnectionStatus = IsConnected ? ConnectionStatus : Loc.Tr("conn.error");
StatusMessage = $"Errore: {ex.Message}";
}
finally
@@ -569,7 +621,7 @@ public partial class MainWindowViewModel : ViewModelBase
{
if (_client is null || !_client.IsConnected)
{
- StatusMessage = "Connettiti a un server prima di cercare i peer.";
+ StatusMessage = Loc.Tr("conn.none") + ".";
return;
}
try
@@ -608,7 +660,7 @@ public partial class MainWindowViewModel : ViewModelBase
private void ResetCertificates()
{
new CertificatePinStore(AppPaths.CertificatePinsPath(Net)).ResetAll();
- StatusMessage = "Certificati SSL azzerati: riprova la connessione.";
+ StatusMessage = Loc.Tr("msg.certreset");
}
[RelayCommand]
@@ -629,7 +681,7 @@ public partial class MainWindowViewModel : ViewModelBase
var destination = BitcoinAddress.Create(SendTo.Trim(), PalladiumNetworks.For(Net));
long amount = 0;
- if (!SendAll && !CoinAmount.TryParseCoins(SendAmount, out amount))
+ if (!SendAll && !CoinAmount.TryParseIn(SendAmount, _config.Unit, out amount))
{
SendPreview = "Importo non valido.";
return;
@@ -645,7 +697,7 @@ public partial class MainWindowViewModel : ViewModelBase
_doc.Cache.NextChangeIndex, SendAll);
SendPreview = $"txid {_pendingSend.Txid[..16]}… · " +
- $"fee {CoinAmount.Format(_pendingSend.Fee.Satoshi, Profile.CoinUnit)} " +
+ $"fee {Fmt(_pendingSend.Fee.Satoshi)} " +
$"({_pendingSend.Transaction.GetVirtualSize()} vB)" +
(_pendingSend.Signed ? "" : " · NON firmata (watch-only)");
HasPendingSend = _pendingSend.Signed;
@@ -695,7 +747,7 @@ public partial class MainWindowViewModel : ViewModelBase
History.Clear();
IsWalletOpen = false;
IsConnected = false;
- ConnectionStatus = "non connesso";
+ ConnectionStatus = Loc.Tr("conn.none");
RefreshSetupState();
}
diff --git a/src/App/Views/MainWindow.axaml b/src/App/Views/MainWindow.axaml
index 917ac35..1db00ad 100644
--- a/src/App/Views/MainWindow.axaml
+++ b/src/App/Views/MainWindow.axaml
@@ -18,16 +18,40 @@
@@ -41,94 +65,95 @@
-
+
-
-
-
-
-
+
-
-
+
+
-
+
+ Text="{Binding Loc[wiz.seed.warning]}"/>
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
-
-
+
@@ -145,7 +170,7 @@
-
@@ -153,7 +178,7 @@
-
-
-
+
-
-
+
-
+
-
+
-
-
+
@@ -213,13 +238,13 @@
-
+
-
-
-
-
+
+
+
+
@@ -239,23 +264,25 @@
-
+
-
-
-
+
-
+
-
-
+
+
-
-
+
diff --git a/src/Core/Storage/AppConfig.cs b/src/Core/Storage/AppConfig.cs
new file mode 100644
index 0000000..1c10025
--- /dev/null
+++ b/src/Core/Storage/AppConfig.cs
@@ -0,0 +1,42 @@
+using System.Text.Json;
+
+namespace PalladiumWallet.Core.Storage;
+
+///
+/// Configurazione globale dell'applicazione (blueprint §8), separata dai file
+/// wallet: lingua, unità di visualizzazione, ecc. Persistita in config.json
+/// nella radice dei dati (vale per tutte le reti).
+///
+public sealed class AppConfig
+{
+ /// Codice lingua UI ("it", "en").
+ public string Language { get; set; } = "it";
+
+ /// Unità di visualizzazione degli importi (vedi ).
+ public string Unit { get; set; } = "PLM";
+
+ private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
+
+ public static AppConfig Load(string? path = null)
+ {
+ path ??= AppPaths.ConfigPath();
+ if (!File.Exists(path))
+ return new AppConfig();
+ try
+ {
+ return JsonSerializer.Deserialize(File.ReadAllText(path)) ?? new AppConfig();
+ }
+ catch (JsonException)
+ {
+ // Config corrotta: si riparte dai default senza bloccare l'avvio.
+ return new AppConfig();
+ }
+ }
+
+ public void Save(string? path = null)
+ {
+ path ??= AppPaths.ConfigPath();
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, JsonSerializer.Serialize(this, JsonOptions));
+ }
+}
diff --git a/src/Core/Wallet/CoinAmount.cs b/src/Core/Wallet/CoinAmount.cs
index 35f7006..598da2d 100644
--- a/src/Core/Wallet/CoinAmount.cs
+++ b/src/Core/Wallet/CoinAmount.cs
@@ -10,10 +10,51 @@ public static class CoinAmount
{
public const long SatsPerCoin = 100_000_000;
+ /// Unità di visualizzazione selezionabili (config §8).
+ public static readonly string[] Units = ["PLM", "mPLM", "µPLM", "sat"];
+
+ /// (satoshi per unità, decimali mostrati) di ciascuna unità.
+ private static (long Factor, int Decimals) Of(string unit) => unit switch
+ {
+ "mPLM" => (100_000, 5),
+ "µPLM" => (100, 2),
+ "sat" => (1, 0),
+ _ => (SatsPerCoin, 8), // PLM
+ };
+
public static string Format(long sats, string unit = "") =>
(sats / (decimal)SatsPerCoin).ToString("0.00000000", CultureInfo.InvariantCulture)
+ (unit.Length > 0 ? " " + unit : "");
+ /// Formatta nell'unità scelta (es. 150000 sat → "1.50000 mPLM").
+ public static string FormatIn(long sats, string unit, bool withLabel = true)
+ {
+ var (factor, decimals) = Of(unit);
+ var value = (sats / (decimal)factor).ToString(
+ decimals == 0 ? "0" : "0." + new string('0', decimals), CultureInfo.InvariantCulture);
+ return withLabel ? $"{value} {unit}" : value;
+ }
+
+ /// Parsa un importo espresso nell'unità scelta in satoshi.
+ public static bool TryParseIn(string text, string unit, out long sats)
+ {
+ sats = 0;
+ var (factor, _) = Of(unit);
+ text = text.Trim().Replace(',', '.');
+ if (!decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var value)
+ || value < 0)
+ return false;
+ try
+ {
+ sats = (long)(value * factor);
+ }
+ catch (OverflowException)
+ {
+ return false;
+ }
+ return true;
+ }
+
/// Parsa un importo in coin (punto o virgola decimale) in satoshi.
public static bool TryParseCoins(string text, out long sats)
{
diff --git a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs
index d17e71c..8e68b13 100644
--- a/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs
+++ b/tests/PalladiumWallet.Tests/Wallet/TransactionFactoryTests.cs
@@ -156,4 +156,53 @@ public class TransactionFactoryTests
Assert.False(CoinAmount.TryParseCoins("abc", out _));
Assert.False(CoinAmount.TryParseCoins("-1", out _));
}
+
+ // 1.5 PLM = 150_000_000 sat, espressi in ciascuna unità (§8).
+ [Theory]
+ [InlineData("PLM", "1.50000000 PLM", "1.5")]
+ [InlineData("mPLM", "1500.00000 mPLM", "1500")]
+ [InlineData("µPLM", "1500000.00 µPLM", "1500000")]
+ [InlineData("sat", "150000000 sat", "150000000")]
+ public void Le_unita_formattano_e_parsano_in_modo_coerente(string unit, string formatted, string input)
+ {
+ const long sats = 150_000_000;
+ Assert.Equal(formatted, CoinAmount.FormatIn(sats, unit));
+ Assert.True(CoinAmount.TryParseIn(input, unit, out var parsed));
+ Assert.Equal(sats, parsed);
+ }
+
+ [Fact]
+ public void La_config_globale_fa_roundtrip_su_file()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"plm-config-{Guid.NewGuid()}.json");
+ try
+ {
+ var config = new PalladiumWallet.Core.Storage.AppConfig { Language = "en", Unit = "sat" };
+ config.Save(path);
+ var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path);
+ Assert.Equal("en", loaded.Language);
+ Assert.Equal("sat", loaded.Unit);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public void Una_config_corrotta_torna_ai_default()
+ {
+ var path = Path.Combine(Path.GetTempPath(), $"plm-config-{Guid.NewGuid()}.json");
+ try
+ {
+ File.WriteAllText(path, "{ rotto ");
+ var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path);
+ Assert.Equal("it", loaded.Language);
+ Assert.Equal("PLM", loaded.Unit);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
}