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 <config>/contact-avatars/ (named by an
FNV hash of the source path, so re-picking is idempotent) and stored as
"img:<path>". 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) <noreply@anthropic.com>
This commit is contained in:
@@ -287,9 +287,20 @@
|
|||||||
"console_welcome": "Willkommen bei ObsidianDragon Konsole",
|
"console_welcome": "Willkommen bei ObsidianDragon Konsole",
|
||||||
"console_zoom_in": "Vergrößern",
|
"console_zoom_in": "Vergrößern",
|
||||||
"console_zoom_out": "Verkleinern",
|
"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": "In jeder Wallet anzeigen (globaler Kontakt)",
|
||||||
"contact_global_badge_tt": "Globaler Kontakt — in jeder Wallet sichtbar",
|
"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_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": "Kontakte",
|
||||||
"contacts_search_no_match": "Keine passenden Kontakte",
|
"contacts_search_no_match": "Keine passenden Kontakte",
|
||||||
"contacts_search_placeholder": "Kontakte durchsuchen...",
|
"contacts_search_placeholder": "Kontakte durchsuchen...",
|
||||||
@@ -465,6 +476,12 @@
|
|||||||
"hide_qr": "QR ausblenden",
|
"hide_qr": "QR ausblenden",
|
||||||
"hide_zero_balances": "Nullsalden ausblenden",
|
"hide_zero_balances": "Nullsalden ausblenden",
|
||||||
"history": "Verlauf",
|
"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",
|
"immature_type": "Unreif",
|
||||||
"import": "Importieren",
|
"import": "Importieren",
|
||||||
"import_key_address": "Adresse:",
|
"import_key_address": "Adresse:",
|
||||||
|
|||||||
@@ -287,9 +287,20 @@
|
|||||||
"console_welcome": "Bienvenido a la Consola de ObsidianDragon",
|
"console_welcome": "Bienvenido a la Consola de ObsidianDragon",
|
||||||
"console_zoom_in": "Acercar",
|
"console_zoom_in": "Acercar",
|
||||||
"console_zoom_out": "Alejar",
|
"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": "Mostrar en todas las carteras (contacto global)",
|
||||||
"contact_global_badge_tt": "Contacto global — visible en todas las carteras",
|
"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_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": "Contactos",
|
||||||
"contacts_search_no_match": "No hay contactos coincidentes",
|
"contacts_search_no_match": "No hay contactos coincidentes",
|
||||||
"contacts_search_placeholder": "Buscar contactos...",
|
"contacts_search_placeholder": "Buscar contactos...",
|
||||||
@@ -465,6 +476,12 @@
|
|||||||
"hide_qr": "Ocultar QR",
|
"hide_qr": "Ocultar QR",
|
||||||
"hide_zero_balances": "Ocultar saldos 0",
|
"hide_zero_balances": "Ocultar saldos 0",
|
||||||
"history": "Historial",
|
"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",
|
"immature_type": "Inmaduro",
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
"import_key_address": "Dirección:",
|
"import_key_address": "Dirección:",
|
||||||
|
|||||||
@@ -287,9 +287,20 @@
|
|||||||
"console_welcome": "Bienvenue dans la console ObsidianDragon",
|
"console_welcome": "Bienvenue dans la console ObsidianDragon",
|
||||||
"console_zoom_in": "Agrandir",
|
"console_zoom_in": "Agrandir",
|
||||||
"console_zoom_out": "Réduire",
|
"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": "Afficher dans tous les portefeuilles (contact global)",
|
||||||
"contact_global_badge_tt": "Contact global — visible dans tous les portefeuilles",
|
"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_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": "Contacts",
|
||||||
"contacts_search_no_match": "Aucun contact correspondant",
|
"contacts_search_no_match": "Aucun contact correspondant",
|
||||||
"contacts_search_placeholder": "Rechercher des contacts...",
|
"contacts_search_placeholder": "Rechercher des contacts...",
|
||||||
@@ -465,6 +476,12 @@
|
|||||||
"hide_qr": "Masquer le QR",
|
"hide_qr": "Masquer le QR",
|
||||||
"hide_zero_balances": "Masquer les soldes à 0",
|
"hide_zero_balances": "Masquer les soldes à 0",
|
||||||
"history": "Historique",
|
"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",
|
"immature_type": "Immature",
|
||||||
"import": "Importer",
|
"import": "Importer",
|
||||||
"import_key_address": "Adresse :",
|
"import_key_address": "Adresse :",
|
||||||
|
|||||||
@@ -287,9 +287,20 @@
|
|||||||
"console_welcome": "ObsidianDragonコンソールへようこそ",
|
"console_welcome": "ObsidianDragonコンソールへようこそ",
|
||||||
"console_zoom_in": "拡大",
|
"console_zoom_in": "拡大",
|
||||||
"console_zoom_out": "縮小",
|
"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": "すべてのウォレットで表示(グローバル連絡先)",
|
||||||
"contact_global_badge_tt": "グローバル連絡先 — すべてのウォレットで表示",
|
"contact_global_badge_tt": "グローバル連絡先 — すべてのウォレットで表示",
|
||||||
"contact_global_tt": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。",
|
"contact_global_tt": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。",
|
||||||
|
"contact_preview_addr": "ここにアドレスが表示されます",
|
||||||
|
"contact_preview_name": "連絡先名",
|
||||||
"contacts": "連絡先",
|
"contacts": "連絡先",
|
||||||
"contacts_search_no_match": "一致する連絡先がありません",
|
"contacts_search_no_match": "一致する連絡先がありません",
|
||||||
"contacts_search_placeholder": "連絡先を検索...",
|
"contacts_search_placeholder": "連絡先を検索...",
|
||||||
@@ -465,6 +476,12 @@
|
|||||||
"hide_qr": "QRを非表示",
|
"hide_qr": "QRを非表示",
|
||||||
"hide_zero_balances": "残高0を非表示",
|
"hide_zero_balances": "残高0を非表示",
|
||||||
"history": "履歴",
|
"history": "履歴",
|
||||||
|
"img_picker_count": "このフォルダの画像:%d",
|
||||||
|
"img_picker_empty": "このフォルダにはサブフォルダも画像もありません。",
|
||||||
|
"img_picker_none": "このフォルダに画像はありません",
|
||||||
|
"img_picker_pictures": "画像フォルダ",
|
||||||
|
"img_picker_title": "画像を選択",
|
||||||
|
"img_picker_use": "この画像を使用",
|
||||||
"immature_type": "未成熟",
|
"immature_type": "未成熟",
|
||||||
"import": "インポート",
|
"import": "インポート",
|
||||||
"import_key_address": "アドレス:",
|
"import_key_address": "アドレス:",
|
||||||
|
|||||||
@@ -287,9 +287,20 @@
|
|||||||
"console_welcome": "ObsidianDragon 콘솔에 오신 것을 환영합니다",
|
"console_welcome": "ObsidianDragon 콘솔에 오신 것을 환영합니다",
|
||||||
"console_zoom_in": "확대",
|
"console_zoom_in": "확대",
|
||||||
"console_zoom_out": "축소",
|
"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": "모든 지갑에 표시(전역 연락처)",
|
||||||
"contact_global_badge_tt": "전역 연락처 — 모든 지갑에서 표시됨",
|
"contact_global_badge_tt": "전역 연락처 — 모든 지갑에서 표시됨",
|
||||||
"contact_global_tt": "켜짐: 어떤 지갑을 불러오든 이 연락처가 계속 표시됩니다. 꺼짐: 현재 지갑에만 속합니다.",
|
"contact_global_tt": "켜짐: 어떤 지갑을 불러오든 이 연락처가 계속 표시됩니다. 꺼짐: 현재 지갑에만 속합니다.",
|
||||||
|
"contact_preview_addr": "여기에 주소가 표시됩니다",
|
||||||
|
"contact_preview_name": "연락처 이름",
|
||||||
"contacts": "연락처",
|
"contacts": "연락처",
|
||||||
"contacts_search_no_match": "일치하는 연락처 없음",
|
"contacts_search_no_match": "일치하는 연락처 없음",
|
||||||
"contacts_search_placeholder": "연락처 검색...",
|
"contacts_search_placeholder": "연락처 검색...",
|
||||||
@@ -465,6 +476,12 @@
|
|||||||
"hide_qr": "QR 숨기기",
|
"hide_qr": "QR 숨기기",
|
||||||
"hide_zero_balances": "잔액 0 숨기기",
|
"hide_zero_balances": "잔액 0 숨기기",
|
||||||
"history": "내역",
|
"history": "내역",
|
||||||
|
"img_picker_count": "이 폴더에 이미지 %d개",
|
||||||
|
"img_picker_empty": "이 폴더에는 하위 폴더나 이미지가 없습니다.",
|
||||||
|
"img_picker_none": "이 폴더에 이미지가 없습니다",
|
||||||
|
"img_picker_pictures": "사진 폴더",
|
||||||
|
"img_picker_title": "이미지 선택",
|
||||||
|
"img_picker_use": "이미지 사용",
|
||||||
"immature_type": "미성숙",
|
"immature_type": "미성숙",
|
||||||
"import": "가져오기",
|
"import": "가져오기",
|
||||||
"import_key_address": "주소:",
|
"import_key_address": "주소:",
|
||||||
|
|||||||
@@ -287,9 +287,20 @@
|
|||||||
"console_welcome": "Bem-vindo ao Console ObsidianDragon",
|
"console_welcome": "Bem-vindo ao Console ObsidianDragon",
|
||||||
"console_zoom_in": "Aumentar zoom",
|
"console_zoom_in": "Aumentar zoom",
|
||||||
"console_zoom_out": "Diminuir 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": "Mostrar em todas as carteiras (contato global)",
|
||||||
"contact_global_badge_tt": "Contato global — visível em todas as carteiras",
|
"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_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": "Contatos",
|
||||||
"contacts_search_no_match": "Nenhum contato correspondente",
|
"contacts_search_no_match": "Nenhum contato correspondente",
|
||||||
"contacts_search_placeholder": "Pesquisar contatos...",
|
"contacts_search_placeholder": "Pesquisar contatos...",
|
||||||
@@ -465,6 +476,12 @@
|
|||||||
"hide_qr": "Ocultar QR",
|
"hide_qr": "Ocultar QR",
|
||||||
"hide_zero_balances": "Ocultar saldos zero",
|
"hide_zero_balances": "Ocultar saldos zero",
|
||||||
"history": "Histórico",
|
"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",
|
"immature_type": "Imaturo",
|
||||||
"import": "Importar",
|
"import": "Importar",
|
||||||
"import_key_address": "Endereço:",
|
"import_key_address": "Endereço:",
|
||||||
|
|||||||
@@ -287,9 +287,20 @@
|
|||||||
"console_welcome": "Добро пожаловать в консоль ObsidianDragon",
|
"console_welcome": "Добро пожаловать в консоль ObsidianDragon",
|
||||||
"console_zoom_in": "Увеличить",
|
"console_zoom_in": "Увеличить",
|
||||||
"console_zoom_out": "Уменьшить",
|
"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": "Показывать во всех кошельках (глобальный контакт)",
|
||||||
"contact_global_badge_tt": "Глобальный контакт — виден во всех кошельках",
|
"contact_global_badge_tt": "Глобальный контакт — виден во всех кошельках",
|
||||||
"contact_global_tt": "Вкл.: этот контакт остаётся видимым, какой бы кошелёк вы ни загрузили. Выкл.: он принадлежит только текущему кошельку.",
|
"contact_global_tt": "Вкл.: этот контакт остаётся видимым, какой бы кошелёк вы ни загрузили. Выкл.: он принадлежит только текущему кошельку.",
|
||||||
|
"contact_preview_addr": "Здесь появится адрес",
|
||||||
|
"contact_preview_name": "Имя контакта",
|
||||||
"contacts": "Контакты",
|
"contacts": "Контакты",
|
||||||
"contacts_search_no_match": "Совпадающих контактов нет",
|
"contacts_search_no_match": "Совпадающих контактов нет",
|
||||||
"contacts_search_placeholder": "Поиск контактов...",
|
"contacts_search_placeholder": "Поиск контактов...",
|
||||||
@@ -465,6 +476,12 @@
|
|||||||
"hide_qr": "Скрыть QR",
|
"hide_qr": "Скрыть QR",
|
||||||
"hide_zero_balances": "Скрыть нулевые балансы",
|
"hide_zero_balances": "Скрыть нулевые балансы",
|
||||||
"history": "История",
|
"history": "История",
|
||||||
|
"img_picker_count": "Изображений в этой папке: %d",
|
||||||
|
"img_picker_empty": "В этой папке нет подпапок или изображений.",
|
||||||
|
"img_picker_none": "В этой папке нет изображений",
|
||||||
|
"img_picker_pictures": "Папка изображений",
|
||||||
|
"img_picker_title": "Выбрать изображение",
|
||||||
|
"img_picker_use": "Использовать изображение",
|
||||||
"immature_type": "Незрелая",
|
"immature_type": "Незрелая",
|
||||||
"import": "Импорт",
|
"import": "Импорт",
|
||||||
"import_key_address": "Адрес:",
|
"import_key_address": "Адрес:",
|
||||||
|
|||||||
@@ -287,9 +287,20 @@
|
|||||||
"console_welcome": "欢迎使用 ObsidianDragon 控制台",
|
"console_welcome": "欢迎使用 ObsidianDragon 控制台",
|
||||||
"console_zoom_in": "放大",
|
"console_zoom_in": "放大",
|
||||||
"console_zoom_out": "缩小",
|
"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": "在每个钱包中显示(全局联系人)",
|
||||||
"contact_global_badge_tt": "全局联系人——在每个钱包中可见",
|
"contact_global_badge_tt": "全局联系人——在每个钱包中可见",
|
||||||
"contact_global_tt": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。",
|
"contact_global_tt": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。",
|
||||||
|
"contact_preview_addr": "地址将显示在此处",
|
||||||
|
"contact_preview_name": "联系人名称",
|
||||||
"contacts": "联系人",
|
"contacts": "联系人",
|
||||||
"contacts_search_no_match": "没有匹配的联系人",
|
"contacts_search_no_match": "没有匹配的联系人",
|
||||||
"contacts_search_placeholder": "搜索联系人...",
|
"contacts_search_placeholder": "搜索联系人...",
|
||||||
@@ -465,6 +476,12 @@
|
|||||||
"hide_qr": "隐藏二维码",
|
"hide_qr": "隐藏二维码",
|
||||||
"hide_zero_balances": "隐藏零余额",
|
"hide_zero_balances": "隐藏零余额",
|
||||||
"history": "历史",
|
"history": "历史",
|
||||||
|
"img_picker_count": "此文件夹中的图片:%d",
|
||||||
|
"img_picker_empty": "此文件夹没有子文件夹或图片。",
|
||||||
|
"img_picker_none": "此文件夹中没有图片",
|
||||||
|
"img_picker_pictures": "图片文件夹",
|
||||||
|
"img_picker_title": "选择图片",
|
||||||
|
"img_picker_use": "使用图片",
|
||||||
"immature_type": "未成熟",
|
"immature_type": "未成熟",
|
||||||
"import": "导入",
|
"import": "导入",
|
||||||
"import_key_address": "地址:",
|
"import_key_address": "地址:",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
#include "ui/windows/shield_dialog.h"
|
#include "ui/windows/shield_dialog.h"
|
||||||
#include "ui/windows/address_transfer_dialog.h"
|
#include "ui/windows/address_transfer_dialog.h"
|
||||||
#include "ui/windows/key_export_dialog.h"
|
#include "ui/windows/key_export_dialog.h"
|
||||||
|
#include "ui/windows/contacts_tab.h"
|
||||||
#include "ui/pages/settings_page.h"
|
#include "ui/pages/settings_page.h"
|
||||||
#include "util/platform.h"
|
#include "util/platform.h"
|
||||||
#include "wallet/wallet_capabilities.h"
|
#include "wallet/wallet_capabilities.h"
|
||||||
@@ -408,6 +409,19 @@ void App::buildSweepCatalog()
|
|||||||
add("contacts-table", ui::NavPage::Contacts,
|
add("contacts-table", ui::NavPage::Contacts,
|
||||||
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(2); },
|
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(2); },
|
||||||
[](App& a) { restoreSweepContacts(a); });
|
[](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,
|
add("modal-about", ui::NavPage::Overview,
|
||||||
[](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; });
|
[](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; });
|
||||||
add("modal-settings", ui::NavPage::Settings,
|
add("modal-settings", ui::NavPage::Settings,
|
||||||
|
|||||||
@@ -14,11 +14,15 @@
|
|||||||
#include "../material/type.h"
|
#include "../material/type.h"
|
||||||
#include "../material/project_icons.h"
|
#include "../material/project_icons.h"
|
||||||
#include "../../util/texture_loader.h"
|
#include "../../util/texture_loader.h"
|
||||||
|
#include "../../util/platform.h"
|
||||||
|
#include "image_picker.h"
|
||||||
#include "../layout.h"
|
#include "../layout.h"
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
|
#include <filesystem>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -39,6 +43,10 @@ static char s_edit_address[512] = "";
|
|||||||
static char s_edit_notes[512] = "";
|
static char s_edit_notes[512] = "";
|
||||||
static bool s_edit_global = false; // add/edit dialog: "visible in every wallet" toggle
|
static bool s_edit_global = false; // add/edit dialog: "visible in every wallet" toggle
|
||||||
static char s_search[128] = "";
|
static char s_search[128] = "";
|
||||||
|
// Avatar being edited: "" = default Z/T badge, "icon:<name>", or "img:<abs path>".
|
||||||
|
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) {
|
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
|
||||||
if (destSize == 0) return;
|
if (destSize == 0) return;
|
||||||
@@ -112,6 +120,35 @@ static bool isShieldedAddr(const std::string& a) {
|
|||||||
return !a.empty() && a[0] == 'z';
|
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 <config>/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)
|
void RenderContactsTab(App* app)
|
||||||
{
|
{
|
||||||
auto& S = schema::UI();
|
auto& S = schema::UI();
|
||||||
@@ -134,6 +171,9 @@ void RenderContactsTab(App* app)
|
|||||||
s_edit_address[0] = '\0';
|
s_edit_address[0] = '\0';
|
||||||
s_edit_notes[0] = '\0';
|
s_edit_notes[0] = '\0';
|
||||||
s_edit_global = false; // new contacts default to the current wallet
|
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) {
|
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_address, sizeof(s_edit_address), entry.address);
|
||||||
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
|
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
|
||||||
s_edit_global = entry.isGlobal();
|
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<int>(book.size());
|
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
|
||||||
@@ -170,31 +214,84 @@ void RenderContactsTab(App* app)
|
|||||||
s_focus_edit_field = true;
|
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 = [&]() {
|
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 isEdit = s_show_edit_dialog;
|
||||||
bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog;
|
bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog;
|
||||||
if (!*open) return;
|
if (!*open) return;
|
||||||
|
|
||||||
const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add");
|
const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add");
|
||||||
const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd";
|
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();
|
const float dp = Layout::dpiScale();
|
||||||
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth()) / dp;
|
const bool light = material::IsLightTheme();
|
||||||
float formW = addrInput.width > 0 ? addrInput.width * dp : Layout::kDialogFormWidth();
|
float formW = -1.0f; // stretch inputs to the card content width
|
||||||
float actionW = actionBtn.width > 0 ? actionBtn.width * dp : Layout::kDialogActionWidth();
|
float notesH = (notesInput.height > 0 ? notesInput.height : 56.0f) * dp;
|
||||||
float actionGap = actionBtn.gap > 0 ? actionBtn.gap * dp : Layout::kDialogActionGap();
|
ImFont* btnFont = S.resolveFont(actionBtn.font);
|
||||||
float notesH = (notesInput.height > 0 ? notesInput.height : 60.0f) * dp;
|
|
||||||
|
|
||||||
material::OverlayDialogSpec ov;
|
material::OverlayDialogSpec ov;
|
||||||
ov.title = title; ov.p_open = open;
|
ov.title = title; ov.p_open = open;
|
||||||
ov.style = material::OverlayStyle::BlurFloat;
|
ov.style = material::OverlayStyle::BlurFloat;
|
||||||
ov.cardWidth = dialogW; ov.idSuffix = id;
|
ov.cardWidth = 560.0f; ov.idSuffix = id; // wider than a plain form — holds the icon grid
|
||||||
ov.cardBottomViewportRatio = Layout::kDialogCompactBottomRatio();
|
ov.cardBottomViewportRatio = 0.92f;
|
||||||
if (material::BeginOverlayDialog(ov)) {
|
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<unsigned char>(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.
|
// Focus the first field the frame the dialog opens so it's keyboard-ready.
|
||||||
if (s_focus_edit_field) {
|
if (s_focus_edit_field) {
|
||||||
ImGui::SetKeyboardFocusHere();
|
ImGui::SetKeyboardFocusHere();
|
||||||
@@ -205,11 +302,17 @@ void RenderContactsTab(App* app)
|
|||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
|
||||||
material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress",
|
// Address + inline Paste (available for both add and edit — handy when correcting an address).
|
||||||
s_edit_address, sizeof(s_edit_address), formW);
|
{
|
||||||
if (!isEdit) {
|
float pasteW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("paste")).x
|
||||||
ImGui::SameLine();
|
+ ImGui::GetStyle().FramePadding.x * 2.0f + 12.0f * dp;
|
||||||
if (material::TactileButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
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();
|
const char* clipboard = ImGui::GetClipboardText();
|
||||||
if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard);
|
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);
|
ImGui::Checkbox(TR("contact_global"), &s_edit_global);
|
||||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_tt"));
|
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<int> 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;
|
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;
|
float totalActionsW = actionW * 2.0f + actionGap;
|
||||||
material::BeginOverlayDialogFooter(totalActionsW);
|
material::BeginOverlayDialogFooter(totalActionsW, /*drawSeparator=*/false);
|
||||||
|
|
||||||
if (!canSubmit) ImGui::BeginDisabled();
|
if (!canSubmit) ImGui::BeginDisabled();
|
||||||
|
|
||||||
const char* primaryLabel = isEdit ? TR("save") : TR("add");
|
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.
|
// Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is.
|
||||||
auto trimAB = [](std::string s) {
|
auto trimAB = [](std::string s) {
|
||||||
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
|
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;
|
return s;
|
||||||
};
|
};
|
||||||
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
|
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
|
// 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.
|
// (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet.
|
||||||
entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash;
|
entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash;
|
||||||
@@ -262,7 +481,7 @@ void RenderContactsTab(App* app)
|
|||||||
if (!canSubmit) ImGui::EndDisabled();
|
if (!canSubmit) ImGui::EndDisabled();
|
||||||
|
|
||||||
ImGui::SameLine(0, actionGap);
|
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;
|
*open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,11 +602,7 @@ void RenderContactsTab(App* app)
|
|||||||
if (listH < 120.0f * dp) listH = 120.0f * dp;
|
if (listH < 120.0f * dp) listH = 120.0f * dp;
|
||||||
|
|
||||||
const bool lightTheme = material::IsLightTheme();
|
const bool lightTheme = material::IsLightTheme();
|
||||||
auto typeColor = [&](bool shielded) -> ImU32 {
|
auto typeColor = [&](bool shielded) -> ImU32 { return contactTypeColor(shielded, lightTheme); };
|
||||||
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);
|
|
||||||
};
|
|
||||||
// Centered Material empty state (no contacts yet / no search match).
|
// Centered Material empty state (no contacts yet / no search match).
|
||||||
auto emptyState = [&](const char* iconGlyph, const char* msgKey) {
|
auto emptyState = [&](const char* iconGlyph, const char* msgKey) {
|
||||||
const char* msg = TR(msgKey);
|
const char* msg = TR(msgKey);
|
||||||
@@ -655,5 +870,28 @@ void RenderContactsTab(App* app)
|
|||||||
renderEntryDialog();
|
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 ui
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -21,5 +21,11 @@ namespace ui {
|
|||||||
*/
|
*/
|
||||||
void RenderContactsTab(App* app);
|
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 ui
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
371
src/ui/windows/image_picker.h
Normal file
371
src/ui/windows/image_picker.h
Normal file
@@ -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 <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<void(const std::string&)> 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<unsigned char>(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<unsigned char>(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<unsigned char> 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<std::string> s_subdirs;
|
||||||
|
static inline std::vector<std::string> s_images;
|
||||||
|
static inline std::unordered_map<std::string, Thumb> s_thumbs;
|
||||||
|
static inline std::function<void(const std::string&)> s_onPick;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ui
|
||||||
|
} // namespace dragonx
|
||||||
@@ -306,6 +306,13 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["picker_dat_count"] = "%d wallet file(s) in this folder";
|
strings_["picker_dat_count"] = "%d wallet file(s) in this folder";
|
||||||
strings_["picker_dat_none"] = "No wallet files directly in this folder";
|
strings_["picker_dat_none"] = "No wallet files directly in this folder";
|
||||||
strings_["picker_select"] = "Scan 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";
|
strings_["tt_seed_migrate"] = "Create a new seed-phrase wallet and move your funds into it";
|
||||||
// Migrate-to-seed modal
|
// Migrate-to-seed modal
|
||||||
strings_["mig_title"] = "Migrate to a seed wallet";
|
strings_["mig_title"] = "Migrate to a seed wallet";
|
||||||
@@ -1222,6 +1229,18 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["address_book_add_new"] = "Add New";
|
strings_["address_book_add_new"] = "Add New";
|
||||||
strings_["contact_global"] = "Show in every wallet (global contact)";
|
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.";
|
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_["contact_global_badge_tt"] = "Global contact — visible in every wallet";
|
||||||
strings_["address_book_added"] = "Address added to book";
|
strings_["address_book_added"] = "Address added to book";
|
||||||
strings_["address_book_count"] = "%zu addresses saved";
|
strings_["address_book_count"] = "%zu addresses saved";
|
||||||
|
|||||||
Reference in New Issue
Block a user