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:
@@ -100,6 +100,7 @@ void seedSweepContacts(App& a)
|
||||
{
|
||||
s_contactsSweepBackup = a.addressBook().entries();
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -54,6 +54,7 @@ bool AddressBook::load()
|
||||
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
|
||||
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
|
||||
e.scope = entry.value("scope", "global");
|
||||
e.avatar = entry.value("avatar", "");
|
||||
|
||||
if (!e.address.empty()) {
|
||||
entries_.push_back(e);
|
||||
@@ -86,6 +87,7 @@ bool AddressBook::save()
|
||||
e["address"] = entry.address;
|
||||
e["notes"] = entry.notes;
|
||||
e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope;
|
||||
if (!entry.avatar.empty()) e["avatar"] = entry.avatar;
|
||||
j["entries"].push_back(e);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ struct AddressBookEntry {
|
||||
// (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.
|
||||
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(const std::string& l, const std::string& a, const std::string& n = "",
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/project_icons.h"
|
||||
#include "../../util/texture_loader.h"
|
||||
#include "../layout.h"
|
||||
#include "imgui.h"
|
||||
#include <unordered_map>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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),
|
||||
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 ImU32 tu = typeColor(shielded);
|
||||
const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f);
|
||||
dl->AddCircleFilled(avC, avR, material::WithAlpha(tu, lightTheme ? 45 : 60));
|
||||
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);
|
||||
drawContactAvatar(dl, avC, avR, entry, shielded, tu, lightTheme, dp, lblF, icoF);
|
||||
// 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 globeW = entry.isGlobal() ? (icoF->LegacySize + 8.0f * dp) : 0.0f;
|
||||
|
||||
Reference in New Issue
Block a user