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();
|
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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user