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:
2026-07-15 07:10:00 -05:00
parent 2249bc31d5
commit 2d4ba89c00
13 changed files with 809 additions and 25 deletions

View File

@@ -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 <filesystem>
#include <unordered_map>
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
@@ -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:<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) {
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 <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)
{
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<int>(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<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.
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<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;
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