12 Commits

Author SHA1 Message Date
davide 58b86ad1af feat(app): QR code for receive address
Adds QRCoder 1.8.0 and generates a PNG QR code in-memory each time
the receive address changes (OnReceiveAddressChanged). The bitmap is
displayed in the Receive tab inside a white-background border with
pixel-perfect scaling (BitmapInterpolationMode=None). Previous bitmap
is disposed to avoid memory leaks.
2026-06-12 10:21:18 +02:00
davide 7f2759b2fc feat(app): multi-wallet chooser, confirm-password, encrypt toggle
Multi-wallet (§8):
- AppPaths.WalletFiles(net) enumerates all *.wallet.json in the wallets
  dir; WizardStartOpen shows a chooser step when more than one is found
- New StepChooseWallet + ChooseWalletCommand; Back from StepOpen returns
  to chooser (or to StepStart when only one wallet)
- WalletFileEntry record for the chooser ItemsControl

Password step (Electrum style):
- ConfirmPasswordInput: password must be typed twice before creating
- EncryptWallet checkbox (default true); when unchecked, password is
  null (plaintext file) with an explicit warning
- Validation: empty password with encryption on → msg.password.required;
  mismatch → msg.password.mismatch
- ConfirmPasswordInput cleared on wallet open and wizard back

AppPaths:
- DefaultDataRoot() is now platform-aware: %APPDATA%\PalladiumWallet on
  Windows, ~/.palladium-wallet on Linux/macOS (matches §8 convention)
- Legacy root fallback removed (no prior releases to migrate from)

i18n: wiz.choose.title, wiz.password.confirm, wiz.password.encrypt,
wiz.password.encrypt.hint, msg.choose.wallet, msg.password.required,
msg.password.mismatch — all 6 languages
2026-06-12 10:13:17 +02:00
davide a8a97f09b7 feat(app): connect to server before wallet open, status in bottom bar
- ConnectAndSync() no longer bails early when no wallet is open:
  connection is established immediately after the wizard completes
  (Electrum-style), sync starts only once an account is available
- KeepAlive tick no longer requires IsWalletOpen so the connection is
  maintained and auto-reconnected at all times
- WalletSynchronizer is lazily created on first sync (not on connect)
  to avoid a null-ref guard on _synchronizer
- Connection status indicator (dot + text) moved from wallet header to
  the status bar (always visible); tapping it still opens the server
  settings overlay
- Menu "Rete" removed: Discover and ResetCerts are inside the server
  settings overlay; redundant top-level menu entry eliminated
- "Server" button in settings overlay is no longer gated on IsWalletOpen
2026-06-12 10:13:04 +02:00
davide f3bf4cf94a feat(app): first-run data location wizard step
On first launch (no data yet, no portable dir, no pointer file),
the wizard now shows a new step-0 screen asking where to store
wallets, config and certificates.

AppPaths changes:
- DefaultDataRoot() → ~/.PalladiumWallet (home, always writable)
- IsDataLocationConfigured() → true when portable / override / pointer
  already written / legacy or default already has data
- ConfigureDataLocation(root) writes a bootstrap pointer file and
  creates the directory
- DataRoot() resolution order: override → portable → pointer → legacy
  (has data) → default

