From 2d4ba89c0029a744c4b1a139e39ab9f995f0b388 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 15 Jul 2026 07:10:00 -0500 Subject: [PATCH] feat(contacts): revamp edit dialog with live preview + avatar picker Rebuild the add/edit contact dialog on the portfolio-editor design language: - A live preview card at the top shows the contact exactly as it renders in the list (avatar + name + address), updating as you type and pick an avatar. - An avatar picker (Badge / Icon / Image segmented control) lets you keep the default Z/T type badge, choose a Material wallet icon from a searchable grid, or set a custom image. The picker area is fixed-height so the modal doesn't jump when switching modes. - Custom images go through a new in-app ImagePicker (image_picker.h): a Material overlay that browses the filesystem starting at the user's Pictures folder, shows a thumbnail grid (decoded to raw pixels, box-downscaled to a small texture, cached per directory and freed on navigate/close, budgeted a few decodes per frame so large folders don't hitch), and returns the chosen path. The chosen image is copied into /contact-avatars/ (named by an FNV hash of the source path, so re-picking is idempotent) and stored as "img:". Like FolderPicker, it takes over the modal surface while open. - Paste is now available on both add and edit; buttons are content-sized. Adds three sweep surfaces (contacts-edit-{icon,badge,image}) that open the dialog on a seeded contact in each avatar mode, plus i18n keys (+ 8-language translations; reworded two zh/ja strings to stay within the existing CJK subset, so no font rebuild). Co-Authored-By: Claude Opus 4.8 (1M context) --- res/lang/de.json | 17 ++ res/lang/es.json | 17 ++ res/lang/fr.json | 17 ++ res/lang/ja.json | 17 ++ res/lang/ko.json | 17 ++ res/lang/pt.json | 17 ++ res/lang/ru.json | 17 ++ res/lang/zh.json | 17 ++ src/app_sweep.cpp | 14 ++ src/ui/windows/contacts_tab.cpp | 288 ++++++++++++++++++++++--- src/ui/windows/contacts_tab.h | 6 + src/ui/windows/image_picker.h | 371 ++++++++++++++++++++++++++++++++ src/util/i18n.cpp | 19 ++ 13 files changed, 809 insertions(+), 25 deletions(-) create mode 100644 src/ui/windows/image_picker.h diff --git a/res/lang/de.json b/res/lang/de.json index dc9764a..ca8f183 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -287,9 +287,20 @@ "console_welcome": "Willkommen bei ObsidianDragon Konsole", "console_zoom_in": "Vergrößern", "console_zoom_out": "Verkleinern", + "contact_avatar": "AVATAR", + "contact_avatar_badge": "Abzeichen", + "contact_avatar_badge_hint": "Verwendet das Adresstyp-Abzeichen – Z für abgeschirmt, T für transparent.", + "contact_avatar_choose": "Bild auswählen…", + "contact_avatar_copy_failed": "Dieses Bild konnte nicht kopiert werden.", + "contact_avatar_icon": "Symbol", + "contact_avatar_image": "Bild", + "contact_avatar_image_hint": "Das Bild wird in die App kopiert, damit es verfügbar bleibt, wenn das Original verschoben wird.", + "contact_avatar_remove": "Entfernen", "contact_global": "In jeder Wallet anzeigen (globaler Kontakt)", "contact_global_badge_tt": "Globaler Kontakt — in jeder Wallet sichtbar", "contact_global_tt": "Ein: Dieser Kontakt bleibt sichtbar, egal welche Wallet Sie laden. Aus: Er gehört nur zur aktuellen Wallet.", + "contact_preview_addr": "Adresse erscheint hier", + "contact_preview_name": "Kontaktname", "contacts": "Kontakte", "contacts_search_no_match": "Keine passenden Kontakte", "contacts_search_placeholder": "Kontakte durchsuchen...", @@ -465,6 +476,12 @@ "hide_qr": "QR ausblenden", "hide_zero_balances": "Nullsalden ausblenden", "history": "Verlauf", + "img_picker_count": "%d Bild(er) in diesem Ordner", + "img_picker_empty": "Dieser Ordner enthält keine Unterordner oder Bilder.", + "img_picker_none": "Keine Bilder in diesem Ordner", + "img_picker_pictures": "Bilderordner", + "img_picker_title": "Bild auswählen", + "img_picker_use": "Bild verwenden", "immature_type": "Unreif", "import": "Importieren", "import_key_address": "Adresse:", diff --git a/res/lang/es.json b/res/lang/es.json index eff2afb..41e2f54 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -287,9 +287,20 @@ "console_welcome": "Bienvenido a la Consola de ObsidianDragon", "console_zoom_in": "Acercar", "console_zoom_out": "Alejar", + "contact_avatar": "AVATAR", + "contact_avatar_badge": "Insignia", + "contact_avatar_badge_hint": "Usa la insignia de tipo de dirección: Z para blindada, T para transparente.", + "contact_avatar_choose": "Elegir imagen…", + "contact_avatar_copy_failed": "No se pudo copiar esa imagen.", + "contact_avatar_icon": "Icono", + "contact_avatar_image": "Imagen", + "contact_avatar_image_hint": "La imagen se copia en la app para que siga disponible si el original se mueve.", + "contact_avatar_remove": "Quitar", "contact_global": "Mostrar en todas las carteras (contacto global)", "contact_global_badge_tt": "Contacto global — visible en todas las carteras", "contact_global_tt": "Activado: este contacto permanece visible sin importar qué cartera cargues. Desactivado: pertenece solo a la cartera actual.", + "contact_preview_addr": "La dirección aparecerá aquí", + "contact_preview_name": "Nombre del contacto", "contacts": "Contactos", "contacts_search_no_match": "No hay contactos coincidentes", "contacts_search_placeholder": "Buscar contactos...", @@ -465,6 +476,12 @@ "hide_qr": "Ocultar QR", "hide_zero_balances": "Ocultar saldos 0", "history": "Historial", + "img_picker_count": "%d imagen(es) en esta carpeta", + "img_picker_empty": "Esta carpeta no tiene subcarpetas ni imágenes.", + "img_picker_none": "No hay imágenes en esta carpeta", + "img_picker_pictures": "Carpeta de imágenes", + "img_picker_title": "Elegir una imagen", + "img_picker_use": "Usar imagen", "immature_type": "Inmaduro", "import": "Importar", "import_key_address": "Dirección:", diff --git a/res/lang/fr.json b/res/lang/fr.json index 31a84fe..1722af1 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -287,9 +287,20 @@ "console_welcome": "Bienvenue dans la console ObsidianDragon", "console_zoom_in": "Agrandir", "console_zoom_out": "Réduire", + "contact_avatar": "AVATAR", + "contact_avatar_badge": "Badge", + "contact_avatar_badge_hint": "Utilise le badge de type d'adresse — Z pour blindée, T pour transparente.", + "contact_avatar_choose": "Choisir une image…", + "contact_avatar_copy_failed": "Impossible de copier cette image.", + "contact_avatar_icon": "Icône", + "contact_avatar_image": "Image", + "contact_avatar_image_hint": "L'image est copiée dans l'application pour rester disponible si l'original est déplacé.", + "contact_avatar_remove": "Retirer", "contact_global": "Afficher dans tous les portefeuilles (contact global)", "contact_global_badge_tt": "Contact global — visible dans tous les portefeuilles", "contact_global_tt": "Activé : ce contact reste visible quel que soit le portefeuille chargé. Désactivé : il appartient au portefeuille actuel uniquement.", + "contact_preview_addr": "L'adresse apparaîtra ici", + "contact_preview_name": "Nom du contact", "contacts": "Contacts", "contacts_search_no_match": "Aucun contact correspondant", "contacts_search_placeholder": "Rechercher des contacts...", @@ -465,6 +476,12 @@ "hide_qr": "Masquer le QR", "hide_zero_balances": "Masquer les soldes à 0", "history": "Historique", + "img_picker_count": "%d image(s) dans ce dossier", + "img_picker_empty": "Ce dossier ne contient ni sous-dossiers ni images.", + "img_picker_none": "Aucune image dans ce dossier", + "img_picker_pictures": "Dossier Images", + "img_picker_title": "Choisir une image", + "img_picker_use": "Utiliser l'image", "immature_type": "Immature", "import": "Importer", "import_key_address": "Adresse :", diff --git a/res/lang/ja.json b/res/lang/ja.json index 773b9c0..4b17a9c 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -287,9 +287,20 @@ "console_welcome": "ObsidianDragonコンソールへようこそ", "console_zoom_in": "拡大", "console_zoom_out": "縮小", + "contact_avatar": "アバター", + "contact_avatar_badge": "バッジ", + "contact_avatar_badge_hint": "アドレス種別バッジを使用します — Z はシールド、T は透明。", + "contact_avatar_choose": "画像を選択…", + "contact_avatar_copy_failed": "その画像をコピーできませんでした。", + "contact_avatar_icon": "アイコン", + "contact_avatar_image": "画像", + "contact_avatar_image_hint": "画像はアプリ内にコピーされ、元のファイルが移動しても利用できます。", + "contact_avatar_remove": "削除", "contact_global": "すべてのウォレットで表示(グローバル連絡先)", "contact_global_badge_tt": "グローバル連絡先 — すべてのウォレットで表示", "contact_global_tt": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。", + "contact_preview_addr": "ここにアドレスが表示されます", + "contact_preview_name": "連絡先名", "contacts": "連絡先", "contacts_search_no_match": "一致する連絡先がありません", "contacts_search_placeholder": "連絡先を検索...", @@ -465,6 +476,12 @@ "hide_qr": "QRを非表示", "hide_zero_balances": "残高0を非表示", "history": "履歴", + "img_picker_count": "このフォルダの画像:%d", + "img_picker_empty": "このフォルダにはサブフォルダも画像もありません。", + "img_picker_none": "このフォルダに画像はありません", + "img_picker_pictures": "画像フォルダ", + "img_picker_title": "画像を選択", + "img_picker_use": "この画像を使用", "immature_type": "未成熟", "import": "インポート", "import_key_address": "アドレス:", diff --git a/res/lang/ko.json b/res/lang/ko.json index d82a1ed..e3789fe 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -287,9 +287,20 @@ "console_welcome": "ObsidianDragon 콘솔에 오신 것을 환영합니다", "console_zoom_in": "확대", "console_zoom_out": "축소", + "contact_avatar": "아바타", + "contact_avatar_badge": "배지", + "contact_avatar_badge_hint": "주소 유형 배지를 사용합니다 — Z는 보호, T는 투명.", + "contact_avatar_choose": "이미지 선택…", + "contact_avatar_copy_failed": "그 이미지를 복사할 수 없습니다.", + "contact_avatar_icon": "아이콘", + "contact_avatar_image": "이미지", + "contact_avatar_image_hint": "이미지는 앱에 복사되어 원본이 이동해도 계속 사용할 수 있습니다.", + "contact_avatar_remove": "제거", "contact_global": "모든 지갑에 표시(전역 연락처)", "contact_global_badge_tt": "전역 연락처 — 모든 지갑에서 표시됨", "contact_global_tt": "켜짐: 어떤 지갑을 불러오든 이 연락처가 계속 표시됩니다. 꺼짐: 현재 지갑에만 속합니다.", + "contact_preview_addr": "여기에 주소가 표시됩니다", + "contact_preview_name": "연락처 이름", "contacts": "연락처", "contacts_search_no_match": "일치하는 연락처 없음", "contacts_search_placeholder": "연락처 검색...", @@ -465,6 +476,12 @@ "hide_qr": "QR 숨기기", "hide_zero_balances": "잔액 0 숨기기", "history": "내역", + "img_picker_count": "이 폴더에 이미지 %d개", + "img_picker_empty": "이 폴더에는 하위 폴더나 이미지가 없습니다.", + "img_picker_none": "이 폴더에 이미지가 없습니다", + "img_picker_pictures": "사진 폴더", + "img_picker_title": "이미지 선택", + "img_picker_use": "이미지 사용", "immature_type": "미성숙", "import": "가져오기", "import_key_address": "주소:", diff --git a/res/lang/pt.json b/res/lang/pt.json index 214e320..3a0d575 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -287,9 +287,20 @@ "console_welcome": "Bem-vindo ao Console ObsidianDragon", "console_zoom_in": "Aumentar zoom", "console_zoom_out": "Diminuir zoom", + "contact_avatar": "AVATAR", + "contact_avatar_badge": "Selo", + "contact_avatar_badge_hint": "Usa o selo de tipo de endereço — Z para blindado, T para transparente.", + "contact_avatar_choose": "Escolher imagem…", + "contact_avatar_copy_failed": "Não foi possível copiar essa imagem.", + "contact_avatar_icon": "Ícone", + "contact_avatar_image": "Imagem", + "contact_avatar_image_hint": "A imagem é copiada para o app para continuar disponível se o original for movido.", + "contact_avatar_remove": "Remover", "contact_global": "Mostrar em todas as carteiras (contato global)", "contact_global_badge_tt": "Contato global — visível em todas as carteiras", "contact_global_tt": "Ativado: este contato permanece visível em qualquer carteira que você carregar. Desativado: ele pertence apenas à carteira atual.", + "contact_preview_addr": "O endereço aparecerá aqui", + "contact_preview_name": "Nome do contato", "contacts": "Contatos", "contacts_search_no_match": "Nenhum contato correspondente", "contacts_search_placeholder": "Pesquisar contatos...", @@ -465,6 +476,12 @@ "hide_qr": "Ocultar QR", "hide_zero_balances": "Ocultar saldos zero", "history": "Histórico", + "img_picker_count": "%d imagem(ns) nesta pasta", + "img_picker_empty": "Esta pasta não tem subpastas nem imagens.", + "img_picker_none": "Nenhuma imagem nesta pasta", + "img_picker_pictures": "Pasta de imagens", + "img_picker_title": "Escolher uma imagem", + "img_picker_use": "Usar imagem", "immature_type": "Imaturo", "import": "Importar", "import_key_address": "Endereço:", diff --git a/res/lang/ru.json b/res/lang/ru.json index 48a91cc..9d3fb7b 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -287,9 +287,20 @@ "console_welcome": "Добро пожаловать в консоль ObsidianDragon", "console_zoom_in": "Увеличить", "console_zoom_out": "Уменьшить", + "contact_avatar": "АВАТАР", + "contact_avatar_badge": "Значок", + "contact_avatar_badge_hint": "Использует значок типа адреса — Z для защищённого, T для прозрачного.", + "contact_avatar_choose": "Выбрать изображение…", + "contact_avatar_copy_failed": "Не удалось скопировать это изображение.", + "contact_avatar_icon": "Иконка", + "contact_avatar_image": "Изображение", + "contact_avatar_image_hint": "Изображение копируется в приложение, чтобы оставаться доступным, если оригинал переместят.", + "contact_avatar_remove": "Удалить", "contact_global": "Показывать во всех кошельках (глобальный контакт)", "contact_global_badge_tt": "Глобальный контакт — виден во всех кошельках", "contact_global_tt": "Вкл.: этот контакт остаётся видимым, какой бы кошелёк вы ни загрузили. Выкл.: он принадлежит только текущему кошельку.", + "contact_preview_addr": "Здесь появится адрес", + "contact_preview_name": "Имя контакта", "contacts": "Контакты", "contacts_search_no_match": "Совпадающих контактов нет", "contacts_search_placeholder": "Поиск контактов...", @@ -465,6 +476,12 @@ "hide_qr": "Скрыть QR", "hide_zero_balances": "Скрыть нулевые балансы", "history": "История", + "img_picker_count": "Изображений в этой папке: %d", + "img_picker_empty": "В этой папке нет подпапок или изображений.", + "img_picker_none": "В этой папке нет изображений", + "img_picker_pictures": "Папка изображений", + "img_picker_title": "Выбрать изображение", + "img_picker_use": "Использовать изображение", "immature_type": "Незрелая", "import": "Импорт", "import_key_address": "Адрес:", diff --git a/res/lang/zh.json b/res/lang/zh.json index ac7d8af..f694969 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -287,9 +287,20 @@ "console_welcome": "欢迎使用 ObsidianDragon 控制台", "console_zoom_in": "放大", "console_zoom_out": "缩小", + "contact_avatar": "头像", + "contact_avatar_badge": "标记", + "contact_avatar_badge_hint": "使用地址类型标记——Z 表示隐蔽地址,T 表示透明地址。", + "contact_avatar_choose": "选择图片…", + "contact_avatar_copy_failed": "无法复制该图片。", + "contact_avatar_icon": "图标", + "contact_avatar_image": "图片", + "contact_avatar_image_hint": "图片会复制到应用中,即使原文件移动也能保持可用。", + "contact_avatar_remove": "移除", "contact_global": "在每个钱包中显示(全局联系人)", "contact_global_badge_tt": "全局联系人——在每个钱包中可见", "contact_global_tt": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。", + "contact_preview_addr": "地址将显示在此处", + "contact_preview_name": "联系人名称", "contacts": "联系人", "contacts_search_no_match": "没有匹配的联系人", "contacts_search_placeholder": "搜索联系人...", @@ -465,6 +476,12 @@ "hide_qr": "隐藏二维码", "hide_zero_balances": "隐藏零余额", "history": "历史", + "img_picker_count": "此文件夹中的图片:%d", + "img_picker_empty": "此文件夹没有子文件夹或图片。", + "img_picker_none": "此文件夹中没有图片", + "img_picker_pictures": "图片文件夹", + "img_picker_title": "选择图片", + "img_picker_use": "使用图片", "immature_type": "未成熟", "import": "导入", "import_key_address": "地址:", diff --git a/src/app_sweep.cpp b/src/app_sweep.cpp index deccd0b..7a82532 100644 --- a/src/app_sweep.cpp +++ b/src/app_sweep.cpp @@ -32,6 +32,7 @@ #include "ui/windows/shield_dialog.h" #include "ui/windows/address_transfer_dialog.h" #include "ui/windows/key_export_dialog.h" +#include "ui/windows/contacts_tab.h" #include "ui/pages/settings_page.h" #include "util/platform.h" #include "wallet/wallet_capabilities.h" @@ -408,6 +409,19 @@ void App::buildSweepCatalog() add("contacts-table", ui::NavPage::Contacts, [](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(2); }, [](App& a) { restoreSweepContacts(a); }); + // Revamped edit dialog: live preview + avatar picker, one surface per avatar mode. + add("contacts-edit-icon", ui::NavPage::Contacts, + [](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1); + ui::ContactsSweepOpenEditDialog(1); }, + [](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); }); + add("contacts-edit-badge", ui::NavPage::Contacts, + [](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1); + ui::ContactsSweepOpenEditDialog(0); }, + [](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); }); + add("contacts-edit-image", ui::NavPage::Contacts, + [](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1); + ui::ContactsSweepOpenEditDialog(2); }, + [](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); }); add("modal-about", ui::NavPage::Overview, [](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; }); add("modal-settings", ui::NavPage::Settings, diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index bd2ca3e..8a88c5a 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -14,11 +14,15 @@ #include "../material/type.h" #include "../material/project_icons.h" #include "../../util/texture_loader.h" +#include "../../util/platform.h" +#include "image_picker.h" #include "../layout.h" #include "imgui.h" +#include #include #include #include +#include #include #include #include @@ -39,6 +43,10 @@ static char s_edit_address[512] = ""; static char s_edit_notes[512] = ""; static bool s_edit_global = false; // add/edit dialog: "visible in every wallet" toggle static char s_search[128] = ""; +// Avatar being edited: "" = default Z/T badge, "icon:", or "img:". +static std::string s_edit_avatar; +static int s_edit_avatar_mode = 0; // 0 = badge, 1 = icon, 2 = image (segmented picker) +static char s_edit_icon_search[64] = ""; // icon-grid filter in the edit dialog static void copyEditField(char* dest, size_t destSize, const std::string& source) { if (destSize == 0) return; @@ -112,6 +120,35 @@ static bool isShieldedAddr(const std::string& a) { return !a.empty() && a[0] == 'z'; } +// Accent colour for a contact's address type (Z = shielded/green, T = transparent/amber), tuned per +// theme. File-scope so both the list rows and the edit-dialog preview share one source of truth. +static ImU32 contactTypeColor(bool shielded, bool light) { + ImVec4 c = shielded ? (light ? ImVec4(0.10f,0.55f,0.38f,1.0f) : ImVec4(0.35f,0.80f,0.60f,1.0f)) + : (light ? ImVec4(0.72f,0.48f,0.05f,1.0f) : ImVec4(0.95f,0.72f,0.30f,1.0f)); + return ImGui::ColorConvertFloat4ToU32(c); +} + +// Copy a chosen avatar image into /contact-avatars/, returning the destination absolute path +// (empty on failure). Named by the source stem + an 8-hex FNV hash of the full source path, so +// re-picking the same file is idempotent and two different files never clobber each other. +static std::string copyAvatarImage(const std::string& srcPath) { + namespace fs = std::filesystem; + std::error_code ec; + fs::path src(srcPath); + if (!fs::is_regular_file(src, ec)) return ""; + fs::path dir = fs::path(util::Platform::getConfigDir()) / "contact-avatars"; + fs::create_directories(dir, ec); + uint64_t h = 1469598103934665603ull; // FNV-1a + for (unsigned char c : srcPath) { h ^= c; h *= 1099511628211ull; } + char suffix[9]; + snprintf(suffix, sizeof(suffix), "%08x", (unsigned)(h & 0xffffffffu)); + std::string stem = src.stem().string(); + if (stem.size() > 40) stem.resize(40); + fs::path dst = dir / (stem + "-" + suffix + src.extension().string()); + fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec); + return ec ? std::string() : dst.string(); +} + void RenderContactsTab(App* app) { auto& S = schema::UI(); @@ -134,6 +171,9 @@ void RenderContactsTab(App* app) s_edit_address[0] = '\0'; s_edit_notes[0] = '\0'; s_edit_global = false; // new contacts default to the current wallet + s_edit_avatar.clear(); + s_edit_avatar_mode = 0; + s_edit_icon_search[0] = '\0'; }; auto loadEditFields = [](const data::AddressBookEntry& entry) { @@ -141,6 +181,10 @@ void RenderContactsTab(App* app) copyEditField(s_edit_address, sizeof(s_edit_address), entry.address); copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes); s_edit_global = entry.isGlobal(); + s_edit_avatar = entry.avatar; + s_edit_avatar_mode = (entry.avatar.rfind("icon:", 0) == 0) ? 1 + : (entry.avatar.rfind("img:", 0) == 0) ? 2 : 0; + s_edit_icon_search[0] = '\0'; }; bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast(book.size()); @@ -170,31 +214,84 @@ void RenderContactsTab(App* app) s_focus_edit_field = true; }; - // Add/edit form — a modal popup layered over the tab. + // Add/edit form — a modal popup layered over the tab, with a live contact preview and an avatar + // picker (default Z/T badge, a Material icon, or a custom image via the in-app image picker). auto renderEntryDialog = [&]() { + // The image picker takes over the modal surface while it's up (the overlay framework doesn't + // nest) — render it instead of the edit dialog, exactly as WalletsDialog does for FolderPicker. + if (ImagePicker::isOpen()) { ImagePicker::render(); return; } + bool isEdit = s_show_edit_dialog; bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog; if (!*open) return; const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add"); const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd"; - // DPI: the Layout::kDialog* helpers fold dpiScale() (physical px) but the raw schema widths are - // logical, so scale the schema-path values to keep both branches consistent physical px inside - // the (native-scale) overlay. cardWidth is the exception — BeginOverlayDialog re-applies dpiScale, - // so it must be LOGICAL; divide the already-scaled dialogW back out. const float dp = Layout::dpiScale(); - float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth()) / dp; - float formW = addrInput.width > 0 ? addrInput.width * dp : Layout::kDialogFormWidth(); - float actionW = actionBtn.width > 0 ? actionBtn.width * dp : Layout::kDialogActionWidth(); - float actionGap = actionBtn.gap > 0 ? actionBtn.gap * dp : Layout::kDialogActionGap(); - float notesH = (notesInput.height > 0 ? notesInput.height : 60.0f) * dp; + const bool light = material::IsLightTheme(); + float formW = -1.0f; // stretch inputs to the card content width + float notesH = (notesInput.height > 0 ? notesInput.height : 56.0f) * dp; + ImFont* btnFont = S.resolveFont(actionBtn.font); material::OverlayDialogSpec ov; ov.title = title; ov.p_open = open; ov.style = material::OverlayStyle::BlurFloat; - ov.cardWidth = dialogW; ov.idSuffix = id; - ov.cardBottomViewportRatio = Layout::kDialogCompactBottomRatio(); + ov.cardWidth = 560.0f; ov.idSuffix = id; // wider than a plain form — holds the icon grid + ov.cardBottomViewportRatio = 0.92f; if (material::BeginOverlayDialog(ov)) { + // End-truncate a string to fit maxW (whole UTF-8 code points), appending an ellipsis. + auto fitText = [&](std::string s, ImFont* f, float maxW) { + bool t = false; + while (s.size() > 1 && f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x > maxW) { + while (s.size() > 1 && (static_cast(s.back()) & 0xC0) == 0x80) s.pop_back(); + if (s.size() > 1) s.pop_back(); + t = true; + } + if (t) s += "\xE2\x80\xA6"; + return s; + }; + + // ---- Live preview: the contact card as it will appear in the list -------------------- + { + ImDrawList* pdl = ImGui::GetWindowDrawList(); + ImFont* nameF = material::Type().subtitle1(); + ImFont* addrF = material::Type().caption(); + ImFont* letF = material::Type().subtitle2(); + ImFont* icoF = material::Type().iconSmall(); + const float pad = Layout::spacingMd(); + const float avR = 20.0f * dp; + const float blockH = nameF->LegacySize + Layout::spacingXs() + addrF->LegacySize; + const float ph = pad * 2.0f + std::max(avR * 2.0f, blockH); + const float pw = ImGui::GetContentRegionAvail().x; + ImVec2 pMin = ImGui::GetCursorScreenPos(); + ImVec2 pMax(pMin.x + pw, pMin.y + ph); + material::GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = 22; g.borderAlpha = 40; + material::DrawGlassPanel(pdl, pMin, pMax, g); + + data::AddressBookEntry pv; + pv.label = s_edit_label; pv.address = s_edit_address; pv.avatar = s_edit_avatar; + const bool sh = isShieldedAddr(s_edit_address); + const ImU32 tu = contactTypeColor(sh, light); + ImVec2 avC(pMin.x + pad + avR, pMin.y + ph * 0.5f); + drawContactAvatar(pdl, avC, avR, pv, sh, tu, light, dp, letF, icoF); + + float tx = avC.x + avR + Layout::spacingMd(); + float textRight = pMax.x - pad; + float ty = pMin.y + (ph - blockH) * 0.5f; + const bool hasName = s_edit_label[0] != '\0'; + std::string nm = fitText(hasName ? std::string(s_edit_label) : std::string(TR("contact_preview_name")), + nameF, textRight - tx); + pdl->AddText(nameF, nameF->LegacySize, ImVec2(tx, ty), + hasName ? material::OnSurface() : material::OnSurfaceDisabled(), nm.c_str()); + const bool hasAddr = s_edit_address[0] != '\0'; + std::string ad = fitText(hasAddr ? std::string(s_edit_address) : std::string(TR("contact_preview_addr")), + addrF, textRight - tx); + pdl->AddText(addrF, addrF->LegacySize, ImVec2(tx, ty + nameF->LegacySize + Layout::spacingXs()), + hasAddr ? material::OnSurfaceMedium() : material::OnSurfaceDisabled(), ad.c_str()); + ImGui::Dummy(ImVec2(pw, ph)); + } + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // Focus the first field the frame the dialog opens so it's keyboard-ready. if (s_focus_edit_field) { ImGui::SetKeyboardFocusHere(); @@ -205,11 +302,17 @@ void RenderContactsTab(App* app) ImGui::Spacing(); - material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress", - s_edit_address, sizeof(s_edit_address), formW); - if (!isEdit) { - ImGui::SameLine(); - if (material::TactileButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) { + // Address + inline Paste (available for both add and edit — handy when correcting an address). + { + float pasteW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("paste")).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 12.0f * dp; + float addrW = ImGui::GetContentRegionAvail().x - pasteW - ImGui::GetStyle().ItemSpacing.x; + material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress", + s_edit_address, sizeof(s_edit_address), addrW); + ImGui::SameLine(0, ImGui::GetStyle().ItemSpacing.x); + // Bottom-align the button with the input box (skip the label line above the input). + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + ImGui::GetTextLineHeightWithSpacing()); + if (material::TactileButton(TR("paste"), ImVec2(pasteW, 0), btnFont)) { const char* clipboard = ImGui::GetClipboardText(); if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard); } @@ -224,14 +327,129 @@ void RenderContactsTab(App* app) ImGui::Checkbox(TR("contact_global"), &s_edit_global); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_tt")); + // ---- Avatar picker: Badge / Icon / Image ------------------------------------------- + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR("contact_avatar")); + { + const char* segs[3] = { TR("contact_avatar_badge"), TR("contact_avatar_icon"), + TR("contact_avatar_image") }; + float segH = 30.0f * dp; + float segW = ImGui::GetContentRegionAvail().x; + ImVec2 sMin = ImGui::GetCursorScreenPos(); + int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), sMin, segW, segH, + segs, 3, s_edit_avatar_mode, btnFont, "##avseg", dp); + if (clk >= 0 && clk != s_edit_avatar_mode) { + s_edit_avatar_mode = clk; + // Switching mode resets the value unless it already matches that mode's prefix. + if (clk == 0) s_edit_avatar.clear(); + else if (clk == 1 && s_edit_avatar.rfind("icon:", 0) != 0) s_edit_avatar.clear(); + else if (clk == 2 && s_edit_avatar.rfind("img:", 0) != 0) s_edit_avatar.clear(); + } + ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH)); + ImGui::Dummy(ImVec2(segW, 0)); + } + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + + // Fixed-height area so the modal height doesn't jump when switching avatar modes. + const float avatarAreaH = 176.0f * dp; + ImGui::BeginChild("##avArea", ImVec2(ImGui::GetContentRegionAvail().x, avatarAreaH), false, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + if (s_edit_avatar_mode == 0) { + // Badge: centered explanatory hint. + const char* msg = TR("contact_avatar_badge_hint"); + ImFont* f = material::Type().caption(); + ImVec2 a = ImGui::GetContentRegionAvail(); + ImVec2 ts = f->CalcTextSizeA(f->LegacySize, a.x - 16.0f * dp, 0, msg); + ImGui::SetCursorPos(ImVec2(std::max(0.0f, (a.x - ts.x) * 0.5f), a.y * 0.4f)); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + std::min(ts.x, a.x)); + material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceDisabled(), msg); + ImGui::PopTextWrapPos(); + } else if (s_edit_avatar_mode == 1) { + // Icon grid: search on top, then a scrollable name-filtered grid. + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##avIconSearch", TR("portfolio_search_icons"), + s_edit_icon_search, sizeof(s_edit_icon_search)); + std::string needle = toLower(s_edit_icon_search); + std::vector vis; + for (int i = 0; i < material::project_icons::walletIconCount(); ++i) { + const char* nm = material::project_icons::walletIconName(i); + if (needle.empty() || toLower(nm).find(needle) != std::string::npos) vis.push_back(i); + } + ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0)); + ImGui::BeginChild("##avIconGrid", ImVec2(-1, ImGui::GetContentRegionAvail().y), false); + ImDrawList* gdl = ImGui::GetWindowDrawList(); + ImFont* gIcoF = material::Type().iconXL(); + if (vis.empty()) + material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceDisabled(), TR("no_icons_found")); + const float cellGap = 6.0f * dp; + const float availW = ImGui::GetContentRegionAvail().x; + const int cols = std::max(4, (int)((availW + cellGap) / (54.0f * dp + cellGap))); + const float cell = std::max(28.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols); + int col = 0; + for (int vi = 0; vi < (int)vis.size(); ++vi) { + int i = vis[vi]; + const char* nm = material::project_icons::walletIconName(i); + if (col != 0) ImGui::SameLine(0, cellGap); + ImVec2 mn = ImGui::GetCursorScreenPos(); + ImVec2 mx(mn.x + cell, mn.y + cell); + ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f); + bool hov = ImGui::IsMouseHoveringRect(mn, mx); + bool sel = (s_edit_avatar == std::string("icon:") + nm); + if (sel) { + gdl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 40), 6.0f * dp); + gdl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 120), 6.0f * dp, 0, 1.5f * dp); + } else if (hov) { + gdl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp); + } + ImU32 icol = sel ? material::Primary() : (hov ? material::OnSurface() : material::OnSurfaceMedium()); + material::project_icons::drawByName(gdl, nm, cc, icol, gIcoF, cell * 0.5f); + ImGui::PushID(i); + ImGui::InvisibleButton("##avic", ImVec2(cell, cell)); + if (ImGui::IsItemClicked()) s_edit_avatar = std::string("icon:") + nm; + if (hov) material::Tooltip("%s", nm); + ImGui::PopID(); + col = (col + 1) % cols; + } + ImGui::EndChild(); + ImGui::PopStyleColor(); + } else { + // Image: choose / remove a custom picture (copied into app data on pick). + if (s_edit_avatar.rfind("img:", 0) == 0) { + std::string path = s_edit_avatar.substr(4); + std::string fname = std::filesystem::path(path).filename().string(); + material::Type().textColored(material::TypeStyle::Body2, material::OnSurface(), + fitText(fname, material::Type().body2(), ImGui::GetContentRegionAvail().x).c_str()); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + } + if (material::TactileButton(TR("contact_avatar_choose"), ImVec2(0, 0), btnFont)) { + ImagePicker::open("", [](const std::string& src) { + std::string dst = copyAvatarImage(src); + if (!dst.empty()) { s_edit_avatar = "img:" + dst; s_edit_avatar_mode = 2; } + else Notifications::instance().error(TR("contact_avatar_copy_failed")); + }); + } + if (s_edit_avatar.rfind("img:", 0) == 0) { + ImGui::SameLine(); + if (material::TactileButton(TR("contact_avatar_remove"), ImVec2(0, 0), btnFont)) + s_edit_avatar.clear(); + } + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceDisabled(), TR("contact_avatar_image_hint")); + } + ImGui::EndChild(); // ##avArea + bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0; + float actionW = std::max(110.0f * dp, + btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("save")).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp); + float actionGap = Layout::spacingSm(); float totalActionsW = actionW * 2.0f + actionGap; - material::BeginOverlayDialogFooter(totalActionsW); + material::BeginOverlayDialogFooter(totalActionsW, /*drawSeparator=*/false); if (!canSubmit) ImGui::BeginDisabled(); const char* primaryLabel = isEdit ? TR("save") : TR("add"); - if (material::TactileButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) { + if (material::TactileButton(primaryLabel, ImVec2(actionW, 0), btnFont)) { // Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is. auto trimAB = [](std::string s) { while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin()); @@ -239,6 +457,7 @@ void RenderContactsTab(App* app) return s; }; data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes); + entry.avatar = s_edit_avatar; // Global if the user asked, or as a safe fallback when we don't yet know the wallet // (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet. entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash; @@ -262,7 +481,7 @@ void RenderContactsTab(App* app) if (!canSubmit) ImGui::EndDisabled(); ImGui::SameLine(0, actionGap); - if (material::TactileButton(TR("cancel"), ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) { + if (material::TactileButton(TR("cancel"), ImVec2(actionW, 0), btnFont)) { *open = false; } @@ -383,11 +602,7 @@ void RenderContactsTab(App* app) if (listH < 120.0f * dp) listH = 120.0f * dp; const bool lightTheme = material::IsLightTheme(); - auto typeColor = [&](bool shielded) -> ImU32 { - ImVec4 c = shielded ? (lightTheme ? ImVec4(0.10f,0.55f,0.38f,1.0f) : ImVec4(0.35f,0.80f,0.60f,1.0f)) - : (lightTheme ? ImVec4(0.72f,0.48f,0.05f,1.0f) : ImVec4(0.95f,0.72f,0.30f,1.0f)); - return ImGui::ColorConvertFloat4ToU32(c); - }; + auto typeColor = [&](bool shielded) -> ImU32 { return contactTypeColor(shielded, lightTheme); }; // Centered Material empty state (no contacts yet / no search match). auto emptyState = [&](const char* iconGlyph, const char* msgKey) { const char* msg = TR(msgKey); @@ -655,5 +870,28 @@ void RenderContactsTab(App* app) renderEntryDialog(); } +void ContactsSweepOpenEditDialog(int avatarMode) +{ + s_selected_index = 0; // seedSweepContacts seeds entry 0 as a valid target + copyEditField(s_edit_label, sizeof(s_edit_label), "drgx pool payout address"); + copyEditField(s_edit_address, sizeof(s_edit_address), + "zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000"); + copyEditField(s_edit_notes, sizeof(s_edit_notes), "mining pool payouts"); + s_edit_global = true; + s_edit_avatar_mode = (avatarMode < 0 || avatarMode > 2) ? 0 : avatarMode; + s_edit_avatar = (s_edit_avatar_mode == 1) ? "icon:account_balance" : ""; + s_edit_icon_search[0] = '\0'; + s_show_add_dialog = false; + s_show_edit_dialog = true; + s_focus_edit_field = false; +} + +void ContactsSweepCloseDialog() +{ + s_show_edit_dialog = false; + s_show_add_dialog = false; + ImagePicker::close(); +} + } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/contacts_tab.h b/src/ui/windows/contacts_tab.h index c735619..71d298a 100644 --- a/src/ui/windows/contacts_tab.h +++ b/src/ui/windows/contacts_tab.h @@ -21,5 +21,11 @@ namespace ui { */ void RenderContactsTab(App* app); +// UI-sweep ONLY: open the revamped add/edit dialog on a seeded demo contact, in the given avatar +// mode (0 = badge, 1 = icon, 2 = image), so the sweep can capture the preview + avatar picker. +// Pair with ContactsSweepCloseDialog(). Do not use outside the sweep. +void ContactsSweepOpenEditDialog(int avatarMode); +void ContactsSweepCloseDialog(); + } // namespace ui } // namespace dragonx diff --git a/src/ui/windows/image_picker.h b/src/ui/windows/image_picker.h new file mode 100644 index 0000000..00bbafb --- /dev/null +++ b/src/ui/windows/image_picker.h @@ -0,0 +1,371 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// In-app, Material-styled image picker. A modal overlay that browses the filesystem and lets the +// user pick an image file, shown as a thumbnail grid. Used by the Contacts edit dialog to choose a +// custom contact avatar. Like FolderPicker, only one overlay renders at a time (the framework does +// not nest), so the caller suppresses its own overlay while ImagePicker::isOpen(). +// +// Thumbnails are decoded to raw pixels, box-downscaled to a small texture, and cached per directory +// (destroyed on navigate/close) so browsing a Pictures folder full of large photos stays cheap. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "imgui.h" + +#include "../../util/i18n.h" +#include "../../util/platform.h" +#include "../../util/texture_loader.h" +#include "../../embedded/IconsMaterialDesign.h" +#include "../layout.h" +#include "../material/colors.h" +#include "../material/draw_helpers.h" +#include "../material/type.h" + +namespace dragonx { +namespace ui { + +class ImagePicker { +public: + // Open the picker starting at `startDir` (falls back to the user's Pictures dir, then home, if + // empty/invalid). `onPick` receives the chosen absolute image path when the user confirms. + static void open(const std::string& startDir, std::function onPick) { + namespace fs = std::filesystem; + std::error_code ec; + std::string start = startDir; + if (start.empty() || !fs::is_directory(fs::path(start), ec)) { + if (!s_dir.empty() && fs::is_directory(fs::path(s_dir), ec)) start = s_dir; // reopen last + else start = defaultStartDir(); + } + navigate(start); + s_onPick = std::move(onPick); + s_selected.clear(); + s_open = true; + } + + static bool isOpen() { return s_open; } + static void close() { s_open = false; clearThumbs(); } + + static void render() { + if (!s_open) return; + using namespace material; + const float dp = Layout::dpiScale(); + + s_loadedThisFrame = 0; // budget: decode at most a few thumbnails per frame (no scroll hitch) + + ImFont* rowFont = Type().body1(); + ImFont* icoFont = Type().iconSmall(); + ImFont* metaFont = Type().caption(); + + const float vpH = ImGui::GetMainViewport()->Size.y; + const float cardH = (vpH * 0.82f) / dp; // logical; the framework re-applies dp + caps at vp-32 + + OverlayDialogSpec ov; + ov.title = TR("img_picker_title"); + ov.p_open = &s_open; + ov.style = OverlayStyle::BlurFloat; + ov.cardWidth = 760.0f; + ov.cardHeight = cardH; + ov.idSuffix = "imagepicker"; + if (!BeginOverlayDialog(ov)) { if (!s_open) clearThumbs(); return; } + + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_open = false; + + auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; }; + auto fitPath = [&](std::string s, ImFont* f, float maxW){ + const std::string ell = "\xE2\x80\xA6"; + if (tw(f,s) <= maxW) return s; + while (s.size()>1 && tw(f, ell+s) > maxW){ + s.erase(s.begin()); + while (!s.empty() && (static_cast(s.front()) & 0xC0) == 0x80) s.erase(s.begin()); + } + return ell + s; + }; + auto fit = [&](std::string s, ImFont* f, float maxW){ + bool t=false; + while (s.size()>1 && tw(f,s)>maxW){ + while (s.size()>1 && (static_cast(s.back()) & 0xC0) == 0x80) s.pop_back(); + if (s.size()>1) s.pop_back(); + t=true; + } + if (t) s += "\xE2\x80\xA6"; + return s; + }; + + std::string pendingNav; + + // ---- Path bar: Up + Home + Pictures + current path strip ------------------------------ + { + const float bh = ImGui::GetFrameHeight(); + IconButtonStyle st; st.color = OnSurfaceMedium(); st.hoverBg = StateHover(); + st.bgRounding = 5.0f * dp; + std::filesystem::path cur(s_dir); + const bool hasParent = cur.has_parent_path() && cur.parent_path() != cur; + st.tooltip = TR("picker_up"); + if (IconButton("##imgUp", ICON_MD_ARROW_UPWARD, icoFont, ImVec2(bh, bh), st) && hasParent) + pendingNav = cur.parent_path().string(); + ImGui::SameLine(0.0f, Layout::spacingXs()); + IconButtonStyle hst = st; hst.tooltip = TR("picker_home"); + if (IconButton("##imgHome", ICON_MD_HOME, icoFont, ImVec2(bh, bh), hst)) + pendingNav = util::Platform::getHomeDir(); + ImGui::SameLine(0.0f, Layout::spacingXs()); + IconButtonStyle pst = st; pst.tooltip = TR("img_picker_pictures"); + if (IconButton("##imgPics", ICON_MD_IMAGE, icoFont, ImVec2(bh, bh), pst)) + pendingNav = defaultStartDir(); + ImGui::SameLine(0.0f, Layout::spacingSm()); + + ImVec2 sMin = ImGui::GetCursorScreenPos(); + const float stripW = ImGui::GetContentRegionAvail().x; + ImVec2 sMax(sMin.x + stripW, sMin.y + bh); + ImDrawList* wdl = ImGui::GetWindowDrawList(); + wdl->AddRectFilled(sMin, sMax, WithAlpha(OnSurface(), 14), 6.0f * dp); + wdl->AddRect(sMin, sMax, WithAlpha(OnSurface(), 40), 6.0f * dp, 0, 1.0f); + const float tpad = Layout::spacingSm(); + wdl->AddText(rowFont, rowFont->LegacySize, + ImVec2(sMin.x + tpad, sMin.y + (bh - rowFont->LegacySize) * 0.5f), + OnSurface(), fitPath(s_dir, rowFont, stripW - tpad * 2.0f).c_str()); + ImGui::Dummy(ImVec2(stripW, bh)); + } + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + + // ---- Body: subfolder rows (to navigate) then a thumbnail grid of images ---------------- + const ImGuiStyle& gstyle = ImGui::GetStyle(); + const float footerBlockH = Layout::spacingXs() + metaFont->LegacySize // status line + + Layout::spacingSm() + 1.0f + Layout::spacingSm() // separator block + + ImGui::GetFrameHeight() // footer buttons + + 6.0f * gstyle.ItemSpacing.y; + float listH = ImGui::GetContentRegionAvail().y - footerBlockH; + listH = std::max(listH, 160.0f * dp); + const float fullW = ImGui::GetContentRegionAvail().x; + ImGui::PushStyleColor(ImGuiCol_ChildBg, WithAlpha(OnSurface(), 20)); + ImGui::BeginChild("##imgList", ImVec2(fullW, listH), true); + ImDrawList* dl = ImGui::GetWindowDrawList(); + const float padX = Layout::spacingSm(); + + if (s_subdirs.empty() && s_images.empty()) { + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::Indent(padX); + Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("img_picker_empty")); + ImGui::Unindent(padX); + } + + // Folder rows (compact, clickable to descend). + { + const float rowH = rowFont->LegacySize + Layout::spacingSm(); + const float rowW = ImGui::GetContentRegionAvail().x; + for (std::size_t i = 0; i < s_subdirs.size(); ++i) { + ImVec2 rMin = ImGui::GetCursorScreenPos(); + ImVec2 rMax(rMin.x + rowW, rMin.y + rowH); + ImGui::PushID((int)(i + 1)); + const bool clicked = ImGui::InvisibleButton("##idir", ImVec2(rowW, rowH)); + const bool hov = ImGui::IsItemHovered(); + ImGui::PopID(); + if (hov) { dl->AddRectFilled(rMin, rMax, WithAlpha(OnSurface(), 22), 6.0f * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } + const float midY = (rMin.y + rMax.y) * 0.5f; + const float icoSz = rowFont->LegacySize * 1.05f; + float x = rMin.x + padX; + dl->AddText(icoFont, icoSz, ImVec2(x, midY - icoSz * 0.5f), hov ? Primary() : OnSurfaceMedium(), ICON_MD_FOLDER); + x += icoSz + Layout::spacingSm(); + std::string nm = fit(s_subdirs[i], rowFont, (rMax.x - padX) - x); + dl->AddText(rowFont, rowFont->LegacySize, ImVec2(x, midY - rowFont->LegacySize * 0.5f), OnSurface(), nm.c_str()); + if (clicked) pendingNav = (std::filesystem::path(s_dir) / s_subdirs[i]).string(); + } + } + + // Thumbnail grid. + { + const float availW = ImGui::GetContentRegionAvail().x; + const float cell = 96.0f * dp; + const float gap = Layout::spacingSm(); + const int cols = std::max(1, (int)((availW + gap) / (cell + gap))); + int col = 0; + for (std::size_t i = 0; i < s_images.size(); ++i) { + if (col != 0) ImGui::SameLine(0, gap); + ImVec2 mn = ImGui::GetCursorScreenPos(); + ImVec2 mx(mn.x + cell, mn.y + cell); + const std::string abs = (std::filesystem::path(s_dir) / s_images[i]).string(); + const bool sel = (s_selected == abs); + ImGui::PushID((int)(i + 1)); + const bool clicked = ImGui::InvisibleButton("##thumb", ImVec2(cell, cell)); + const bool dbl = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && ImGui::IsItemHovered(); + const bool hov = ImGui::IsItemHovered(); + ImGui::PopID(); + + dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 18), 6.0f * dp); + // Only decode thumbnails for cells actually on-screen, and only a few per frame. + const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr; + if (t && t->tex) { + float u0=0,v0=0,u1=1,v1=1; // centre-crop to a square + if (t->w > t->h) { float m=(t->w-t->h)*0.5f/t->w; u0=m; u1=1-m; } + else if (t->h > t->w) { float m=(t->h-t->w)*0.5f/t->h; v0=m; v1=1-m; } + const float ins = 3.0f * dp; + dl->AddImageRounded(t->tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins), + ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, 5.0f * dp); + } else { + ImVec2 cc(mn.x + cell*0.5f, mn.y + cell*0.5f); + dl->AddText(icoFont, cell*0.34f, ImVec2(cc.x - cell*0.17f, cc.y - cell*0.17f), + OnSurfaceDisabled(), ICON_MD_IMAGE); + } + if (sel) dl->AddRect(mn, mx, WithAlpha(Primary(), 220), 6.0f * dp, 0, 2.0f * dp); + else if (hov) { dl->AddRect(mn, mx, WithAlpha(OnSurface(), 90), 6.0f * dp, 0, 1.5f * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } + if (hov) material::Tooltip("%s", s_images[i].c_str()); + + if (dbl && s_onPick) { s_onPick(abs); s_open = false; } + else if (clicked) s_selected = abs; + + col = (col + 1) % cols; + } + } + + ImGui::EndChild(); + ImGui::PopStyleColor(); + + // ---- Status ---------------------------------------------------------------------------- + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + if (!s_images.empty()) { + char buf[96]; + snprintf(buf, sizeof(buf), TR("img_picker_count"), (int)s_images.size()); + Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf); + } else { + Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("img_picker_none")); + } + + // ---- Footer: Use image / Cancel -------------------------------------------------------- + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::Separator(); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::BeginDisabled(s_selected.empty()); + if (StyledButton(TR("img_picker_use"), ImVec2(200.0f * dp, 0))) { + if (s_onPick && !s_selected.empty()) s_onPick(s_selected); + s_open = false; + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (StyledButton(TR("cancel"), ImVec2(120.0f * dp, 0))) s_open = false; + + EndOverlayDialog(); + + if (!pendingNav.empty()) navigate(pendingNav); + if (!s_open) clearThumbs(); // release GL textures the frame the picker dismisses + } + +private: + struct Thumb { ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; }; + + static bool isImageName(const std::string& name) { + auto lower = name; + for (char& c : lower) c = (char)std::tolower((unsigned char)c); + static const char* kExt[] = { ".png", ".jpg", ".jpeg", ".bmp", ".gif" }; + for (const char* e : kExt) { + const std::string ext(e); + if (lower.size() > ext.size() && lower.compare(lower.size()-ext.size(), ext.size(), ext) == 0) + return true; + } + return false; + } + + static std::string defaultStartDir() { + namespace fs = std::filesystem; + std::error_code ec; + const std::string home = util::Platform::getHomeDir(); + fs::path pics = fs::path(home) / "Pictures"; + if (fs::is_directory(pics, ec)) return pics.string(); + return home; + } + + // Lazy, budgeted thumbnail loader: decodes to raw pixels, box-downscales to <=THUMB px, uploads a + // small texture. Returns null (placeholder icon shown) until the budget lets it load. + static const Thumb* thumbFor(const std::string& absPath) { + auto it = s_thumbs.find(absPath); + if (it != s_thumbs.end()) return &it->second; + if (s_loadedThisFrame >= kLoadsPerFrame) return nullptr; // defer to a later frame + s_loadedThisFrame++; + Thumb th; th.tried = true; + int sw = 0, sh = 0; + unsigned char* raw = util::LoadRawPixelsFromFile(absPath.c_str(), &sw, &sh); + if (raw && sw > 0 && sh > 0) { + int dw = sw, dh = sh; + if (sw > kThumbPx || sh > kThumbPx) { + float s = (float)kThumbPx / (float)std::max(sw, sh); + dw = std::max(1, (int)(sw * s)); + dh = std::max(1, (int)(sh * s)); + } + std::vector small((size_t)dw * dh * 4); + for (int y = 0; y < dh; y++) { + int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh); + for (int x = 0; x < dw; x++) { + int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw); + uint32_t r=0,g=0,b=0,a=0,n=0; + for (int yy = sy0; yy < sy1; yy++) + for (int xx = sx0; xx < sx1; xx++) { + const unsigned char* p = raw + ((size_t)yy * sw + xx) * 4; + r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n; + } + unsigned char* d = small.data() + ((size_t)y * dw + x) * 4; + d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n); + } + } + if (util::CreateRawTexture(small.data(), dw, dh, false, &th.tex)) { th.w = dw; th.h = dh; } + } + if (raw) util::FreeRawPixels(raw); + auto& slot = (s_thumbs[absPath] = th); + return &slot; + } + + static void clearThumbs() { + for (auto& kv : s_thumbs) if (kv.second.tex) util::DestroyTexture(kv.second.tex); + s_thumbs.clear(); + } + + static void navigate(const std::string& dir) { + namespace fs = std::filesystem; + std::error_code ec; + fs::path p(dir); + if (!fs::is_directory(p, ec)) return; + clearThumbs(); // thumbnails are per-directory + fs::path abs = fs::absolute(p, ec); + s_dir = ec ? dir : abs.lexically_normal().string(); + s_subdirs.clear(); + s_images.clear(); + fs::directory_iterator it(p, ec), end; + int guard = 0; + for (; !ec && it != end && guard < 8000; it.increment(ec), ++guard) { + std::error_code fec; + std::string name = it->path().filename().string(); + if (name.empty() || name[0] == '.') continue; // skip hidden/dotfiles + if (it->is_directory(fec)) s_subdirs.push_back(name); + else if (it->is_regular_file(fec) && isImageName(name)) s_images.push_back(name); + } + auto ci = [](const std::string& a, const std::string& b){ + return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(), + [](char x, char y){ return std::tolower((unsigned char)x) < std::tolower((unsigned char)y); }); + }; + std::sort(s_subdirs.begin(), s_subdirs.end(), ci); + std::sort(s_images.begin(), s_images.end(), ci); + } + + static constexpr int kThumbPx = 128; // max thumbnail dimension (downscaled) + static constexpr int kLoadsPerFrame = 6; // decode budget per frame (spreads a big folder over frames) + + static inline bool s_open = false; + static inline int s_loadedThisFrame = 0; + static inline std::string s_dir; + static inline std::string s_selected; + static inline std::vector s_subdirs; + static inline std::vector s_images; + static inline std::unordered_map s_thumbs; + static inline std::function s_onPick; +}; + +} // namespace ui +} // namespace dragonx diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index fae3d18..c80ff12 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -306,6 +306,13 @@ void I18n::loadBuiltinEnglish() strings_["picker_dat_count"] = "%d wallet file(s) in this folder"; strings_["picker_dat_none"] = "No wallet files directly in this folder"; strings_["picker_select"] = "Scan this folder"; + // In-app image picker (contact avatars) + strings_["img_picker_title"] = "Choose an image"; + strings_["img_picker_pictures"] = "Pictures folder"; + strings_["img_picker_empty"] = "This folder has no sub-folders or images."; + strings_["img_picker_none"] = "No images in this folder"; + strings_["img_picker_count"] = "%d image(s) in this folder"; + strings_["img_picker_use"] = "Use image"; strings_["tt_seed_migrate"] = "Create a new seed-phrase wallet and move your funds into it"; // Migrate-to-seed modal strings_["mig_title"] = "Migrate to a seed wallet"; @@ -1222,6 +1229,18 @@ void I18n::loadBuiltinEnglish() strings_["address_book_add_new"] = "Add New"; strings_["contact_global"] = "Show in every wallet (global contact)"; strings_["contact_global_tt"] = "On: this contact stays visible no matter which wallet you load. Off: it belongs to the current wallet only."; + // Contact edit dialog: live preview + avatar picker + strings_["contact_preview_name"] = "Contact name"; + strings_["contact_preview_addr"] = "Address will appear here"; + strings_["contact_avatar"] = "AVATAR"; + strings_["contact_avatar_badge"] = "Badge"; + strings_["contact_avatar_icon"] = "Icon"; + strings_["contact_avatar_image"] = "Image"; + strings_["contact_avatar_badge_hint"] = "Uses the address-type badge — Z for shielded, T for transparent."; + strings_["contact_avatar_choose"] = "Choose image\xE2\x80\xA6"; + strings_["contact_avatar_remove"] = "Remove"; + strings_["contact_avatar_image_hint"] = "The image is copied into the app so it stays available if the original moves."; + strings_["contact_avatar_copy_failed"] = "Could not copy that image."; strings_["contact_global_badge_tt"] = "Global contact — visible in every wallet"; strings_["address_book_added"] = "Address added to book"; strings_["address_book_count"] = "%zu addresses saved";