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 @@ - + -