ViewModel: new StepDataLocation step, UseDefaultDataLocationCommand,
ApplyDataLocation(root) (also called from View's folder picker).

View: new wizard panel with description, default-path display, two
buttons (use default / choose folder); folder picker via
StorageProvider.OpenFolderPickerAsync.

Loc: add wiz.data.* keys (6 languages); fix fallback language "it"→"en";
update test assertion accordingly.
2026-06-12 09:25:04 +02:00
davide 87e1c82610 feat(app): connection status indicator opens server settings on tap
The green/red dot + status text in the wallet header is now tappable:
opens the server settings overlay directly. Added Hand cursor and
tooltip (server title) to signal interactivity.
Status text uses SystemAccentColor instead of Gray.
2026-06-12 09:24:57 +02:00
davide 51c87a7dc9 refactor(app): replace nested settings submenu with in-app overlay
The Impostazioni menu now opens a single overlay (same pattern as
address/server overlays) instead of nested OS popup menus, which are
slow to respond under WSLg.

The overlay groups language (RadioButton) and unit (RadioButton) in one
panel, with a button to open the server overlay. Esc and backdrop tap
close it. OpenServerSettings() closes the settings overlay first so the
two overlays never stack.
2026-06-12 09:09:31 +02:00
davide 28cb4ce6ae feat(app): server settings as in-app overlay, split host/port fields
- Moves server configuration from the wallet panel into an overlay
  (Impostazioni → Server), same pattern as the address detail overlay
- Splits the single "host:port" text box into separate Host and Port
  fields; port changes auto-toggle the TLS checkbox when the typed
  port matches a known SSL/TCP port, and vice-versa
- Adds DisconnectAsync() so changing server/TLS reconnects to the new
  endpoint instead of reusing the old socket
- Adds i18n keys (IT/EN/ES/FR/PT/DE) for the new overlay
- Removes the server row from the main wallet panel; connection status
  indicator (green/red dot) is now shown in the wallet header
2026-06-12 09:02:19 +02:00
davide cf6e2d7654 refactor(app): replace AddressInfoWindow with in-app overlay
The address detail panel is now rendered as a modal overlay inside
MainWindow instead of a separate top-level Window, avoiding the
create/destroy cost of a dialog and working better under WSLg.

Backdrop tap and Esc both close the overlay.
2026-06-12 08:22:30 +02:00
davide fe320584eb feat(app): navbar restructure, contacts tab and address info window
- Reorder tabs: Storico / Invia / Ricevi / Indirizzi / Contatti
- Invia: ComboBox to quick-fill recipient from saved contacts
- Contatti: new tab to add/remove contacts (name + address), persisted
  to the wallet file via WalletDocument.Contacts
- Indirizzi: clicking any address (left or right click) opens a modal
  window with derivation path, public key and private key as selectable
  text (no copy button needed — user selects and copies manually);
  AddressRow now carries PubKey, PrivKey, DerivPath pre-computed
- AddressInfoWindow: new Window with SelectableTextBlock per field,
  private key section hidden for watch-only wallets
- Loc: adds tab.contacts, addr.*, contacts.*, send.from.contact keys
  in all 6 languages
2026-06-11 21:39:36 +02:00
davide 6fe31964e1 feat(storage): add contacts list to WalletDocument
Adds StoredContact class and a Contacts list to WalletDocument so the
address book survives app restarts inside the encrypted wallet file.
2026-06-11 21:39:18 +02:00
davide 8ab8bbd8b3 feat(storage): XDG-compliant paths and default language English
AppPaths: use Environment.SpecialFolder.ApplicationData for all platforms
  - Windows  → %APPDATA%\PalladiumWallet  (unchanged)
  - Linux    → ~/.config/PalladiumWallet  (XDG standard, was ~/.palladium-wallet)
  - macOS    → ~/Library/Application Support/PalladiumWallet

AppConfig: change default language from "it" to "en" for new installs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 18:41:47 +02:00
davide 71a604c8a5 feat(i18n): add ES, FR, PT, DE languages and fix live switching
Add Spanish, French, Portuguese, German translations to all UI strings.

Fix language switching in Avalonia compiled bindings: instead of firing
PropertyChanged("Item[]") on a singleton (which Avalonia ignores when the
object reference is unchanged), Loc.SwitchTo() now creates a new Loc
instance for the selected language and replaces both Loc.Instance and the
ViewModel's _loc field. OnPropertyChanged("Loc") then forces Avalonia to
re-evaluate all {Binding Loc[key]} bindings with the new instance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 18:41:31 +02:00
9 changed files with 1226 additions and 277 deletions
+242 -117
View File
@@ -1,166 +1,291 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel;
namespace PalladiumWallet.App.Localization; namespace PalladiumWallet.App.Localization;
/// <summary> /// <summary>
/// Localizzazione UI (blueprint §14): dizionario chiave → [it, en], con /// Localizzazione UI: dizionario chiave → traduzioni per lingua, con
/// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua /// indicizzatore bindabile da XAML ({Binding Loc[chiave]}). Al cambio lingua
/// notifica "Item[]" e tutte le binding si aggiornano. /// il ViewModel sostituisce l'istanza così Avalonia rivaluta tutte le binding.
/// </summary> /// </summary>
public sealed class Loc : INotifyPropertyChanged public sealed class Loc
{ {
public static Loc Instance { get; } = new(); public static Loc Instance { get; private set; } = new();
public static readonly string[] Languages = ["it", "en"]; public static readonly string[] Languages = ["it", "en", "es", "fr", "pt", "de"];
public static readonly string[] LanguageNames = ["Italiano", "English"]; public static readonly string[] LanguageNames = ["Italiano", "English", "Español", "Français", "Português", "Deutsch"];
public string Language { get; private set; } = "it"; public string Language { get; private set; } = "en";
public event PropertyChangedEventHandler? PropertyChanged; private Loc() { }
private Loc(string language) { Language = language; }
public void SetLanguage(string language) /// <summary>
/// Crea una nuova istanza con la lingua specificata e aggiorna il singleton
/// usato da <see cref="Tr"/>. Il ViewModel assegna questa istanza alla
/// propria property Loc così Avalonia vede un riferimento diverso e
/// rivaluta tutte le binding {Binding Loc[chiave]}.
/// </summary>
internal static Loc SwitchTo(string language)
{ {
if (Language == language || System.Array.IndexOf(Languages, language) < 0) if (System.Array.IndexOf(Languages, language) < 0) language = "en";
return; var loc = new Loc(language);
Language = language; Instance = loc;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]")); return loc;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Language)));
} }
public string this[string key] => public string this[string key] =>
Strings.TryGetValue(key, out var values) Strings.TryGetValue(key, out var values)
? values[Language == "en" ? 1 : 0] ? values[System.Math.Max(0, System.Array.IndexOf(Languages, Language))]
: key; : key;
public static string Tr(string key) => Instance[key]; public static string Tr(string key) => Instance[key];
private static readonly Dictionary<string, string[]> Strings = new() private static readonly Dictionary<string, string[]> Strings = new()
{ {
// Menu // Menu it en es fr pt de
["menu.file"] = ["_File", "_File"], ["menu.file"] = ["_File", "_File", "_Archivo", "_Fichier", "_Arquivo", "_Datei"],
["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…"], ["menu.file.new"] = ["Nuovo / ripristina wallet…", "New / restore wallet…", "Nuevo / restaurar wallet…", "Nouveau / restaurer le wallet…", "Novo / restaurar carteira…", "Neu / Wallet wiederherstellen…"],
["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…"], ["menu.file.open"] = ["Apri wallet da file…", "Open wallet from file…", "Abrir wallet desde archivo…", "Ouvrir le wallet depuis un fichier…", "Abrir carteira de arquivo…", "Wallet aus Datei öffnen…"],
["menu.file.close"] = ["Chiudi wallet", "Close wallet"], ["menu.file.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
["menu.net"] = ["_Rete", "_Network"], ["menu.net"] = ["_Rete", "_Network", "_Red", "_Réseau", "_Rede", "_Netzwerk"],
["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)"], ["menu.net.discover"] = ["Cerca altri server (peer)", "Discover servers (peers)", "Buscar otros servidores (peers)", "Rechercher d'autres serveurs (pairs)", "Procurar outros servidores (peers)", "Weitere Server suchen (Peers)"],
["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates"], ["menu.net.resetcerts"] = ["Reset certificati SSL", "Reset SSL certificates", "Restablecer certificados SSL", "Réinitialiser les certificats SSL", "Redefinir certificados SSL", "SSL-Zertifikate zurücksetzen"],
["menu.settings"] = ["_Impostazioni", "_Settings"], ["menu.settings"] = ["_Impostazioni", "_Settings", "_Configuración", "_Paramètres", "_Configurações", "_Einstellungen"],
["settings.unit.short"] = ["Unità", "Unit"], ["settings.unit.short"] = ["Unità", "Unit", "Unidad", "Unité", "Unidade", "Einheit"],
// Wizard // Wizard
["wiz.net"] = ["Rete:", "Network:"], ["wiz.data.title"] = ["Dove salvare i dati", "Where to store data", "Dónde guardar los datos", "Où enregistrer les données", "Onde salvar os dados", "Wo Daten gespeichert werden"],
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet"], ["wiz.data.info"] = [
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet"], "Scegli la cartella in cui salvare wallet, configurazione e certificati. Puoi usare il percorso predefinito o sceglierne uno tuo.",
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed"], "Choose the folder where wallets, configuration and certificates are stored. Use the default path or pick your own.",
["wiz.open.title"] = ["Apri il wallet", "Open the wallet"], "Elige la carpeta donde se guardarán wallets, configuración y certificados. Usa la ruta predeterminada o elige la tuya.",
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)"], "Choisissez le dossier où enregistrer les wallets, la configuration et les certificats. Utilisez le chemin par défaut ou le vôtre.",
["wiz.open.ok"] = ["Apri", "Open"], "Escolha a pasta onde salvar carteiras, configuração e certificados. Use o caminho padrão ou escolha o seu.",
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)"], "Wählen Sie den Ordner für Wallets, Konfiguration und Zertifikate. Nutzen Sie den Standardpfad oder einen eigenen."],
["wiz.seed.warning"] = [ ["wiz.data.default"] = ["Percorso predefinito:", "Default path:", "Ruta predeterminada:", "Chemin par défaut :", "Caminho padrão:", "Standardpfad:"],
["wiz.data.usedefault"] = ["Usa il percorso predefinito", "Use the default path", "Usar la ruta predeterminada", "Utiliser le chemin par défaut", "Usar o caminho padrão", "Standardpfad verwenden"],
["wiz.data.choose"] = ["Scegli una cartella…", "Choose a folder…", "Elegir una carpeta…", "Choisir un dossier…", "Escolher uma pasta…", "Ordner wählen…"],
["wiz.choose.title"] = ["Scegli il wallet da aprire", "Choose the wallet to open", "Elige el wallet a abrir", "Choisissez le wallet à ouvrir", "Escolha a carteira a abrir", "Wallet zum Öffnen wählen"],
["wiz.net"] = ["Rete:", "Network:", "Red:", "Réseau :", "Rede:", "Netzwerk:"],
["wiz.open.btn"] = ["Apri il wallet esistente", "Open existing wallet", "Abrir wallet existente", "Ouvrir le wallet existant", "Abrir carteira existente", "Vorhandenes Wallet öffnen"],
["wiz.new.btn"] = ["Crea un nuovo wallet", "Create a new wallet", "Crear nuevo wallet", "Créer un nouveau wallet", "Criar nova carteira", "Neues Wallet erstellen"],
["wiz.restore.btn"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
["wiz.open.title"] = ["Apri il wallet", "Open the wallet", "Abrir el wallet", "Ouvrir le wallet", "Abrir a carteira", "Wallet öffnen"],
["wiz.open.placeholder"] = ["Password del file (vuoto se non impostata)", "File password (empty if not set)", "Contraseña del archivo (vacío si no establecida)", "Mot de passe du fichier (vide si non défini)", "Senha do arquivo (vazio se não definida)", "Dateipasswort (leer lassen, wenn nicht gesetzt)"],
["wiz.open.ok"] = ["Apri", "Open", "Abrir", "Ouvrir", "Abrir", "Öffnen"],
["wiz.seed.title"] = ["Il tuo seed (12 parole)", "Your seed (12 words)", "Tu semilla (12 palabras)", "Votre graine (12 mots)", "Sua semente (12 palavras)", "Ihr Seed (12 Wörter)"],
["wiz.seed.warning"] = [
"Scrivi le parole su carta, nell'ordine. Chi le possiede controlla i fondi; se le perdi, i fondi sono irrecuperabili.", "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."], "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"], "Escribe las palabras en papel, en orden. Quien las posea controla los fondos; si las pierdes, los fondos son irrecuperables.",
["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed"], "Écrivez les mots sur papier, dans l'ordre. Celui qui les possède contrôle les fonds ; si vous les perdez, les fonds sont irrécupérables.",
["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces"], "Escreva as palavras no papel, em ordem. Quem as possuir controla os fundos; se as perder, os fundos são irrecuperáveis.",
["wiz.words.title"] = ["Ripristina da seed", "Restore from seed"], "Schreiben Sie die Wörter auf Papier, in der richtigen Reihenfolge. Wer sie besitzt, kontrolliert die Gelder; wenn Sie sie verlieren, sind die Gelder unwiederbringlich verloren."],
["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)"], ["wiz.seed.next"] = ["Le ho scritte — Avanti", "I wrote them down — Next", "Las anoté — Siguiente", "Je les ai notés — Suivant", "Eu as anotei — Próximo", "Ich habe sie notiert — Weiter"],
["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase"], ["wiz.confirm.title"] = ["Conferma il seed", "Confirm the seed", "Confirmar la semilla", "Confirmer la graine", "Confirmar a semente", "Seed bestätigen"],
["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip"], ["wiz.confirm.placeholder"] = ["Reinserisci le 12 parole separate da spazi", "Re-enter the 12 words separated by spaces", "Reingresa las 12 palabras separadas por espacios", "Ressaisissez les 12 mots séparés par des espaces", "Reinsira as 12 palavras separadas por espaços", "12 Wörter durch Leerzeichen getrennt erneut eingeben"],
["wiz.password.title"] = ["Password del file wallet", "Wallet file password"], ["wiz.words.title"] = ["Ripristina da seed", "Restore from seed", "Restaurar desde semilla", "Restaurer depuis la graine", "Restaurar da semente", "Aus Seed wiederherstellen"],
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)"], ["wiz.words.placeholder"] = ["Mnemonica BIP39 (12 o 24 parole separate da spazi)", "BIP39 mnemonic (12 or 24 words separated by spaces)", "Mnemónico BIP39 (12 o 24 palabras separadas por espacios)", "Mnémonique BIP39 (12 ou 24 mots séparés par des espaces)", "Mnemônico BIP39 (12 ou 24 palavras separadas por espaços)", "BIP39-Mnemonic (12 oder 24 durch Leerzeichen getrennte Wörter)"],
["wiz.password.create"] = ["Crea il wallet", "Create wallet"], ["wiz.passphrase.title"] = ["Passphrase opzionale", "Optional passphrase", "Frase de contraseña opcional", "Phrase de passe optionnelle", "Frase-senha opcional", "Optionale Passphrase"],
["wiz.back"] = ["Indietro", "Back"], ["wiz.passphrase.placeholder"] = ["Lascia vuoto per non usarla", "Leave empty to skip", "Deja vacío para omitir", "Laisser vide pour ignorer", "Deixe vazio para ignorar", "Leer lassen zum Überspringen"],
["wiz.next"] = ["Avanti", "Next"], ["wiz.password.title"] = ["Password del file wallet", "Wallet file password", "Contraseña del archivo wallet", "Mot de passe du fichier wallet", "Senha do arquivo da carteira", "Wallet-Dateipasswort"],
["wiz.password.placeholder"] = ["Consigliata (vuoto = file in chiaro su disco)", "Recommended (empty = plaintext file on disk)", "Recomendada (vacío = archivo en texto claro en disco)", "Recommandé (vide = fichier en texte clair sur disque)", "Recomendada (vazio = arquivo em texto simples no disco)", "Empfohlen (leer = Klartextdatei auf Disk)"],
["wiz.password.create"] = ["Crea il wallet", "Create wallet", "Crear wallet", "Créer le wallet", "Criar carteira", "Wallet erstellen"],
["wiz.password.confirm"] = ["Ripeti la password", "Repeat the password", "Repite la contraseña", "Répétez le mot de passe", "Repita a senha", "Passwort wiederholen"],
["wiz.password.encrypt"] = ["Cifra il file wallet con la password", "Encrypt the wallet file with the password", "Cifrar el archivo wallet con la contraseña", "Chiffrer le fichier wallet avec le mot de passe", "Criptografar o arquivo da carteira com a senha", "Wallet-Datei mit dem Passwort verschlüsseln"],
["wiz.password.encrypt.hint"] = [
"Attenzione: senza cifratura il seed resta in chiaro sul disco.",
"Warning: without encryption the seed stays in plaintext on disk.",
"Atención: sin cifrado la semilla queda en texto claro en el disco.",
"Attention : sans chiffrement, la graine reste en clair sur le disque.",
"Atenção: sem criptografia a semente fica em texto simples no disco.",
"Achtung: ohne Verschlüsselung bleibt der Seed im Klartext auf der Festplatte."],
["wiz.back"] = ["Indietro", "Back", "Atrás", "Retour", "Voltar", "Zurück"],
["wiz.next"] = ["Avanti", "Next", "Siguiente", "Suivant", "Próximo", "Weiter"],
// Pannello wallet // Pannello wallet
["wallet.close"] = ["Chiudi wallet", "Close wallet"], ["wallet.close"] = ["Chiudi wallet", "Close wallet", "Cerrar wallet", "Fermer le wallet", "Fechar carteira", "Wallet schließen"],
["wallet.server"] = ["Server:", "Server:"], ["wallet.server"] = ["Server:", "Server:", "Servidor:", "Serveur :", "Servidor:", "Server:"],
["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync"], ["wallet.connect"] = ["Connetti e sincronizza", "Connect and sync", "Conectar y sincronizar", "Connecter et synchroniser", "Conectar e sincronizar", "Verbinden und synchronisieren"],
["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port"], ["wallet.manual"] = ["oppure host:porta manuale", "or manual host:port", "o host:puerto manual", "ou hôte:port manuel", "ou host:porta manual", "oder manuell host:port"],
["wallet.discover"] = ["Cerca altri server", "Discover servers"], ["wallet.discover"] = ["Cerca altri server", "Discover servers", "Buscar otros servidores", "Rechercher des serveurs", "Procurar servidores", "Server suchen"],
["wallet.resetcert"] = ["Reset cert.", "Reset certs"], ["wallet.resetcert"] = ["Reset cert.", "Reset certs", "Restablecer cert.", "Réinit. cert.", "Redefinir cert.", "Zert. zurücksetzen"],
["tab.receive"] = ["Ricevi", "Receive"], ["tab.receive"] = ["Ricevi", "Receive", "Recibir", "Recevoir", "Receber", "Empfangen"],
["tab.history"] = ["Storico", "History"], ["tab.history"] = ["Storico", "History", "Historial", "Historique", "Histórico", "Verlauf"],
["tab.addresses"] = ["Indirizzi", "Addresses"], ["tab.addresses"] = ["Indirizzi", "Addresses", "Direcciones", "Adresses", "Endereços", "Adressen"],
["tab.send"] = ["Invia", "Send"], ["tab.send"] = ["Invia", "Send", "Enviar", "Envoyer", "Enviar", "Senden"],
["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:"], ["tab.contacts"] = ["Contatti", "Contacts", "Contactos", "Contacts", "Contatos", "Kontakte"],
["receive.hint"] = [ ["receive.next"] = ["Prossimo indirizzo non usato:", "Next unused address:", "Próxima dirección no usada:", "Prochaine adresse non utilisée :", "Próximo endereço não usado:", "Nächste ungenutzte Adresse:"],
["receive.hint"] = [
"Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.", "Ogni pagamento ricevuto qui comparirà nello storico alla prossima sincronizzazione.",
"Payments received here will appear in the history at the next synchronization."], "Payments received here will appear in the history at the next synchronization.",
["addr.type"] = ["Tipo", "Type"], "Los pagos recibidos aquí aparecerán en el historial en la próxima sincronización.",
["addr.index"] = ["Indice", "Index"], "Les paiements reçus ici apparaîtront dans l'historique à la prochaine synchronisation.",
["addr.address"] = ["Indirizzo", "Address"], "Os pagamentos recebidos aqui aparecerão no histórico na próxima sincronização.",
["addr.balance"] = ["Saldo", "Balance"], "Hier empfangene Zahlungen erscheinen beim nächsten Synchronisieren im Verlauf."],
["addr.receive"] = ["ricezione", "receive"], ["addr.type"] = ["Tipo", "Type", "Tipo", "Type", "Tipo", "Typ"],
["addr.change"] = ["change", "change"], ["addr.index"] = ["Indice", "Index", "Índice", "Index", "Índice", "Index"],
["send.to"] = ["Indirizzo destinatario", "Recipient address"], ["addr.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
["send.amount"] = ["Importo", "Amount"], ["addr.balance"] = ["Saldo", "Balance", "Saldo", "Solde", "Saldo", "Saldo"],
["send.all"] = ["Invia tutto", "Send all"], ["addr.copied"] = ["Indirizzo copiato negli appunti", "Address copied to clipboard", "Dirección copiada al portapapeles", "Adresse copiée dans le presse-papiers", "Endereço copiado para a área de transferência", "Adresse in die Zwischenablage kopiert"],
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:"], ["addr.derivpath"] = ["Percorso di derivazione:", "Derivation path:", "Ruta de derivación:", "Chemin de dérivation :", "Caminho de derivação:", "Ableitungspfad:"],
["send.prepare"] = ["Prepara transazione", "Prepare transaction"], ["addr.pubkey"] = ["Chiave pubblica:", "Public key:", "Clave pública:", "Clé publique :", "Chave pública:", "Öffentlicher Schlüssel:"],
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST"], ["addr.privkey"] = ["Chiave privata (WIF):", "Private key (WIF):", "Clave privada (WIF):", "Clé privée (WIF) :", "Chave privada (WIF):", "Privater Schlüssel (WIF):"],
["addr.show.privkey"] = ["Mostra", "Show", "Mostrar", "Afficher", "Mostrar", "Anzeigen"],
["addr.hide.privkey"] = ["Nascondi", "Hide", "Ocultar", "Masquer", "Ocultar", "Ausblenden"],
["addr.receive"] = ["ricezione", "receive", "recepción", "réception", "recebimento", "Empfang"],
["addr.change"] = ["change", "change", "cambio", "monnaie", "troco", "Wechselgeld"],
["addr.info.title"] = ["Informazioni indirizzo", "Address information", "Información de dirección", "Informations sur l'adresse", "Informações do endereço", "Adressinformationen"],
["addr.close"] = ["Chiudi", "Close", "Cerrar", "Fermer", "Fechar", "Schließen"],
["send.from.contact"] = ["Da contatti:", "From contacts:", "De contactos:", "Depuis les contacts :", "De contatos:", "Aus Kontakten:"],
["send.contact.hint"] = ["seleziona per riempire l'indirizzo", "select to fill address", "selecciona para rellenar la dirección", "sélectionner pour remplir l'adresse", "selecione para preencher o endereço", "auswählen um Adresse einzufügen"],
["send.to"] = ["Indirizzo destinatario", "Recipient address", "Dirección destinataria", "Adresse du destinataire", "Endereço do destinatário", "Empfängeradresse"],
["send.amount"] = ["Importo", "Amount", "Importe", "Montant", "Valor", "Betrag"],
["send.all"] = ["Invia tutto", "Send all", "Enviar todo", "Tout envoyer", "Enviar tudo", "Alles senden"],
["send.feerate"] = ["fee sat/vB:", "fee sat/vB:", "tarifa sat/vB:", "frais sat/vB :", "taxa sat/vB:", "Gebühr sat/vB:"],
["send.prepare"] = ["Prepara transazione", "Prepare transaction", "Preparar transacción", "Préparer la transaction", "Preparar transação", "Transaktion vorbereiten"],
["send.confirm"] = ["CONFERMA E TRASMETTI", "CONFIRM AND BROADCAST", "CONFIRMAR Y TRANSMITIR", "CONFIRMER ET DIFFUSER", "CONFIRMAR E TRANSMITIR", "BESTÄTIGEN UND SENDEN"],
// Stato connessione // Stato connessione
["conn.none"] = ["non connesso", "not connected"], ["conn.none"] = ["non connesso", "not connected", "no conectado", "non connecté", "não conectado", "nicht verbunden"],
["conn.disconnected"] = ["disconnesso", "disconnected"], ["conn.disconnected"] = ["disconnesso", "disconnected", "desconectado", "déconnecté", "desconectado", "getrennt"],
["conn.reconnecting"] = ["riconnessione…", "reconnecting…"], ["conn.reconnecting"] = ["riconnessione…", "reconnecting…", "reconectando…", "reconnexion…", "reconectando…", "Verbindung wird wiederhergestellt…"],
["conn.error"] = ["errore di connessione", "connection error"], ["conn.error"] = ["errore di connessione", "connection error", "error de conexión", "erreur de connexion", "erro de conexão", "Verbindungsfehler"],
["conn.certchanged"] = ["certificato cambiato", "certificate changed"], ["conn.certchanged"] = ["certificato cambiato", "certificate changed", "certificado cambiado", "certificat modifié", "certificado alterado", "Zertifikat geändert"],
["conn.connectedto"] = ["connesso a", "connected to"], ["conn.connectedto"] = ["connesso a", "connected to", "conectado a", "connecté à", "conectado a", "verbunden mit"],
["conn.connectingto"] = ["connessione a", "connecting to"], ["conn.connectingto"] = ["connessione a", "connecting to", "conectando a", "connexion à", "conectando a", "Verbindung zu"],
// Messaggi di stato principali // Messaggi di stato principali
["msg.welcome.existing"] = [ ["msg.welcome.existing"] = [
"Trovato un wallet esistente su questa rete: aprilo, oppure creane un altro.", "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."], "Found an existing wallet on this network: open it, or create another one.",
["msg.welcome.new"] = [ "Se encontró un wallet existente en esta red: ábrelo o crea otro.",
"Un wallet existant a été trouvé sur ce réseau : ouvrez-le ou créez-en un autre.",
"Uma carteira existente foi encontrada nesta rede: abra-a ou crie outra.",
"Ein vorhandenes Wallet wurde in diesem Netzwerk gefunden: öffnen Sie es oder erstellen Sie ein neues."],
["msg.welcome.new"] = [
"Benvenuto: crea un nuovo wallet o ripristina da seed.", "Benvenuto: crea un nuovo wallet o ripristina da seed.",
"Welcome: create a new wallet or restore from seed."], "Welcome: create a new wallet or restore from seed.",
["msg.open.password"] = [ "Bienvenido: crea un nuevo wallet o restaura desde semilla.",
"Bienvenue : créez un nouveau wallet ou restaurez depuis une graine.",
"Bem-vindo: crie uma nova carteira ou restaure da semente.",
"Willkommen: erstellen Sie ein neues Wallet oder stellen Sie es aus einem Seed wieder her."],
["msg.open.password"] = [
"Inserisci la password del file (lascia vuoto se non impostata).", "Inserisci la password del file (lascia vuoto se non impostata).",
"Enter the file password (leave empty if not set)."], "Enter the file password (leave empty if not set).",
["msg.seed.write"] = [ "Ingresa la contraseña del archivo (deja vacío si no establecida).",
"Entrez le mot de passe du fichier (laisser vide si non défini).",
"Digite a senha do arquivo (deixe vazio se não definida).",
"Geben Sie das Dateipasswort ein (leer lassen, wenn nicht gesetzt)."],
["msg.seed.write"] = [
"Scrivi le 12 parole SU CARTA, nell'ordine. Sono l'unico backup del wallet.", "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."], "Write the 12 words ON PAPER, in order. They are the only backup of the wallet.",
["msg.seed.retype"] = [ "Escribe las 12 palabras EN PAPEL, en orden. Son la única copia de seguridad del wallet.",
"Écrivez les 12 mots SUR PAPIER, dans l'ordre. C'est la seule sauvegarde du wallet.",
"Escreva as 12 palavras NO PAPEL, em ordem. São o único backup da carteira.",
"Schreiben Sie die 12 Wörter AUF PAPIER, in der richtigen Reihenfolge. Sie sind die einzige Sicherung des Wallets."],
["msg.seed.retype"] = [
"Reinserisci le 12 parole per confermare di averle scritte.", "Reinserisci le 12 parole per confermare di averle scritte.",
"Re-enter the 12 words to confirm you wrote them down."], "Re-enter the 12 words to confirm you wrote them down.",
["msg.seed.mismatch"] = [ "Reingresa las 12 palabras para confirmar que las has anotado.",
"Ressaisissez les 12 mots pour confirmer que vous les avez notés.",
"Reinsira as 12 palavras para confirmar que as anotou.",
"Geben Sie die 12 Wörter erneut ein, um zu bestätigen, dass Sie sie notiert haben."],
["msg.seed.mismatch"] = [
"Le parole non corrispondono: ricontrolla quello che hai scritto su carta.", "Le parole non corrispondono: ricontrolla quello che hai scritto su carta.",
"The words do not match: check what you wrote on paper."], "The words do not match: check what you wrote on paper.",
["msg.words.enter"] = [ "Las palabras no coinciden: revisa lo que escribiste en papel.",
"Les mots ne correspondent pas : vérifiez ce que vous avez écrit sur papier.",
"As palavras não correspondem: verifique o que escreveu no papel.",
"Die Wörter stimmen nicht überein: überprüfen Sie, was Sie auf Papier geschrieben haben."],
["msg.words.enter"] = [
"Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).", "Inserisci la mnemonica BIP39 (12 o 24 parole separate da spazi).",
"Enter the BIP39 mnemonic (12 or 24 words separated by spaces)."], "Enter the BIP39 mnemonic (12 or 24 words separated by spaces).",
["msg.words.invalid"] = [ "Ingresa el mnemónico BIP39 (12 o 24 palabras separadas por espacios).",
"Entrez le mnémonique BIP39 (12 ou 24 mots séparés par des espaces).",
"Insira o mnemônico BIP39 (12 ou 24 palavras separadas por espaços).",
"Geben Sie die BIP39-Mnemonic ein (12 oder 24 durch Leerzeichen getrennte Wörter)."],
["msg.words.invalid"] = [
"Mnemonica non valida (parole o checksum errati): ricontrolla.", "Mnemonica non valida (parole o checksum errati): ricontrolla.",
"Invalid mnemonic (wrong words or checksum): check again."], "Invalid mnemonic (wrong words or checksum): check again.",
["msg.passphrase.info"] = [ "Mnemónico no válido (palabras o checksum incorrectos): verifica de nuevo.",
"Mnémonique invalide (mots ou checksum incorrects) : vérifiez à nouveau.",
"Mnemônico inválido (palavras ou checksum incorretos): verifique novamente.",
"Ungültige Mnemonic (falsche Wörter oder Prüfsumme): bitte erneut prüfen."],
["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.", "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."], "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"] = [ "Frase de contraseña BIP39 opcional: deriva un wallet completamente diferente. Si la usas, anótala SEPARADA de la semilla; si la pierdes, los fondos son irrecuperables. Deja vacío para omitir.",
"Phrase de passe BIP39 optionnelle : elle dérive un wallet complètement différent. Si vous l'utilisez, notez-la SÉPARÉMENT de la graine ; si vous la perdez, les fonds sont irrécupérables. Laisser vide pour ignorer.",
"Frase-senha BIP39 opcional: deriva uma carteira completamente diferente. Se a usar, anote-a SEPARADAMENTE da semente; se a perder, os fundos são irrecuperáveis. Deixe vazio para ignorar.",
"Optionale BIP39-Passphrase: leitet ein völlig anderes Wallet ab. Falls verwendet, GETRENNT vom Seed notieren; falls verloren, sind die Gelder unwiederbringlich. Leer lassen zum Überspringen."],
["msg.password.info"] = [
"Password di cifratura del file wallet su disco (consigliata). Non sostituisce il seed: serve solo a proteggere il file.", "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."], "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."], "Contraseña de cifrado para el archivo wallet en disco (recomendada). No reemplaza la semilla: solo protege el archivo.",
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server"], "Mot de passe de chiffrement pour le fichier wallet sur disque (recommandé). Ne remplace pas la graine : protège uniquement le fichier.",
["msg.synced"] = ["Sincronizzato", "Synchronized"], "Senha de criptografia para o arquivo da carteira no disco (recomendada). Não substitui a semente: apenas protege o arquivo.",
["msg.synced.detail"] = [ "Verschlüsselungspasswort für die Wallet-Datei auf der Festplatte (empfohlen). Ersetzt nicht den Seed: schützt nur die Datei."],
["msg.choose.wallet"] = ["Più wallet disponibili: scegline uno.", "Multiple wallets available: pick one.", "Varios wallets disponibles: elige uno.", "Plusieurs wallets disponibles : choisissez-en un.", "Várias carteiras disponíveis: escolha uma.", "Mehrere Wallets verfügbar: wählen Sie eines."],
["msg.password.required"] = [
"Inserisci una password per cifrare il wallet (o togli la spunta «Cifra il file wallet»).",
"Enter a password to encrypt the wallet (or uncheck “Encrypt the wallet file”).",
"Ingresa una contraseña para cifrar el wallet (o desmarca «Cifrar el archivo wallet»).",
"Entrez un mot de passe pour chiffrer le wallet (ou décochez « Chiffrer le fichier wallet »).",
"Digite uma senha para criptografar a carteira (ou desmarque «Criptografar o arquivo da carteira»).",
"Geben Sie ein Passwort zum Verschlüsseln ein (oder deaktivieren Sie „Wallet-Datei verschlüsseln“)."],
["msg.password.mismatch"] = [
"Le due password non coincidono.",
"The two passwords do not match.",
"Las dos contraseñas no coinciden.",
"Les deux mots de passe ne correspondent pas.",
"As duas senhas não coincidem.",
"Die beiden Passwörter stimmen nicht überein."],
["msg.wrongpassword"] = ["Password errata.", "Wrong password.", "Contraseña incorrecta.", "Mot de passe incorrect.", "Senha incorreta.", "Falsches Passwort."],
["msg.opened"] = ["Wallet aperto: connessione al server…", "Wallet opened: connecting to server…", "Wallet abierto: conectando al servidor…", "Wallet ouvert : connexion au serveur…", "Carteira aberta: conectando ao servidor…", "Wallet geöffnet: Verbindung zum Server…"],
["msg.synced"] = ["Sincronizzato", "Synchronized", "Sincronizado", "Synchronisé", "Sincronizado", "Synchronisiert"],
["msg.synced.detail"] = [
"transazioni verificate SPV. Aggiornamento in tempo reale attivo.", "transazioni verificate SPV. Aggiornamento in tempo reale attivo.",
"SPV-verified transactions. Real-time updates active."], "SPV-verified transactions. Real-time updates active.",
["msg.height"] = ["altezza", "height"], "transacciones verificadas SPV. Actualizaciones en tiempo real activas.",
["msg.pending"] = ["in attesa di conferma", "pending confirmation"], "transactions vérifiées SPV. Mises à jour en temps réel actives.",
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable"], "transações verificadas SPV. Atualizações em tempo real ativas.",
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved."], "SPV-verifizierte Transaktionen. Echtzeit-Updates aktiv."],
["msg.certreset"] = [ ["msg.height"] = ["altezza", "height", "altura", "hauteur", "altura", "Höhe"],
["msg.pending"] = ["in attesa di conferma", "pending confirmation", "pendiente de confirmación", "en attente de confirmation", "aguardando confirmação", "ausstehende Bestätigung"],
["msg.notspendable"] = ["non ancora spendibile", "not yet spendable", "aún no gastable", "pas encore dépensable", "ainda não gastável", "noch nicht verwendbar"],
["msg.settings.saved"] = ["Impostazioni salvate.", "Settings saved.", "Configuración guardada.", "Paramètres enregistrés.", "Configurações salvas.", "Einstellungen gespeichert."],
["msg.certreset"] = [
"Certificati SSL azzerati: riprova la connessione.", "Certificati SSL azzerati: riprova la connessione.",
"SSL certificates cleared: retry the connection."], "SSL certificates cleared: retry the connection.",
["msg.error"] = ["Errore", "Error"], "Certificados SSL restablecidos: reintenta la conexión.",
"Certificats SSL réinitialisés : réessayez la connexion.",
"Certificados SSL redefinidos: tente novamente a conexão.",
"SSL-Zertifikate zurückgesetzt: Verbindung erneut versuchen."],
["msg.error"] = ["Errore", "Error", "Error", "Erreur", "Erro", "Fehler"],
// Contatti
["contacts.name"] = ["Nome", "Name", "Nombre", "Nom", "Nome", "Name"],
["contacts.address"] = ["Indirizzo", "Address", "Dirección", "Adresse", "Endereço", "Adresse"],
["contacts.name.ph"] = ["Nome contatto", "Contact name", "Nombre del contacto", "Nom du contact", "Nome do contato", "Kontaktname"],
["contacts.address.ph"] = ["Indirizzo blockchain", "Blockchain address", "Dirección blockchain", "Adresse blockchain", "Endereço blockchain", "Blockchain-Adresse"],
["contacts.add"] = ["Aggiungi", "Add", "Agregar", "Ajouter", "Adicionar", "Hinzufügen"],
["contacts.remove"] = ["Rimuovi selezionato", "Remove selected", "Eliminar seleccionado", "Supprimer la sélection", "Remover selecionado", "Auswahl entfernen"],
["contacts.empty"] = ["Nessun contatto salvato.", "No saved contacts.", "No hay contactos guardados.", "Aucun contact enregistré.", "Nenhum contato salvo.", "Keine gespeicherten Kontakte."],
// Finestra impostazioni // Finestra impostazioni
["settings.title"] = ["Impostazioni", "Settings"], ["settings.title"] = ["Impostazioni", "Settings", "Configuración", "Paramètres", "Configurações", "Einstellungen"],
["settings.language"] = ["Lingua", "Language"], ["settings.language"] = ["Lingua", "Language", "Idioma", "Langue", "Idioma", "Sprache"],
["settings.unit"] = ["Unità degli importi", "Amount unit"], ["settings.unit"] = ["Unità degli importi", "Amount unit", "Unidad de importes", "Unité des montants", "Unidade dos valores", "Betrageinheit"],
["settings.ok"] = ["Salva", "Save"], ["settings.ok"] = ["Salva", "Save", "Guardar", "Enregistrer", "Salvar", "Speichern"],
["settings.cancel"] = ["Annulla", "Cancel"], ["settings.cancel"] = ["Annulla", "Cancel", "Cancelar", "Annuler", "Cancelar", "Abbrechen"],
["settings.server"] = ["Server di indicizzazione…", "Indexing server…", "Servidor de indexación…", "Serveur d'indexation…", "Servidor de indexação…", "Indexierungsserver…"],
// Finestra server
["server.title"] = ["Server di indicizzazione", "Indexing server", "Servidor de indexación", "Serveur d'indexation", "Servidor de indexação", "Indexierungsserver"],
["server.host"] = ["Host", "Host", "Host", "Hôte", "Host", "Host"],
["server.port"] = ["Porta", "Port", "Puerto", "Port", "Porta", "Port"],
["server.known"] = ["Server conosciuti (clicca per usarlo):", "Known servers (click to use):", "Servidores conocidos (clic para usar):", "Serveurs connus (cliquez pour utiliser) :", "Servidores conhecidos (clique para usar):", "Bekannte Server (zum Verwenden anklicken):"],
["server.empty"] = ["Nessun server conosciuto. Usa «Cerca altri server» dopo esserti connesso.", "No known servers. Use “Discover servers” after connecting.", "No hay servidores conocidos. Usa «Buscar otros servidores» tras conectar.", "Aucun serveur connu. Utilisez « Rechercher des serveurs » après connexion.", "Nenhum servidor conhecido. Use «Procurar servidores» após conectar.", "Keine bekannten Server. Nutzen Sie „Server suchen“ nach dem Verbinden."],
}; };
} }
+1
View File
@@ -22,6 +22,7 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="QRCoder" Version="1.8.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+406 -33
View File
@@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Threading; using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@@ -15,14 +16,34 @@ using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv; using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage; using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet; using PalladiumWallet.Core.Wallet;
using QRCoder;
namespace PalladiumWallet.App.ViewModels; namespace PalladiumWallet.App.ViewModels;
/// <summary>Riga dello storico transazioni per la vista.</summary> /// <summary>Riga dello storico transazioni per la vista.</summary>
public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata); public sealed record HistoryRow(string Conferma, string Importo, string Txid, string Verificata);
/// <summary>Riga della vista indirizzi (stile Electrum): saldo e uso per indirizzo.</summary> /// <summary>Riga della vista indirizzi con chiavi e derivation path pre-calcolati.</summary>
public sealed record AddressRow(string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx); public sealed record AddressRow(
string Tipo, int Indice, string Indirizzo, string Saldo, string NumTx,
bool IsChange = false, string PubKey = "", string PrivKey = "", string DerivPath = "")
{
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
}
/// <summary>Dati completi di un indirizzo passati alla finestra di dettaglio.</summary>
public sealed record AddressInfo(
Localization.Loc Loc,
string Address, string DerivPath, string PubKey, string PrivKey)
{
public bool HasPrivKey => !string.IsNullOrEmpty(PrivKey);
}
/// <summary>Contatto in rubrica: nome + indirizzo blockchain.</summary>
public sealed record ContactEntry(string Name, string Address);
/// <summary>Voce della lista di scelta wallet: nome file + percorso completo.</summary>
public sealed record WalletFileEntry(string Name, string Path);
/// <summary> /// <summary>
/// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard): /// ViewModel unico dell'applicazione (wizard §15 ridotto + dashboard):
@@ -46,8 +67,10 @@ public partial class MainWindowViewModel : ViewModelBase
/// <summary>Configurazione globale (§8): lingua e unità.</summary> /// <summary>Configurazione globale (§8): lingua e unità.</summary>
private AppConfig _config = AppConfig.Load(); private AppConfig _config = AppConfig.Load();
/// <summary>Stringhe localizzate, bindabili da XAML come Loc[chiave].</summary> /// <summary>Istanza Loc corrente: viene rimpiazzata ad ogni cambio lingua
public Loc Loc => Loc.Instance; /// così Avalonia vede un riferimento diverso e rivaluta le binding {Binding Loc[chiave]}.</summary>
private Loc _loc = Loc.Instance;
public Loc Loc => _loc;
/// <summary>Unità corrente per il campo importo del pannello Invia.</summary> /// <summary>Unità corrente per il campo importo del pannello Invia.</summary>
public string UnitLabel => _config.Unit; public string UnitLabel => _config.Unit;
@@ -57,6 +80,10 @@ public partial class MainWindowViewModel : ViewModelBase
// Spunte del menu Impostazioni (ToggleType Radio). // Spunte del menu Impostazioni (ToggleType Radio).
public bool IsLangIt => _config.Language == "it"; public bool IsLangIt => _config.Language == "it";
public bool IsLangEn => _config.Language == "en"; public bool IsLangEn => _config.Language == "en";
public bool IsLangEs => _config.Language == "es";
public bool IsLangFr => _config.Language == "fr";
public bool IsLangPt => _config.Language == "pt";
public bool IsLangDe => _config.Language == "de";
public bool IsUnitPlm => _config.Unit == "PLM"; public bool IsUnitPlm => _config.Unit == "PLM";
public bool IsUnitMilli => _config.Unit == "mPLM"; public bool IsUnitMilli => _config.Unit == "mPLM";
public bool IsUnitMicro => _config.Unit == "µPLM"; public bool IsUnitMicro => _config.Unit == "µPLM";
@@ -81,10 +108,15 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
_config = config; _config = config;
_config.Save(); _config.Save();
Loc.SetLanguage(config.Language); _loc = Loc.SwitchTo(config.Language);
OnPropertyChanged(nameof(Loc));
OnPropertyChanged(nameof(UnitLabel)); OnPropertyChanged(nameof(UnitLabel));
OnPropertyChanged(nameof(IsLangIt)); OnPropertyChanged(nameof(IsLangIt));
OnPropertyChanged(nameof(IsLangEn)); OnPropertyChanged(nameof(IsLangEn));
OnPropertyChanged(nameof(IsLangEs));
OnPropertyChanged(nameof(IsLangFr));
OnPropertyChanged(nameof(IsLangPt));
OnPropertyChanged(nameof(IsLangDe));
OnPropertyChanged(nameof(IsUnitPlm)); OnPropertyChanged(nameof(IsUnitPlm));
OnPropertyChanged(nameof(IsUnitMilli)); OnPropertyChanged(nameof(IsUnitMilli));
OnPropertyChanged(nameof(IsUnitMicro)); OnPropertyChanged(nameof(IsUnitMicro));
@@ -97,6 +129,18 @@ public partial class MainWindowViewModel : ViewModelBase
private string Fmt(long sats, bool withLabel = true) => private string Fmt(long sats, bool withLabel = true) =>
CoinAmount.FormatIn(sats, _config.Unit, withLabel); CoinAmount.FormatIn(sats, _config.Unit, withLabel);
/// <summary>Chiave privata WIF per un indirizzo; stringa vuota se watch-only.</summary>
private string KeyWif(bool isChange, int index)
{
if (_account is null or { IsWatchOnly: true }) return "";
try
{
return _account.GetExtPrivateKey(isChange, index)
.PrivateKey.GetWif(PalladiumNetworks.For(Net)).ToString();
}
catch { return ""; }
}
/// <summary>File in attesa di password (apertura da menu File → Apri).</summary> /// <summary>File in attesa di password (apertura da menu File → Apri).</summary>
private string? _pendingOpenPath; private string? _pendingOpenPath;
@@ -126,7 +170,9 @@ public partial class MainWindowViewModel : ViewModelBase
// ---- wizard di setup (§15): un passo alla volta ---- // ---- wizard di setup (§15): un passo alla volta ----
public const string StepDataLocation = "data-location";
public const string StepStart = "start"; public const string StepStart = "start";
public const string StepChooseWallet = "choose-wallet";
public const string StepOpen = "open"; public const string StepOpen = "open";
public const string StepShowSeed = "show-seed"; public const string StepShowSeed = "show-seed";
public const string StepConfirmSeed = "confirm-seed"; public const string StepConfirmSeed = "confirm-seed";
@@ -135,7 +181,9 @@ public partial class MainWindowViewModel : ViewModelBase
public const string StepPassword = "password"; public const string StepPassword = "password";
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsStepDataLocation))]
[NotifyPropertyChangedFor(nameof(IsStepStart))] [NotifyPropertyChangedFor(nameof(IsStepStart))]
[NotifyPropertyChangedFor(nameof(IsStepChooseWallet))]
[NotifyPropertyChangedFor(nameof(IsStepOpen))] [NotifyPropertyChangedFor(nameof(IsStepOpen))]
[NotifyPropertyChangedFor(nameof(IsStepShowSeed))] [NotifyPropertyChangedFor(nameof(IsStepShowSeed))]
[NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))] [NotifyPropertyChangedFor(nameof(IsStepConfirmSeed))]
@@ -144,7 +192,9 @@ public partial class MainWindowViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(IsStepPassword))] [NotifyPropertyChangedFor(nameof(IsStepPassword))]
private string setupStep = StepStart; private string setupStep = StepStart;
public bool IsStepDataLocation => SetupStep == StepDataLocation;
public bool IsStepStart => SetupStep == StepStart; public bool IsStepStart => SetupStep == StepStart;
public bool IsStepChooseWallet => SetupStep == StepChooseWallet;
public bool IsStepOpen => SetupStep == StepOpen; public bool IsStepOpen => SetupStep == StepOpen;
public bool IsStepShowSeed => SetupStep == StepShowSeed; public bool IsStepShowSeed => SetupStep == StepShowSeed;
public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed; public bool IsStepConfirmSeed => SetupStep == StepConfirmSeed;
@@ -152,6 +202,9 @@ public partial class MainWindowViewModel : ViewModelBase
public bool IsStepPassphrase => SetupStep == StepPassphrase; public bool IsStepPassphrase => SetupStep == StepPassphrase;
public bool IsStepPassword => SetupStep == StepPassword; public bool IsStepPassword => SetupStep == StepPassword;
/// <summary>Percorso dati predefinito, mostrato al primo avvio.</summary>
public string DefaultDataPath => AppPaths.DefaultDataRoot();
/// <summary>True quando il flusso è "ripristina" (parole inserite dall'utente).</summary> /// <summary>True quando il flusso è "ripristina" (parole inserite dall'utente).</summary>
private bool _isRestoreFlow; private bool _isRestoreFlow;
@@ -167,6 +220,14 @@ public partial class MainWindowViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string passwordInput = ""; private string passwordInput = "";
/// <summary>Conferma password alla creazione (stile Electrum: digitata due volte).</summary>
[ObservableProperty]
private string confirmPasswordInput = "";
/// <summary>Se true il file wallet viene cifrato con la password (default, stile Electrum).</summary>
[ObservableProperty]
private bool encryptWallet = true;
// ---- pannello wallet ---- // ---- pannello wallet ----
[ObservableProperty] [ObservableProperty]
@@ -181,11 +242,68 @@ public partial class MainWindowViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
private string receiveAddress = ""; private string receiveAddress = "";
/// <summary>QR code dell'indirizzo di ricezione corrente (PNG in-memory).</summary>
[ObservableProperty] [ObservableProperty]
private string serverInput = ""; private Bitmap? receiveQr;
// Rigenera il QR ogni volta che l'indirizzo di ricezione cambia.
partial void OnReceiveAddressChanged(string value)
{
var previous = ReceiveQr;
ReceiveQr = string.IsNullOrEmpty(value) ? null : GenerateQr(value);
previous?.Dispose();
}
private static Bitmap? GenerateQr(string text)
{
try
{
using var generator = new QRCodeGenerator();
using var data = generator.CreateQrCode(text, QRCodeGenerator.ECCLevel.M);
var png = new PngByteQRCode(data).GetGraphic(8);
return new Bitmap(new MemoryStream(png));
}
catch
{
return null;
}
}
[ObservableProperty] [ObservableProperty]
private bool useSsl; private string serverHost = "";
[ObservableProperty]
private string serverPort = "";
/// <summary>Si predilige TLS: la connessione automatica all'apertura del
/// wallet usa la porta SSL del server. L'utente può disattivarlo.</summary>
[ObservableProperty]
private bool useSsl = true;
/// <summary>Overlay impostazioni server: aperto dall'overlay Impostazioni.</summary>
[ObservableProperty]
private bool isServerSettingsOpen;
[RelayCommand]
private void OpenServerSettings()
{
IsSettingsOpen = false;
IsServerSettingsOpen = true;
}
[RelayCommand]
private void CloseServerSettings() => IsServerSettingsOpen = false;
/// <summary>Overlay impostazioni (lingua, unità, server): in-app per evitare
/// i popup di menu annidati, lenti su WSLg.</summary>
[ObservableProperty]
private bool isSettingsOpen;
[RelayCommand]
private void OpenSettings() => IsSettingsOpen = true;
[RelayCommand]
private void CloseSettings() => IsSettingsOpen = false;
[ObservableProperty] [ObservableProperty]
private string connectionStatus = Loc.Tr("conn.none"); private string connectionStatus = Loc.Tr("conn.none");
@@ -205,6 +323,87 @@ public partial class MainWindowViewModel : ViewModelBase
public ObservableCollection<AddressRow> Addresses { get; } = []; public ObservableCollection<AddressRow> Addresses { get; } = [];
/// <summary>Wallet disponibili nella cartella della rete, per la schermata di scelta.</summary>
public ObservableCollection<WalletFileEntry> WalletList { get; } = [];
// ---- tab indirizzi ----
[ObservableProperty]
private AddressRow? selectedAddressRow;
/// <summary>Dettaglio indirizzo mostrato nell'overlay in-app; null = nascosto.</summary>
[ObservableProperty]
private AddressInfo? addressInfo;
/// <summary>Apre l'overlay con i dati dell'indirizzo passato.</summary>
public void ShowAddressInfo(AddressRow row) =>
AddressInfo = new AddressInfo(Loc, row.Indirizzo, row.DerivPath, row.PubKey, row.PrivKey);
[RelayCommand]
private void CloseAddressInfo() => AddressInfo = null;
// ---- rubrica contatti ----
public ObservableCollection<ContactEntry> Contacts { get; } = [];
[ObservableProperty]
private ContactEntry? selectedContactInList;
/// <summary>Contatto selezionato nella ComboBox del pannello Invia: riempie SendTo.</summary>
[ObservableProperty]
private ContactEntry? sendToContact;
partial void OnSendToContactChanged(ContactEntry? value)
{
if (value is not null)
SendTo = value.Address;
}
[ObservableProperty]
private string newContactName = "";
[ObservableProperty]
private string newContactAddress = "";
[RelayCommand]
private void AddContact()
{
var name = NewContactName.Trim();
var addr = NewContactAddress.Trim();
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(addr)) return;
Contacts.Add(new ContactEntry(name, addr));
NewContactName = NewContactAddress = "";
PersistContacts();
}
[RelayCommand]
private void RemoveSelectedContact()
{
if (SelectedContactInList is { } c)
{
Contacts.Remove(c);
SelectedContactInList = null;
PersistContacts();
}
}
private void PersistContacts()
{
if (_doc is null || _walletPath is null) return;
_doc.Contacts = Contacts
.Select(c => new PalladiumWallet.Core.Storage.StoredContact { Name = c.Name, Address = c.Address })
.ToList();
WalletStore.Save(_doc, _walletPath, _password);
}
private void LoadContacts()
{
Contacts.Clear();
if (_doc is null) return;
foreach (var c in _doc.Contacts)
Contacts.Add(new ContactEntry(c.Name, c.Address));
}
// ---- pannello invia ---- // ---- pannello invia ----
[ObservableProperty] [ObservableProperty]
@@ -227,7 +426,13 @@ public partial class MainWindowViewModel : ViewModelBase
public MainWindowViewModel() public MainWindowViewModel()
{ {
RefreshSetupState(); _loc = Loc.SwitchTo(_config.Language);
// Primo avvio: se la posizione dei dati non è ancora determinata,
// chiediamola prima di tutto il resto del wizard.
if (!AppPaths.IsDataLocationConfigured())
SetupStep = StepDataLocation;
else
RefreshSetupState();
// Aggiornamenti continui (§9): ping periodico per tenere viva la // Aggiornamenti continui (§9): ping periodico per tenere viva la
// connessione e accorgersi subito della caduta; se cade, riconnette // connessione e accorgersi subito della caduta; se cade, riconnette
// e risincronizza da solo. Le notifiche push restano la via principale. // e risincronizza da solo. Le notifiche push restano la via principale.
@@ -238,7 +443,9 @@ public partial class MainWindowViewModel : ViewModelBase
private async Task KeepAliveTickAsync() private async Task KeepAliveTickAsync()
{ {
if (!IsWalletOpen || IsSyncing) // Vale anche senza wallet aperto: se l'utente si è connesso dalle
// impostazioni server, la connessione va mantenuta e riconnessa.
if (IsSyncing)
return; return;
if (_client is { IsConnected: true }) if (_client is { IsConnected: true })
{ {
@@ -265,15 +472,46 @@ public partial class MainWindowViewModel : ViewModelBase
private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net)); private ServerRegistry Registry => new(Profile, AppPaths.ServersPath(Net));
/// <summary>Primo avvio: usa il percorso dati predefinito di piattaforma.</summary>
[RelayCommand]
private void UseDefaultDataLocation() => ApplyDataLocation(AppPaths.DefaultDataRoot());
/// <summary>
/// Memorizza la radice dati scelta (default o personalizzata), ricarica la
/// configurazione dalla nuova posizione e prosegue col wizard. Chiamata anche
/// dalla View dopo la scelta di una cartella.
/// </summary>
public void ApplyDataLocation(string root)
{
try
{
AppPaths.ConfigureDataLocation(root);
}
catch (Exception ex)
{
StatusMessage = $"{Loc.Tr("msg.error")}: {ex.Message}";
return;
}
// La config globale vive nella radice dati: ricaricala da lì.
_config = AppConfig.Load();
_loc = Loc.SwitchTo(_config.Language);
OnPropertyChanged(nameof(Loc));
OnPropertyChanged(nameof(UnitLabel));
RefreshSetupState();
}
private void RefreshSetupState() private void RefreshSetupState()
{ {
SetupStep = StepStart; SetupStep = StepStart;
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ""; MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = "";
WalletFileExists = WalletStore.Exists(AppPaths.DefaultWalletPath(Net)); WalletFileExists = AppPaths.WalletFiles(Net).Count > 0;
StatusMessage = WalletFileExists StatusMessage = WalletFileExists
? Loc.Tr("msg.welcome.existing") ? Loc.Tr("msg.welcome.existing")
: Loc.Tr("msg.welcome.new"); : Loc.Tr("msg.welcome.new");
RefreshServers(); RefreshServers();
// Come Electrum: ci si connette al server già durante il setup, senza
// aspettare l'apertura di un wallet (la sync parte poi all'apertura).
_ = ConnectAndSync();
} }
private void RefreshServers() private void RefreshServers()
@@ -283,19 +521,59 @@ public partial class MainWindowViewModel : ViewModelBase
KnownServers.Add(server); KnownServers.Add(server);
SelectedKnownServer = KnownServers.FirstOrDefault(); SelectedKnownServer = KnownServers.FirstOrDefault();
if (SelectedKnownServer is null) if (SelectedKnownServer is null)
ServerInput = $"127.0.0.1:{Profile.DefaultTcpPort}"; {
ServerHost = "127.0.0.1";
ServerPort = (UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString();
}
} }
/// <summary>Evita la ricorsione tra OnUseSslChanged e OnServerPortChanged
/// mentre tengono coerenti porta e flag TLS.</summary>
private bool _syncingServerFields;
partial void OnSelectedKnownServerChanged(KnownServer? value) partial void OnSelectedKnownServerChanged(KnownServer? value)
{ {
if (value is not null) if (value is null)
ServerInput = $"{value.Host}:{value.PortFor(UseSsl)}"; return;
_syncingServerFields = true;
ServerHost = value.Host;
ServerPort = value.PortFor(UseSsl).ToString();
_syncingServerFields = false;
} }
partial void OnUseSslChanged(bool value) partial void OnUseSslChanged(bool value)
{ {
if (SelectedKnownServer is { } server) if (_syncingServerFields)
ServerInput = $"{server.Host}:{server.PortFor(value)}"; return;
// Attivare/disattivare TLS allinea la porta: porta SSL del server
// selezionato (o default di profilo) quando TLS è on, porta TCP quando off.
_syncingServerFields = true;
ServerPort = SelectedKnownServer is { } server
? server.PortFor(value).ToString()
: (value ? Profile.DefaultSslPort : Profile.DefaultTcpPort).ToString();
_syncingServerFields = false;
}
partial void OnServerPortChanged(string value)
{
if (_syncingServerFields)
return;
if (!int.TryParse(value.Trim(), out var port))
return;
// Scegliere una porta nota allinea il flag TLS, così non si tenta mai
// una connessione in chiaro sulla porta SSL (causa di "connection error").
bool? wantSsl =
SelectedKnownServer is { } s && port == s.SslPort ? true :
SelectedKnownServer is { } t && port == t.TcpPort ? false :
port == Profile.DefaultSslPort ? true :
port == Profile.DefaultTcpPort ? false :
null;
if (wantSsl is bool b && b != UseSsl)
{
_syncingServerFields = true;
UseSsl = b;
_syncingServerFields = false;
}
} }
// ---------- comandi del wizard (§15): un passo alla volta ---------- // ---------- comandi del wizard (§15): un passo alla volta ----------
@@ -303,6 +581,31 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void WizardStartOpen() private void WizardStartOpen()
{ {
// Con più wallet nella cartella della rete si sceglie quale aprire;
// con uno solo si va dritti alla password (multi-wallet §8).
var files = AppPaths.WalletFiles(Net);
if (files.Count > 1)
{
WalletList.Clear();
foreach (var path in files)
WalletList.Add(new WalletFileEntry(Path.GetFileName(path), path));
SetupStep = StepChooseWallet;
StatusMessage = Loc.Tr("msg.choose.wallet");
return;
}
_pendingOpenPath = files.Count == 1 ? files[0] : AppPaths.DefaultWalletPath(Net);
PasswordInput = "";
SetupStep = StepOpen;
StatusMessage = Loc.Tr("msg.open.password");
}
/// <summary>Sceglie quale wallet aprire dalla lista e passa alla password.</summary>
[RelayCommand]
private void ChooseWallet(WalletFileEntry? entry)
{
if (entry is null)
return;
_pendingOpenPath = entry.Path;
PasswordInput = ""; PasswordInput = "";
SetupStep = StepOpen; SetupStep = StepOpen;
StatusMessage = Loc.Tr("msg.open.password"); StatusMessage = Loc.Tr("msg.open.password");
@@ -368,7 +671,8 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void WizardNextFromPassphrase() private void WizardNextFromPassphrase()
{ {
PasswordInput = ""; PasswordInput = ConfirmPasswordInput = "";
EncryptWallet = true;
SetupStep = StepPassword; SetupStep = StepPassword;
StatusMessage = Loc.Tr("msg.password.info"); StatusMessage = Loc.Tr("msg.password.info");
} }
@@ -378,7 +682,8 @@ public partial class MainWindowViewModel : ViewModelBase
{ {
SetupStep = SetupStep switch SetupStep = SetupStep switch
{ {
StepOpen or StepShowSeed or StepWords => StepStart, StepOpen => WalletList.Count > 1 ? StepChooseWallet : StepStart,
StepChooseWallet or StepShowSeed or StepWords => StepStart,
StepConfirmSeed => StepShowSeed, StepConfirmSeed => StepShowSeed,
StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed, StepPassphrase => _isRestoreFlow ? StepWords : StepConfirmSeed,
StepPassword => StepPassphrase, StepPassword => StepPassphrase,
@@ -391,6 +696,29 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private void CreateOrRestore() private void CreateOrRestore()
{ {
// Scelta cifratura stile Electrum: con cifratura attiva serve una
// password non vuota, digitata due volte e coincidente. Solo se la
// cifratura è disattivata il file resta in chiaro (avviso esplicito).
string? password;
if (EncryptWallet)
{
if (string.IsNullOrEmpty(PasswordInput))
{
StatusMessage = Loc.Tr("msg.password.required");
return;
}
if (PasswordInput != ConfirmPasswordInput)
{
StatusMessage = Loc.Tr("msg.password.mismatch");
return;
}
password = PasswordInput;
}
else
{
password = null;
}
try try
{ {
var (doc, account) = WalletLoader.NewFromMnemonic( var (doc, account) = WalletLoader.NewFromMnemonic(
@@ -402,7 +730,6 @@ public partial class MainWindowViewModel : ViewModelBase
var path = AppPaths.DefaultWalletPath(Net); var path = AppPaths.DefaultWalletPath(Net);
for (var n = 2; WalletStore.Exists(path); n++) for (var n = 2; WalletStore.Exists(path); n++)
path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json"); path = Path.Combine(AppPaths.WalletsDir(Net), $"wallet-{n}.wallet.json");
var password = string.IsNullOrEmpty(PasswordInput) ? null : PasswordInput;
WalletStore.Save(doc, path, password); WalletStore.Save(doc, path, password);
OpenLoaded(doc, account, path, password); OpenLoaded(doc, account, path, password);
} }
@@ -478,11 +805,12 @@ public partial class MainWindowViewModel : ViewModelBase
_account = account; _account = account;
_walletPath = path; _walletPath = path;
_password = password; _password = password;
MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ""; MnemonicInput = ConfirmMnemonicInput = PassphraseInput = PasswordInput = ConfirmPasswordInput = "";
SetupStep = StepStart; SetupStep = StepStart;
NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}" NetworkInfo = $"{doc.Network} · {doc.ScriptKind} · m/{doc.AccountPath}"
+ (doc.IsWatchOnly ? " · watch-only" : ""); + (doc.IsWatchOnly ? " · watch-only" : "");
LoadContacts();
ApplyCache(doc.Cache); ApplyCache(doc.Cache);
IsWalletOpen = true; IsWalletOpen = true;
StatusMessage = Loc.Tr("msg.opened"); StatusMessage = Loc.Tr("msg.opened");
@@ -504,8 +832,12 @@ public partial class MainWindowViewModel : ViewModelBase
// Prima della sincronizzazione si mostrano i primi indirizzi derivati. // Prima della sincronizzazione si mostrano i primi indirizzi derivati.
Addresses.Clear(); Addresses.Clear();
for (var i = 0; i < 10; i++) for (var i = 0; i < 10; i++)
Addresses.Add(new AddressRow("ricezione", i, Addresses.Add(new AddressRow(_loc["addr.receive"], i,
_account.GetReceiveAddress(i).ToString(), "—", "—")); _account.GetReceiveAddress(i).ToString(), "—", "—",
false,
_account.GetPublicKey(false, i).ToHex(),
KeyWif(false, i),
$"m/{_doc!.AccountPath}/0/{i}"));
return; return;
} }
BalanceText = Fmt(cache.ConfirmedSats); BalanceText = Fmt(cache.ConfirmedSats);
@@ -528,11 +860,15 @@ public partial class MainWindowViewModel : ViewModelBase
Addresses.Clear(); Addresses.Clear();
foreach (var a in cache.Addresses) foreach (var a in cache.Addresses)
Addresses.Add(new AddressRow( Addresses.Add(new AddressRow(
a.IsChange ? "change" : "ricezione", a.IsChange ? _loc["addr.change"] : _loc["addr.receive"],
a.Index, a.Index,
a.Address, a.Address,
a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"), a.BalanceSats > 0 ? Fmt(a.BalanceSats, withLabel: false) : (a.TxCount > 0 ? "0" : "—"),
a.TxCount > 0 ? a.TxCount.ToString() : "—")); a.TxCount > 0 ? a.TxCount.ToString() : "—",
a.IsChange,
_account.GetPublicKey(a.IsChange, a.Index).ToHex(),
KeyWif(a.IsChange, a.Index),
$"m/{_doc!.AccountPath}/{(a.IsChange ? 1 : 0)}/{a.Index}"));
} }
// ---------- comandi wallet ---------- // ---------- comandi wallet ----------
@@ -540,8 +876,6 @@ public partial class MainWindowViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
private async Task ConnectAndSync() private async Task ConnectAndSync()
{ {
if (_account is null || _doc is null)
return;
if (IsSyncing) if (IsSyncing)
{ {
_resyncRequested = true; _resyncRequested = true;
@@ -551,9 +885,19 @@ public partial class MainWindowViewModel : ViewModelBase
StatusMessage = ""; StatusMessage = "";
try try
{ {
var (host, port) = ParseServer();
// Se l'endpoint richiesto (host/porta/TLS) è diverso da quello della
// connessione attiva, la chiudo: l'utente ha cambiato server o ha
// attivato TLS e si aspetta che la nuova scelta abbia effetto.
if (_client is { } current &&
(current.Host != host || current.Port != port || current.UseSsl != UseSsl))
{
await DisconnectAsync();
}
if (_client is null || !_client.IsConnected) if (_client is null || !_client.IsConnected)
{ {
var (host, port) = ParseServer();
ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…"; ConnectionStatus = $"{Loc.Tr("conn.connectingto")} {host}:{port}…";
var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net)); var pins = new CertificatePinStore(AppPaths.CertificatePinsPath(Net));
_client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins); _client = await ElectrumClient.ConnectAsync(host, port, UseSsl, pins);
@@ -566,8 +910,18 @@ public partial class MainWindowViewModel : ViewModelBase
IsConnected = true; IsConnected = true;
_autoReconnect = true; _autoReconnect = true;
ConnectionStatus = $"{Loc.Tr("conn.connectedto")} {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.
// Senza un wallet aperto ci si limita a stabilire/verificare la
// connessione: la sincronizzazione richiede un account.
if (_account is null || _doc is null)
return;
// Synchronizer legato all'account corrente, creato alla prima sync
// dopo la connessione: conserva la cache di tx e prove verificate,
// così le risincronizzazioni sono incrementali.
if (_synchronizer is null)
{
_synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit); _synchronizer = new WalletSynchronizer(_account, _client, _doc.GapLimit);
_synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg); _synchronizer.Progress += msg => Dispatcher.UIThread.Post(() => StatusMessage = msg);
} }
@@ -577,7 +931,7 @@ public partial class MainWindowViewModel : ViewModelBase
do do
{ {
_resyncRequested = false; _resyncRequested = false;
var result = await _synchronizer!.SyncOnceAsync(); var result = await _synchronizer.SyncOnceAsync();
_lastTransactions = result.Transactions; _lastTransactions = result.Transactions;
_doc.Cache = new SyncCache _doc.Cache = new SyncCache
@@ -731,6 +1085,22 @@ public partial class MainWindowViewModel : ViewModelBase
} }
} }
/// <summary>
/// Chiude la connessione corrente lasciando il wallet aperto: usata quando
/// l'utente cambia server o attiva TLS e serve riconnettersi al nuovo
/// endpoint. Attende la chiusura del socket prima di tornare.
/// </summary>
private async Task DisconnectAsync()
{
if (_client is { } client)
{
_client = null;
_synchronizer = null;
try { await client.DisposeAsync(); } catch { /* in chiusura */ }
}
IsConnected = false;
}
[RelayCommand] [RelayCommand]
private void CloseWallet() private void CloseWallet()
{ {
@@ -745,6 +1115,10 @@ public partial class MainWindowViewModel : ViewModelBase
_pendingSend = null; _pendingSend = null;
HasPendingSend = false; HasPendingSend = false;
History.Clear(); History.Clear();
Contacts.Clear();
SelectedContactInList = null;
SendToContact = null;
SelectedAddressRow = null;
IsWalletOpen = false; IsWalletOpen = false;
IsConnected = false; IsConnected = false;
ConnectionStatus = Loc.Tr("conn.none"); ConnectionStatus = Loc.Tr("conn.none");
@@ -753,9 +1127,8 @@ public partial class MainWindowViewModel : ViewModelBase
private (string Host, int Port) ParseServer() private (string Host, int Port) ParseServer()
{ {
var parts = ServerInput.Trim().Split(':'); var host = ServerHost.Trim();
var host = parts[0]; var port = int.TryParse(ServerPort.Trim(), out var p)
var port = parts.Length > 1 && int.TryParse(parts[1], out var p)
? p ? p
: UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort; : UseSsl ? Profile.DefaultSslPort : Profile.DefaultTcpPort;
return (host, port); return (host, port);
+395 -115
View File
@@ -1,6 +1,7 @@
<Window xmlns="https://github.com/avaloniaui" <Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PalladiumWallet.App.ViewModels" xmlns:vm="using:PalladiumWallet.App.ViewModels"
xmlns:net="using:PalladiumWallet.Core.Net"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620" mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="620"
@@ -25,34 +26,8 @@
<MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}" <MenuItem Header="{Binding Loc[menu.file.close]}" Command="{Binding CloseWalletCommand}"
IsEnabled="{Binding IsWalletOpen}"/> IsEnabled="{Binding IsWalletOpen}"/>
</MenuItem> </MenuItem>
<MenuItem Header="{Binding Loc[menu.net]}"> <MenuItem Header="{Binding Loc[menu.settings]}"
<MenuItem Header="{Binding Loc[menu.net.discover]}" Command="{Binding DiscoverServersCommand}"/> Command="{Binding OpenSettingsCommand}"/>
<MenuItem Header="{Binding Loc[menu.net.resetcerts]}" Command="{Binding ResetCertificatesCommand}"/>
</MenuItem>
<MenuItem Header="{Binding Loc[menu.settings]}">
<MenuItem Header="{Binding Loc[settings.language]}">
<MenuItem Header="Italiano" ToggleType="Radio"
IsChecked="{Binding IsLangIt, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="it"/>
<MenuItem Header="English" ToggleType="Radio"
IsChecked="{Binding IsLangEn, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="en"/>
</MenuItem>
<MenuItem Header="{Binding Loc[settings.unit.short]}">
<MenuItem Header="PLM" ToggleType="Radio"
IsChecked="{Binding IsUnitPlm, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="PLM"/>
<MenuItem Header="mPLM" ToggleType="Radio"
IsChecked="{Binding IsUnitMilli, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="mPLM"/>
<MenuItem Header="µPLM" ToggleType="Radio"
IsChecked="{Binding IsUnitMicro, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="µPLM"/>
<MenuItem Header="sat" ToggleType="Radio"
IsChecked="{Binding IsUnitSat, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="sat"/>
</MenuItem>
</MenuItem>
</Menu> </Menu>
<!-- ============ WIZARD DI SETUP (§15): un passo alla volta ============ --> <!-- ============ WIZARD DI SETUP (§15): un passo alla volta ============ -->
@@ -62,6 +37,23 @@
<TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold" <TextBlock Text="Palladium Wallet" FontSize="28" FontWeight="Bold"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
<!-- Passo 0 (primo avvio): dove salvare i dati -->
<StackPanel IsVisible="{Binding IsStepDataLocation}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.data.title]}" FontSize="18" FontWeight="Bold"/>
<TextBlock Text="{Binding Loc[wiz.data.info]}" TextWrapping="Wrap" Foreground="Gray"/>
<StackPanel Spacing="4">
<TextBlock Text="{Binding Loc[wiz.data.default]}" FontSize="11" Foreground="Gray"/>
<SelectableTextBlock Text="{Binding DefaultDataPath}"
FontFamily="monospace" FontSize="13" TextWrapping="Wrap"/>
</StackPanel>
<Button Content="{Binding Loc[wiz.data.usedefault]}" FontSize="16" Classes="accent"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Command="{Binding UseDefaultDataLocationCommand}"/>
<Button Content="{Binding Loc[wiz.data.choose]}" FontSize="16"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
Click="OnChooseDataFolderClick"/>
</StackPanel>
<!-- Passo 1: scelta iniziale --> <!-- Passo 1: scelta iniziale -->
<StackPanel IsVisible="{Binding IsStepStart}" Spacing="12"> <StackPanel IsVisible="{Binding IsStepStart}" Spacing="12">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
@@ -81,6 +73,23 @@
Command="{Binding WizardStartRestoreCommand}"/> Command="{Binding WizardStartRestoreCommand}"/>
</StackPanel> </StackPanel>
<!-- Passo: scelta del wallet (più file presenti) -->
<StackPanel IsVisible="{Binding IsStepChooseWallet}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.choose.title]}" FontSize="18" FontWeight="Bold"/>
<ItemsControl ItemsSource="{Binding WalletList}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WalletFileEntry">
<Button Content="{Binding Name}" FontFamily="monospace"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Margin="0,0,0,6"
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).ChooseWalletCommand}"
CommandParameter="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
</StackPanel>
<!-- Passo: password del wallet esistente --> <!-- Passo: password del wallet esistente -->
<StackPanel IsVisible="{Binding IsStepOpen}" Spacing="12"> <StackPanel IsVisible="{Binding IsStepOpen}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.open.title]}" FontSize="18" FontWeight="Bold"/> <TextBlock Text="{Binding Loc[wiz.open.title]}" FontSize="18" FontWeight="Bold"/>
@@ -146,11 +155,18 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<!-- Passo finale: password del file --> <!-- Passo finale: password del file (cifratura, stile Electrum) -->
<StackPanel IsVisible="{Binding IsStepPassword}" Spacing="12"> <StackPanel IsVisible="{Binding IsStepPassword}" Spacing="12">
<TextBlock Text="{Binding Loc[wiz.password.title]}" FontSize="18" FontWeight="Bold"/> <TextBlock Text="{Binding Loc[wiz.password.title]}" FontSize="18" FontWeight="Bold"/>
<TextBox PlaceholderText="{Binding Loc[wiz.password.placeholder]}" <TextBox PlaceholderText="{Binding Loc[wiz.password.placeholder]}"
PasswordChar="●" Text="{Binding PasswordInput}"/> PasswordChar="●" Text="{Binding PasswordInput}"/>
<TextBox PlaceholderText="{Binding Loc[wiz.password.confirm]}"
PasswordChar="●" Text="{Binding ConfirmPasswordInput}"/>
<CheckBox Content="{Binding Loc[wiz.password.encrypt]}"
IsChecked="{Binding EncryptWallet}"/>
<TextBlock Text="{Binding Loc[wiz.password.encrypt.hint]}"
Foreground="Gray" FontSize="12" TextWrapping="Wrap"
IsVisible="{Binding !EncryptWallet}"/>
<StackPanel Orientation="Horizontal" Spacing="10"> <StackPanel Orientation="Horizontal" Spacing="10">
<Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/> <Button Content="{Binding Loc[wiz.back]}" Command="{Binding WizardBackCommand}"/>
<Button Content="{Binding Loc[wiz.password.create]}" Classes="accent" <Button Content="{Binding Loc[wiz.password.create]}" Classes="accent"
@@ -161,67 +177,20 @@
</ScrollViewer> </ScrollViewer>
<!-- ============ PANNELLO WALLET ============ --> <!-- ============ PANNELLO WALLET ============ -->
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*" IsVisible="{Binding IsWalletOpen}" Margin="16"> <Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding IsWalletOpen}" Margin="16">
<!-- Testata: saldo + rete + chiudi --> <!-- Testata: saldo + rete. Connetti/sincronizza è automatico; chiudi
<Grid Grid.Row="0" ColumnDefinitions="*,Auto"> wallet è in File. Lo stato connessione è nella barra in basso. -->
<StackPanel Grid.Column="0" Spacing="2"> <StackPanel Grid.Row="0" Spacing="2" Margin="0,0,0,12">
<TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/> <TextBlock Text="{Binding BalanceText}" FontSize="30" FontWeight="Bold"/>
<TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/> <TextBlock Text="{Binding UnconfirmedText}" Foreground="Orange"/>
<TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/> <TextBlock Text="{Binding NetworkInfo}" FontSize="12" Foreground="Gray"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="{Binding Loc[wallet.close]}" VerticalAlignment="Top"
Command="{Binding CloseWalletCommand}"/>
</Grid>
<!-- Server --> <!-- Tab: Storico / Invia / Ricevi / Indirizzi / Contatti -->
<Border Grid.Row="1" Margin="0,12,0,12" Padding="10" <TabControl Grid.Row="1">
BorderBrush="Gray" BorderThickness="1" CornerRadius="6">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Loc[wallet.server]}"
VerticalAlignment="Center" Margin="0,0,8,0"/>
<ComboBox Grid.Row="0" Grid.Column="1"
ItemsSource="{Binding KnownServers}"
SelectedItem="{Binding SelectedKnownServer}"
HorizontalAlignment="Stretch"/>
<CheckBox Grid.Row="0" Grid.Column="2" Content="TLS"
IsChecked="{Binding UseSsl}" Margin="8,0"/>
<Button Grid.Row="0" Grid.Column="3" Content="{Binding Loc[wallet.connect]}"
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
<StackPanel Grid.Row="1" Grid.Column="1" Orientation="Horizontal"
Spacing="8" Margin="0,8,0,0">
<TextBox Text="{Binding ServerInput}" MinWidth="220"
PlaceholderText="{Binding Loc[wallet.manual]}"/>
<Button Content="{Binding Loc[wallet.discover]}"
Command="{Binding DiscoverServersCommand}"/>
<Button Content="{Binding Loc[wallet.resetcert]}"
Command="{Binding ResetCertificatesCommand}"/>
</StackPanel>
<StackPanel Grid.Row="1" Grid.Column="2" Grid.ColumnSpan="2"
Orientation="Horizontal" Spacing="6" Margin="8,8,0,0"
VerticalAlignment="Center">
<Ellipse Width="10" Height="10" Fill="LimeGreen"
IsVisible="{Binding IsConnected}"/>
<Ellipse Width="10" Height="10" Fill="IndianRed"
IsVisible="{Binding !IsConnected}"/>
<TextBlock Text="{Binding ConnectionStatus}" Foreground="Gray"/>
</StackPanel>
</Grid>
</Border>
<!-- Tab: Ricevi / Storico / Indirizzi / Invia -->
<TabControl Grid.Row="2">
<TabItem Header="{Binding Loc[tab.receive]}">
<StackPanel Spacing="10" Margin="8">
<TextBlock Text="{Binding Loc[receive.next]}"/>
<SelectableTextBlock Text="{Binding ReceiveAddress}"
FontFamily="monospace" FontSize="16"/>
<TextBlock Text="{Binding Loc[receive.hint]}"
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</TabItem>
<!-- 1. Storico -->
<TabItem Header="{Binding Loc[tab.history]}"> <TabItem Header="{Binding Loc[tab.history]}">
<ListBox ItemsSource="{Binding History}" Margin="4"> <ListBox ItemsSource="{Binding History}" Margin="4">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
@@ -238,34 +207,23 @@
</ListBox> </ListBox>
</TabItem> </TabItem>
<TabItem Header="{Binding Loc[tab.addresses]}"> <!-- 2. Invia -->
<Grid RowDefinitions="Auto,*" Margin="4">
<Grid Grid.Row="0" ColumnDefinitions="90,60,*,140,60" Margin="12,4">
<TextBlock Grid.Column="0" Text="{Binding Loc[addr.type]}" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Text="{Binding Loc[addr.index]}" FontWeight="Bold"/>
<TextBlock Grid.Column="2" Text="{Binding Loc[addr.address]}" FontWeight="Bold"/>
<TextBlock Grid.Column="3" Text="{Binding Loc[addr.balance]}" FontWeight="Bold"/>
<TextBlock Grid.Column="4" Text="Tx" FontWeight="Bold"/>
</Grid>
<ListBox Grid.Row="1" ItemsSource="{Binding Addresses}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:AddressRow">
<Grid ColumnDefinitions="90,60,*,140,60">
<TextBlock Grid.Column="0" Text="{Binding Tipo}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Indice}" Foreground="Gray"/>
<SelectableTextBlock Grid.Column="2" Text="{Binding Indirizzo}"
FontFamily="monospace" FontSize="13"/>
<TextBlock Grid.Column="3" Text="{Binding Saldo}" FontFamily="monospace"/>
<TextBlock Grid.Column="4" Text="{Binding NumTx}" Foreground="Gray"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<TabItem Header="{Binding Loc[tab.send]}"> <TabItem Header="{Binding Loc[tab.send]}">
<StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left"> <StackPanel Spacing="10" Margin="8" MaxWidth="640" HorizontalAlignment="Left">
<!-- Contatto rapido -->
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Loc[send.from.contact]}" VerticalAlignment="Center"/>
<ComboBox ItemsSource="{Binding Contacts}"
SelectedItem="{Binding SendToContact}"
PlaceholderText="{Binding Loc[send.contact.hint]}"
MinWidth="220">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:ContactEntry">
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
<TextBox PlaceholderText="{Binding Loc[send.to]}" Text="{Binding SendTo}" <TextBox PlaceholderText="{Binding Loc[send.to]}" Text="{Binding SendTo}"
FontFamily="monospace"/> FontFamily="monospace"/>
<Grid ColumnDefinitions="*,Auto,Auto,Auto"> <Grid ColumnDefinitions="*,Auto,Auto,Auto">
@@ -290,13 +248,335 @@
FontSize="13"/> FontSize="13"/>
</StackPanel> </StackPanel>
</TabItem> </TabItem>
<!-- 3. Ricevi -->
<TabItem Header="{Binding Loc[tab.receive]}">
<StackPanel Spacing="10" Margin="8">
<TextBlock Text="{Binding Loc[receive.next]}"/>
<SelectableTextBlock Text="{Binding ReceiveAddress}"
FontFamily="monospace" FontSize="16"/>
<Border Background="White" Padding="12" CornerRadius="6"
HorizontalAlignment="Left"
IsVisible="{Binding ReceiveQr, Converter={x:Static ObjectConverters.IsNotNull}}">
<Image Source="{Binding ReceiveQr}" Width="220" Height="220"
RenderOptions.BitmapInterpolationMode="None"/>
</Border>
<TextBlock Text="{Binding Loc[receive.hint]}"
Foreground="Gray" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</TabItem>
<!-- 4. Indirizzi -->
<TabItem Header="{Binding Loc[tab.addresses]}">
<Grid RowDefinitions="Auto,*" Margin="4">
<Grid Grid.Row="0" ColumnDefinitions="90,60,*,140,60" Margin="12,4">
<TextBlock Grid.Column="0" Text="{Binding Loc[addr.type]}" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Text="{Binding Loc[addr.index]}" FontWeight="Bold"/>
<TextBlock Grid.Column="2" Text="{Binding Loc[addr.address]}" FontWeight="Bold"/>
<TextBlock Grid.Column="3" Text="{Binding Loc[addr.balance]}" FontWeight="Bold"/>
<TextBlock Grid.Column="4" Text="Tx" FontWeight="Bold"/>
</Grid>
<ListBox Grid.Row="1" ItemsSource="{Binding Addresses}"
SelectedItem="{Binding SelectedAddressRow}"
x:Name="AddressesListBox"
Tapped="OnAddressListTapped"
PointerPressed="OnAddressListPointerPressed">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:AddressRow">
<Grid ColumnDefinitions="90,60,*,140,60">
<TextBlock Grid.Column="0" Text="{Binding Tipo}" Foreground="Gray"/>
<TextBlock Grid.Column="1" Text="{Binding Indice}" Foreground="Gray"/>
<TextBlock Grid.Column="2" Text="{Binding Indirizzo}"
FontFamily="monospace" FontSize="13"/>
<TextBlock Grid.Column="3" Text="{Binding Saldo}" FontFamily="monospace"/>
<TextBlock Grid.Column="4" Text="{Binding NumTx}" Foreground="Gray"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</TabItem>
<!-- 5. Contatti -->
<TabItem Header="{Binding Loc[tab.contacts]}">
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="4">
<!-- Intestazioni colonne -->
<Grid Grid.Row="0" ColumnDefinitions="180,*" Margin="12,4">
<TextBlock Grid.Column="0" Text="{Binding Loc[contacts.name]}" FontWeight="Bold"/>
<TextBlock Grid.Column="1" Text="{Binding Loc[contacts.address]}" FontWeight="Bold"/>
</Grid>
<!-- Lista contatti -->
<ListBox Grid.Row="1" ItemsSource="{Binding Contacts}"
SelectedItem="{Binding SelectedContactInList}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ContactEntry">
<Grid ColumnDefinitions="180,*">
<TextBlock Grid.Column="0" Text="{Binding Name}"
VerticalAlignment="Center"/>
<SelectableTextBlock Grid.Column="1" Text="{Binding Address}"
FontFamily="monospace" FontSize="12"
VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Pulsante rimuovi selezionato -->
<Button Grid.Row="2" Content="{Binding Loc[contacts.remove]}"
Command="{Binding RemoveSelectedContactCommand}"
IsEnabled="{Binding SelectedContactInList, Converter={x:Static ObjectConverters.IsNotNull}}"
Margin="0,6,0,0"/>
<!-- Form aggiungi contatto -->
<Grid Grid.Row="3" ColumnDefinitions="180,*,Auto" Margin="0,8,0,0">
<TextBox Grid.Column="0" PlaceholderText="{Binding Loc[contacts.name.ph]}"
Text="{Binding NewContactName}" Margin="0,0,6,0"/>
<TextBox Grid.Column="1" PlaceholderText="{Binding Loc[contacts.address.ph]}"
Text="{Binding NewContactAddress}" FontFamily="monospace"
Margin="0,0,6,0"/>
<Button Grid.Column="2" Content="{Binding Loc[contacts.add]}"
Command="{Binding AddContactCommand}"/>
</Grid>
</Grid>
</TabItem>
</TabControl> </TabControl>
</Grid> </Grid>
<!-- Barra di stato --> <!-- Barra di stato: messaggio a sinistra, stato connessione a destra.
Lo stato connessione è cliccabile e apre le impostazioni del server. -->
<Border Grid.Row="2" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}" <Border Grid.Row="2" Background="{DynamicResource SystemControlBackgroundChromeMediumBrush}"
Padding="10,6"> Padding="10,6">
<TextBlock Text="{Binding StatusMessage}" FontSize="12" TextWrapping="Wrap"/> <Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding StatusMessage}" FontSize="12"
TextWrapping="Wrap" VerticalAlignment="Center"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
Margin="12,0,0,0" VerticalAlignment="Center"
Cursor="Hand" Background="Transparent"
ToolTip.Tip="{Binding Loc[server.title]}"
Tapped="OnConnectionStatusTapped">
<Ellipse Width="10" Height="10" Fill="LimeGreen" VerticalAlignment="Center"
IsVisible="{Binding IsConnected}"/>
<Ellipse Width="10" Height="10" Fill="IndianRed" VerticalAlignment="Center"
IsVisible="{Binding !IsConnected}"/>
<TextBlock Text="{Binding ConnectionStatus}" FontSize="12"
Foreground="{DynamicResource SystemAccentColor}"
VerticalAlignment="Center"/>
</StackPanel>
</Grid>
</Border>
<!-- ============ OVERLAY DETTAGLIO INDIRIZZO ============ -->
<!-- Overlay in-app invece di una Window separata: apertura/chiusura
istantanee (niente create/destroy di una top-level window). -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="#99000000"
Tapped="OnAddressOverlayBackdropTapped"
IsVisible="{Binding AddressInfo, Converter={x:Static ObjectConverters.IsNotNull}}">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderBrush="Gray" BorderThickness="1" CornerRadius="8"
Width="560" Padding="0"
HorizontalAlignment="Center" VerticalAlignment="Center"
DataContext="{Binding AddressInfo}">
<StackPanel Margin="24" Spacing="16">
<TextBlock Text="{Binding Loc[addr.info.title]}"
FontSize="18" FontWeight="Bold"/>
<!-- Indirizzo -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding Loc[addr.address]}" FontSize="11" Foreground="Gray"/>
<SelectableTextBlock Text="{Binding Address}"
FontFamily="monospace" FontSize="13"
TextWrapping="Wrap"/>
</StackPanel>
<!-- Derivation path -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding Loc[addr.derivpath]}" FontSize="11" Foreground="Gray"/>
<SelectableTextBlock Text="{Binding DerivPath}"
FontFamily="monospace" FontSize="13"/>
</StackPanel>
<!-- Chiave pubblica -->
<StackPanel Spacing="4">
<TextBlock Text="{Binding Loc[addr.pubkey]}" FontSize="11" Foreground="Gray"/>
<SelectableTextBlock Text="{Binding PubKey}"
FontFamily="monospace" FontSize="12"
TextWrapping="Wrap"/>
</StackPanel>
<!-- Chiave privata (solo se disponibile) -->
<StackPanel Spacing="4" IsVisible="{Binding HasPrivKey}">
<TextBlock Text="{Binding Loc[addr.privkey]}" FontSize="11" Foreground="OrangeRed"/>
<SelectableTextBlock Text="{Binding PrivKey}"
FontFamily="monospace" FontSize="12"
Foreground="OrangeRed"
TextWrapping="Wrap"/>
</StackPanel>
<Button Content="{Binding Loc[addr.close]}"
HorizontalAlignment="Right"
Command="{Binding $parent[Window].((vm:MainWindowViewModel)DataContext).CloseAddressInfoCommand}"/>
</StackPanel>
</Border>
</Border>
<!-- ============ OVERLAY IMPOSTAZIONI SERVER ============ -->
<!-- Stesso pattern dell'overlay indirizzo: apertura/chiusura istantanee.
Accessibile da Impostazioni → Server. -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="#99000000"
Tapped="OnServerOverlayBackdropTapped"
IsVisible="{Binding IsServerSettingsOpen}">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderBrush="Gray" BorderThickness="1" CornerRadius="8"
Width="560" MaxHeight="540"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Margin="24" Spacing="14">
<TextBlock Text="{Binding Loc[server.title]}"
FontSize="18" FontWeight="Bold"/>
<!-- Host + porta separati -->
<Grid ColumnDefinitions="*,Auto,Auto" RowDefinitions="Auto,Auto">
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding Loc[server.host]}"
FontSize="11" Foreground="Gray"/>
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding Loc[server.port]}"
FontSize="11" Foreground="Gray" Margin="10,0,0,0" Width="90"/>
<TextBlock Grid.Row="0" Grid.Column="2" Text="TLS"
FontSize="11" Foreground="Gray" Margin="10,0,0,0"/>
<TextBox Grid.Row="1" Grid.Column="0" Text="{Binding ServerHost}"
FontFamily="monospace" Margin="0,2,0,0"/>
<TextBox Grid.Row="1" Grid.Column="1" Text="{Binding ServerPort}"
FontFamily="monospace" Width="90" Margin="10,2,0,0"/>
<CheckBox Grid.Row="1" Grid.Column="2" IsChecked="{Binding UseSsl}"
Margin="10,2,0,0" VerticalAlignment="Center"/>
</Grid>
<!-- Azioni -->
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="{Binding Loc[wallet.connect]}" Classes="accent"
Command="{Binding ConnectAndSyncCommand}" IsEnabled="{Binding !IsSyncing}"/>
<Button Content="{Binding Loc[wallet.discover]}"
Command="{Binding DiscoverServersCommand}"/>
<Button Content="{Binding Loc[wallet.resetcert]}"
Command="{Binding ResetCertificatesCommand}"/>
</StackPanel>
<!-- Stato connessione -->
<StackPanel Orientation="Horizontal" Spacing="6">
<Ellipse Width="10" Height="10" Fill="LimeGreen" VerticalAlignment="Center"
IsVisible="{Binding IsConnected}"/>
<Ellipse Width="10" Height="10" Fill="IndianRed" VerticalAlignment="Center"
IsVisible="{Binding !IsConnected}"/>
<TextBlock Text="{Binding ConnectionStatus}" Foreground="Gray"
VerticalAlignment="Center"/>
</StackPanel>
<!-- Server conosciuti: clicca per riempire host/porta -->
<TextBlock Text="{Binding Loc[server.known]}" FontSize="11" Foreground="Gray"/>
<ListBox ItemsSource="{Binding KnownServers}"
SelectedItem="{Binding SelectedKnownServer}"
MaxHeight="200">
<ListBox.Styles>
<Style Selector="ListBox:empty">
<Setter Property="Template">
<ControlTemplate>
<TextBlock Text="{Binding Loc[server.empty]}"
Foreground="Gray" FontSize="12"
TextWrapping="Wrap" Margin="8"/>
</ControlTemplate>
</Setter>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="net:KnownServer">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Host}"
FontFamily="monospace" FontSize="13"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" FontSize="11" Foreground="Gray"
VerticalAlignment="Center">
<Run Text="tcp"/><Run Text="{Binding TcpPort}"/>
<Run Text=" / ssl"/><Run Text="{Binding SslPort}"/>
</TextBlock>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Content="{Binding Loc[addr.close]}"
HorizontalAlignment="Right"
Command="{Binding CloseServerSettingsCommand}"/>
</StackPanel>
</Border>
</Border>
<!-- ============ OVERLAY IMPOSTAZIONI ============ -->
<!-- In-app invece dei sottomenu annidati: niente popup OS lenti su WSLg. -->
<Border Grid.Row="0" Grid.RowSpan="3"
Background="#99000000"
Tapped="OnSettingsOverlayBackdropTapped"
IsVisible="{Binding IsSettingsOpen}">
<Border Background="{DynamicResource SystemControlBackgroundChromeMediumLowBrush}"
BorderBrush="Gray" BorderThickness="1" CornerRadius="8"
Width="460"
HorizontalAlignment="Center" VerticalAlignment="Center">
<StackPanel Margin="24" Spacing="16">
<TextBlock Text="{Binding Loc[settings.title]}"
FontSize="18" FontWeight="Bold"/>
<!-- Lingua -->
<StackPanel Spacing="6">
<TextBlock Text="{Binding Loc[settings.language]}" FontSize="11" Foreground="Gray"/>
<WrapPanel>
<RadioButton GroupName="lang" Content="Italiano" Margin="0,0,14,4"
IsChecked="{Binding IsLangIt, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="it"/>
<RadioButton GroupName="lang" Content="English" Margin="0,0,14,4"
IsChecked="{Binding IsLangEn, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="en"/>
<RadioButton GroupName="lang" Content="Español" Margin="0,0,14,4"
IsChecked="{Binding IsLangEs, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="es"/>
<RadioButton GroupName="lang" Content="Français" Margin="0,0,14,4"
IsChecked="{Binding IsLangFr, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="fr"/>
<RadioButton GroupName="lang" Content="Português" Margin="0,0,14,4"
IsChecked="{Binding IsLangPt, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="pt"/>
<RadioButton GroupName="lang" Content="Deutsch" Margin="0,0,14,4"
IsChecked="{Binding IsLangDe, Mode=OneWay}"
Command="{Binding SetLanguageCommand}" CommandParameter="de"/>
</WrapPanel>
</StackPanel>
<!-- Unità -->
<StackPanel Spacing="6">
<TextBlock Text="{Binding Loc[settings.unit]}" FontSize="11" Foreground="Gray"/>
<WrapPanel>
<RadioButton GroupName="unit" Content="PLM" Margin="0,0,14,4"
IsChecked="{Binding IsUnitPlm, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="PLM"/>
<RadioButton GroupName="unit" Content="mPLM" Margin="0,0,14,4"
IsChecked="{Binding IsUnitMilli, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="mPLM"/>
<RadioButton GroupName="unit" Content="µPLM" Margin="0,0,14,4"
IsChecked="{Binding IsUnitMicro, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="µPLM"/>
<RadioButton GroupName="unit" Content="sat" Margin="0,0,14,4"
IsChecked="{Binding IsUnitSat, Mode=OneWay}"
Command="{Binding SetUnitCommand}" CommandParameter="sat"/>
</WrapPanel>
</StackPanel>
<!-- Server di indicizzazione (configurabile anche prima di aprire un wallet) -->
<Button Content="{Binding Loc[settings.server]}"
HorizontalAlignment="Left"
Command="{Binding OpenServerSettingsCommand}"/>
<Button Content="{Binding Loc[addr.close]}"
HorizontalAlignment="Right"
Command="{Binding CloseSettingsCommand}"/>
</StackPanel>
</Border>
</Border> </Border>
</Grid> </Grid>
</Window> </Window>
+79 -1
View File
@@ -1,7 +1,12 @@
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using PalladiumWallet.App.ViewModels; using PalladiumWallet.App.ViewModels;
namespace PalladiumWallet.App.Views; namespace PalladiumWallet.App.Views;
@@ -13,7 +18,6 @@ public partial class MainWindow : Window
InitializeComponent(); InitializeComponent();
} }
/// <summary>File → Apri wallet da file (il picker richiede il TopLevel, da qui).</summary>
private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e) private async void OnOpenWalletFileClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is not MainWindowViewModel vm) if (DataContext is not MainWindowViewModel vm)
@@ -32,4 +36,78 @@ public partial class MainWindow : Window
if (files.FirstOrDefault()?.TryGetLocalPath() is { } path) if (files.FirstOrDefault()?.TryGetLocalPath() is { } path)
vm.OpenFromPath(path); vm.OpenFromPath(path);
} }
private void OnAddressListTapped(object? sender, TappedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm || vm.SelectedAddressRow is not { } row)
return;
vm.ShowAddressInfo(row);
}
private void OnAddressListPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(null).Properties.IsRightButtonPressed) return;
if (sender is not ListBox lb || DataContext is not MainWindowViewModel vm) return;
var item = (e.Source as Visual)?.FindAncestorOfType<ListBoxItem>();
if (item is not { DataContext: AddressRow row }) return;
lb.SelectedItem = row;
vm.ShowAddressInfo(row);
}
// Chiusura dell'overlay dettaglio indirizzo: click sullo sfondo scuro
// (solo sullo sfondo, non sulla scheda) o tasto Esc.
private void OnAddressOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.AddressInfo = null;
}
private void OnServerOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.IsServerSettingsOpen = false;
}
private async void OnChooseDataFolderClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel vm)
return;
var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
{
Title = "Cartella dati Palladium Wallet",
AllowMultiple = false,
});
if (folders.FirstOrDefault()?.TryGetLocalPath() is { } path)
vm.ApplyDataLocation(path);
}
private void OnConnectionStatusTapped(object? sender, TappedEventArgs e)
{
if (DataContext is MainWindowViewModel vm)
vm.IsServerSettingsOpen = true;
}
private void OnSettingsOverlayBackdropTapped(object? sender, TappedEventArgs e)
{
if (!ReferenceEquals(e.Source, sender)) return;
if (DataContext is MainWindowViewModel vm)
vm.IsSettingsOpen = false;
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.Key == Key.Escape && DataContext is MainWindowViewModel vm)
{
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; }
}
base.OnKeyDown(e);
}
} }
+2 -2
View File
@@ -9,8 +9,8 @@ namespace PalladiumWallet.Core.Storage;
/// </summary> /// </summary>
public sealed class AppConfig public sealed class AppConfig
{ {
/// <summary>Codice lingua UI ("it", "en").</summary> /// <summary>Codice lingua UI.</summary>
public string Language { get; set; } = "it"; public string Language { get; set; } = "en";
/// <summary>Unità di visualizzazione degli importi (vedi <see cref="Wallet.CoinAmount.Units"/>).</summary> /// <summary>Unità di visualizzazione degli importi (vedi <see cref="Wallet.CoinAmount.Units"/>).</summary>
public string Unit { get; set; } = "PLM"; public string Unit { get; set; } = "PLM";
+90 -8
View File
@@ -3,24 +3,97 @@ using PalladiumWallet.Core.Chain;
namespace PalladiumWallet.Core.Storage; namespace PalladiumWallet.Core.Storage;
/// <summary> /// <summary>
/// Percorsi dati per piattaforma (blueprint §8): ~/.palladium-wallet (Linux) o /// Percorsi dati per piattaforma (blueprint §8). La radice dati può essere:
/// %APPDATA%/PalladiumWallet (Windows), con sottocartella per rete. La modalità /// 1. <b>portable</b>: cartella "palladium-data" accanto all'eseguibile;
/// portable (dati accanto all'eseguibile) si attiva se accanto all'eseguibile /// 2. <b>personalizzata</b>: scelta dall'utente al primo avvio e memorizzata in
/// esiste una cartella "palladium-data". /// un piccolo file "puntatore" in una posizione di bootstrap fissa;
/// 3. <b>legacy</b>: vecchia posizione (%APPDATA%/PalladiumWallet) se contiene già dati;
/// 4. <b>default</b>: ~/.PalladiumWallet (Linux/macOS) o %ProgramFiles%\PalladiumWallet (Windows).
/// Sotto la radice c'è una sottocartella per rete (config, wallet, header, certificati).
/// </summary> /// </summary>
public static class AppPaths public static class AppPaths
{ {
public const string PortableDirName = "palladium-data"; public const string PortableDirName = "palladium-data";
/// <summary>Nome cartella applicazione, usato nei vari percorsi.</summary>
public const string AppDirName = "PalladiumWallet";
/// <summary>Override esplicito della radice dati (es. CLI --data-dir). Ha priorità su tutto.</summary>
public static string? OverrideDataRoot { get; set; }
/// <summary>
/// Radice dati predefinita, secondo la convenzione di ogni piattaforma:
/// Windows → %APPDATA%\PalladiumWallet (PascalCase, come Electrum/Bitcoin);
/// Linux/macOS → ~/.palladium-wallet (dotfolder minuscolo, come ~/.bitcoin).
/// Per-utente e sempre scrivibile, senza privilegi di amministratore.
/// </summary>
public static string DefaultDataRoot()
{
if (OperatingSystem.IsWindows())
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
AppDirName);
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
return Path.Combine(home, ".palladium-wallet");
}
/// <summary>File puntatore alla radice dati scelta dall'utente. Vive in una
/// posizione di bootstrap sempre scrivibile e indipendente dalla radice dati.</summary>
private static string LocationPointerPath() =>
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
AppDirName, "data-location");
private static string PortableRoot() =>
Path.Combine(AppContext.BaseDirectory, PortableDirName);
private static bool HasData(string root) =>
Directory.Exists(root) && Directory.EnumerateFileSystemEntries(root).Any();
/// <summary>Radice dati effettiva, secondo l'ordine di precedenza documentato in classe.</summary>
public static string DataRoot() public static string DataRoot()
{ {
var portable = Path.Combine(AppContext.BaseDirectory, PortableDirName); if (!string.IsNullOrEmpty(OverrideDataRoot))
return OverrideDataRoot;
var portable = PortableRoot();
if (Directory.Exists(portable)) if (Directory.Exists(portable))
return portable; return portable;
return OperatingSystem.IsWindows() if (ReadPointer() is { } custom)
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "PalladiumWallet") return custom;
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".palladium-wallet");
return DefaultDataRoot();
}
/// <summary>
/// true se la posizione dei dati è già determinata e non serve chiederla
/// all'utente: modalità portable, override, puntatore già scritto, oppure
/// dati già presenti nella posizione predefinita.
/// </summary>
public static bool IsDataLocationConfigured() =>
!string.IsNullOrEmpty(OverrideDataRoot)
|| Directory.Exists(PortableRoot())
|| ReadPointer() is not null
|| HasData(DefaultDataRoot());
/// <summary>Memorizza la radice dati scelta dall'utente e la crea su disco.</summary>
public static void ConfigureDataLocation(string root)
{
root = Path.GetFullPath(root.Trim());
Directory.CreateDirectory(root);
var pointer = LocationPointerPath();
Directory.CreateDirectory(Path.GetDirectoryName(pointer)!);
File.WriteAllText(pointer, root);
}
private static string? ReadPointer()
{
var pointer = LocationPointerPath();
if (!File.Exists(pointer))
return null;
var path = File.ReadAllText(pointer).Trim();
return string.IsNullOrEmpty(path) ? null : path;
} }
/// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary> /// <summary>Cartella dati della rete (config, wallet, header, certificati).</summary>
@@ -41,6 +114,15 @@ public static class AppPaths
public static string DefaultWalletPath(NetKind net) => public static string DefaultWalletPath(NetKind net) =>
Path.Combine(WalletsDir(net), "default.wallet.json"); Path.Combine(WalletsDir(net), "default.wallet.json");
/// <summary>Tutti i file wallet della rete, ordinati per nome (multi-wallet §8).</summary>
public static IReadOnlyList<string> WalletFiles(NetKind net)
{
var dir = WalletsDir(net);
return Directory.EnumerateFiles(dir, "*.wallet.json")
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public static string CertificatePinsPath(NetKind net) => public static string CertificatePinsPath(NetKind net) =>
Path.Combine(ForNetwork(net), "server-certs.json"); Path.Combine(ForNetwork(net), "server-certs.json");
+10
View File
@@ -40,6 +40,9 @@ public sealed class WalletDocument
/// <summary>Etichette per indirizzo/txid (§12).</summary> /// <summary>Etichette per indirizzo/txid (§12).</summary>
public Dictionary<string, string> Labels { get; set; } = []; public Dictionary<string, string> Labels { get; set; } = [];
/// <summary>Rubrica contatti (nome + indirizzo blockchain).</summary>
public List<StoredContact> Contacts { get; set; } = [];
/// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary> /// <summary>Cache dell'ultimo stato sincronizzato (saldo/storico mostrabili offline).</summary>
public SyncCache? Cache { get; set; } public SyncCache? Cache { get; set; }
@@ -69,6 +72,13 @@ public sealed class WalletDocument
}; };
} }
/// <summary>Contatto in rubrica: nome leggibile + indirizzo blockchain.</summary>
public sealed class StoredContact
{
public required string Name { get; set; }
public required string Address { get; set; }
}
/// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary> /// <summary>Stato sincronizzato persistito: permette di mostrare saldo/storico offline.</summary>
public sealed class SyncCache public sealed class SyncCache
{ {
@@ -197,7 +197,7 @@ public class TransactionFactoryTests
{ {
File.WriteAllText(path, "{ rotto "); File.WriteAllText(path, "{ rotto ");
var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path); var loaded = PalladiumWallet.Core.Storage.AppConfig.Load(path);
Assert.Equal("it", loaded.Language); Assert.Equal("en", loaded.Language);
Assert.Equal("PLM", loaded.Unit); Assert.Equal("PLM", loaded.Unit);
} }
finally finally