feat(contacts): contact avatars — custom image / Material icon / Z-T badge

Add an `avatar` field to AddressBookEntry ("" = default Z/T type badge,
"icon:<name>" = a Material wallet-icon, "img:<path>" = a custom image),
serialized additively in addressbook.json (only written when non-empty, so
existing books are untouched).

Render it in the Cards/List views via a new drawContactAvatar helper: custom
images are loaded once through a path-keyed texture cache and drawn
circular-cropped (centre-cropped UVs + a thin border ring); icons reuse the
project-icon set (incl. the special pickaxe font path) in a tinted circle;
everything else falls back to the existing Z/T badge (also the fallback when
an image fails to load or an icon name is unknown).

Seed one sweep contact with an icon avatar to exercise the icon-badge path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 06:49:52 -05:00
parent 1ce37aa0fa
commit 2249bc31d5
4 changed files with 60 additions and 6 deletions

View File

@@ -100,6 +100,7 @@ void seedSweepContacts(App& a)
{ {
s_contactsSweepBackup = a.addressBook().entries(); s_contactsSweepBackup = a.addressBook().entries();
data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global
e1.avatar = "icon:account_balance"; // icon avatar (verifies the icon-badge render path)
data::AddressBookEntry e2("exchange deposit", "t1DemoTransparentAddressForUiSweep00000", ""); // T data::AddressBookEntry e2("exchange deposit", "t1DemoTransparentAddressForUiSweep00000", ""); // T
// Scope to the active wallet so it's visible (the tab hides contacts not in the active wallet); // Scope to the active wallet so it's visible (the tab hides contacts not in the active wallet);
// non-empty hash also keeps it non-global -> no globe badge, so the list shows a global/non-global mix. // non-empty hash also keeps it non-global -> no globe badge, so the list shows a global/non-global mix.

View File

@@ -54,6 +54,7 @@ bool AddressBook::load()
// Legacy entries (no "scope") migrate to "global" so nothing disappears when // Legacy entries (no "scope") migrate to "global" so nothing disappears when
// multi-wallet scoping lands — a contact you already had stays visible everywhere. // multi-wallet scoping lands — a contact you already had stays visible everywhere.
e.scope = entry.value("scope", "global"); e.scope = entry.value("scope", "global");
e.avatar = entry.value("avatar", "");
if (!e.address.empty()) { if (!e.address.empty()) {
entries_.push_back(e); entries_.push_back(e);
@@ -86,6 +87,7 @@ bool AddressBook::save()
e["address"] = entry.address; e["address"] = entry.address;
e["notes"] = entry.notes; e["notes"] = entry.notes;
e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope; e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope;
if (!entry.avatar.empty()) e["avatar"] = entry.avatar;
j["entries"].push_back(e); j["entries"].push_back(e);
} }

View File

@@ -21,6 +21,9 @@ struct AddressBookEntry {
// (shown only when that wallet is the active one). Empty is treated as "global" so legacy // (shown only when that wallet is the active one). Empty is treated as "global" so legacy
// entries — written before multi-wallet scoping — keep showing everywhere. // entries — written before multi-wallet scoping — keep showing everywhere.
std::string scope = "global"; std::string scope = "global";
// Contact avatar: "" = default type badge (Z/T), "icon:<name>" = a Material wallet-icon,
// "img:<path>" = a custom image (copied into <config>/contact-avatars/).
std::string avatar;
AddressBookEntry() = default; AddressBookEntry() = default;
AddressBookEntry(const std::string& l, const std::string& a, const std::string& n = "", AddressBookEntry(const std::string& l, const std::string& a, const std::string& n = "",

View File

@@ -12,8 +12,11 @@
#include "../schema/ui_schema.h" #include "../schema/ui_schema.h"
#include "../material/draw_helpers.h" #include "../material/draw_helpers.h"
#include "../material/type.h" #include "../material/type.h"
#include "../material/project_icons.h"
#include "../../util/texture_loader.h"
#include "../layout.h" #include "../layout.h"
#include "imgui.h" #include "imgui.h"
#include <unordered_map>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cstring> #include <cstring>
@@ -56,6 +59,55 @@ static bool matchesSearch(const data::AddressBookEntry& e, const std::string& ne
|| toLower(e.notes).find(needleLower) != std::string::npos; || toLower(e.notes).find(needleLower) != std::string::npos;
} }
// Lazily-loaded, path-keyed cache of custom avatar image textures (loaded once, kept for the session).
struct AvatarTex { ImTextureID tex = 0; int w = 0, h = 0; };
static std::unordered_map<std::string, AvatarTex> s_avatarTexCache;
static const AvatarTex* getAvatarTexture(const std::string& path) {
auto it = s_avatarTexCache.find(path);
if (it != s_avatarTexCache.end()) return it->second.tex ? &it->second : nullptr;
AvatarTex at;
util::LoadTextureFromFile(path.c_str(), &at.tex, &at.w, &at.h); // tex stays 0 on failure
auto& slot = (s_avatarTexCache[path] = at);
return slot.tex ? &slot : nullptr;
}
// Draw a contact's avatar into the circle at `c` (radius `r`): a custom image (circular-cropped), a
// Material icon in a tinted circle, or — the default — the Z/T type badge.
static void drawContactAvatar(ImDrawList* dl, ImVec2 c, float r, const data::AddressBookEntry& e,
bool shielded, ImU32 typeCol, bool light, float dp,
ImFont* letterFont, ImFont* iconFont) {
const std::string& av = e.avatar;
if (av.rfind("img:", 0) == 0) {
if (const AvatarTex* t = getAvatarTexture(av.substr(4))) {
float u0 = 0, v0 = 0, u1 = 1, v1 = 1; // centre-crop to a square so the circle isn't stretched
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; }
dl->AddImageRounded(t->tex, ImVec2(c.x - r, c.y - r), ImVec2(c.x + r, c.y + r),
ImVec2(u0, v0), ImVec2(u1, v1), IM_COL32_WHITE, r);
dl->AddCircle(c, r, material::WithAlpha(material::OnSurface(), 45), 0, 1.0f * dp);
return;
}
// fall through to the type badge if the image failed to load
} else if (av.rfind("icon:", 0) == 0) {
const std::string iconName = av.substr(5);
const bool known = (iconName == material::project_icons::kPickaxeName) ||
(material::project_icons::glyphForName(iconName) != nullptr);
if (known) {
dl->AddCircleFilled(c, r, material::WithAlpha(typeCol, light ? 45 : 60));
dl->AddCircle(c, r, material::WithAlpha(typeCol, 190), 0, 1.4f * dp);
material::project_icons::drawByName(dl, iconName, c, typeCol, iconFont, iconFont->LegacySize);
return;
}
// fall through to the type badge if the icon name is unknown
}
// Default: Z/T type badge.
dl->AddCircleFilled(c, r, material::WithAlpha(typeCol, light ? 45 : 60));
dl->AddCircle(c, r, material::WithAlpha(typeCol, 190), 0, 1.4f * dp);
const char* letter = shielded ? "Z" : "T";
const ImVec2 ls = letterFont->CalcTextSizeA(letterFont->LegacySize, FLT_MAX, 0, letter);
dl->AddText(letterFont, letterFont->LegacySize, ImVec2(c.x - ls.x * 0.5f, c.y - ls.y * 0.5f), typeCol, letter);
}
static bool isShieldedAddr(const std::string& a) { static bool isShieldedAddr(const std::string& a) {
return !a.empty() && a[0] == 'z'; return !a.empty() && a[0] == 'z';
} }
@@ -484,15 +536,11 @@ void RenderContactsTab(App* app)
dl->AddLine(ImVec2(mn.x + pad, mx.y - 0.5f), ImVec2(mx.x - pad, mx.y - 0.5f), dl->AddLine(ImVec2(mn.x + pad, mx.y - 0.5f), ImVec2(mx.x - pad, mx.y - 0.5f),
material::WithAlpha(material::OnSurface(), 24), 1.0f); material::WithAlpha(material::OnSurface(), 24), 1.0f);
} }
// Circular type avatar with the Z/T letter. // Avatar: custom image (circular) / Material icon / default Z/T type badge.
const bool shielded = isShieldedAddr(entry.address); const bool shielded = isShieldedAddr(entry.address);
const ImU32 tu = typeColor(shielded); const ImU32 tu = typeColor(shielded);
const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f); const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f);
dl->AddCircleFilled(avC, avR, material::WithAlpha(tu, lightTheme ? 45 : 60)); drawContactAvatar(dl, avC, avR, entry, shielded, tu, lightTheme, dp, lblF, icoF);
dl->AddCircle(avC, avR, material::WithAlpha(tu, 190), 0, 1.4f * dp);
const char* letter = shielded ? "Z" : "T";
const ImVec2 ls = lblF->CalcTextSizeA(lblF->LegacySize, FLT_MAX, 0, letter);
dl->AddText(lblF, lblF->LegacySize, ImVec2(avC.x - ls.x * 0.5f, avC.y - ls.y * 0.5f), tu, letter);
// Label (line 1) + muted address (line 2) — reserve trailing room for the globe + actions. // Label (line 1) + muted address (line 2) — reserve trailing room for the globe + actions.
const float actHit = icoF->LegacySize + 8.0f * dp; // per-action square hit area const float actHit = icoF->LegacySize + 8.0f * dp; // per-action square hit area
const float globeW = entry.isGlobal() ? (icoF->LegacySize + 8.0f * dp) : 0.0f; const float globeW = entry.isGlobal() ? (icoF->LegacySize + 8.0f * dp) : 0.0f;