5 Commits

Author SHA1 Message Date
03dde25339 test(sweep): seeded contacts-cards/list/table surfaces for the view toggle
Add three sweep surfaces (contacts-cards / -list / -table) that force each Contacts
address-list view mode and seed a few demo contacts (Z + T types, some global) so
the modes render with data instead of the empty state. Teardown restores the real
book and resets the mode.

To avoid touching the user's persisted address book (the Windows sweep can run on
the real HOME, and addEntry/removeEntry call save()), add AddressBook::sweepSetEntries
— a no-save in-memory setter used only here: the surface snapshots the real entries,
swaps in the demo set, and restores the snapshot on teardown, so nothing is written
to disk even if the sweep is interrupted.

Build + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:10:24 -05:00
3fbb64d14c feat(contacts): switchable Cards / List / Table view for the address list
Add a persisted view toggle so the contacts address list can be rendered three
ways, moving it away from a bare data-grid toward a Material look:

- Cards: tactile rounded cards with a circular Z/T type-avatar, label over a muted
  truncated address, trailing globe badge, primary-tint + outline selection, hover
  fill, and a centred Material empty state.
- List: borderless two-line rows (same avatar + label/address) with hover tint and a
  thin per-row divider — denser than cards.
- Table: the previous 3-column table, material-ized (grid borders dropped, row
  backgrounds + interactive sort kept).

The toggle is an icon SegmentedControl (view-agenda / view-list / table-rows)
right-aligned on the toolbar; the choice persists via a new contacts_view_mode_
setting (0 cards / 1 list / 2 table, mirroring portfolio_style_). Cards/List sort by
label; Table keeps its sortable headers. All geometry is dpiScale()-aware; the globe
badge is drawn in the icon font in every mode. Default is Cards.

Build + ctest + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:07:33 -05:00
1b0006a4af fix(contacts): audit fixes — clip, tofu badge, plural, DPI, columns, toolbar
Address the confirmed findings from a workflow audit of the Contacts tab body:

- Label clip: the Label column was WidthFixed 150px and clipped long labels
  mid-word ("drgx pool payout a…"). Make all three columns WidthStretch (label 1.5,
  address 2.6, notes 1.0) so the label grows with the tab and the notes column no
  longer reserves a fixed empty block on the right (notes = low weight, per request).
- Globe badge tofu: the global-contact ICON_MD_PUBLIC badge was drawn with the text
  font (no Material glyphs) → rendered as "?". Push Type().iconSmall() around it.
- Plural: "1 addresses saved" -> add address_book_count_one ("%zu address saved",
  8 langs, %zu kept so the format signature matches) and branch on count == 1.
- DPI: the SameLine badge gaps (6/8px) and the table-height floor (120px) are now
  * Layout::dpiScale(); the WidthStretch columns are relative so need no scaling.
- Toolbar: give the four actions leading Material icons (person-add / edit / delete /
  content-copy) and accent the primary "Add New" — via a small local icon+label
  tactile-button helper (ImGui has no two-font button), icons inherit the
  disabled-aware text alpha so they gray out with BeginDisabled().

Build + ctest + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:49:15 -05:00
ba13875eaf fix(receive): size the QR popup to its content, not 85% of the window
The QR popup used the same window-relative card width the Export-Key dialog just
moved away from (its comment even said "Match the key-export modal: 85% of the
window width"). On a large monitor that left the centered AddressCopyField floating
far from its left-aligned "Address:" label and inflated the QR's empty margins.

Derive the card width from the address field's own natural (chunked) box width
(mirroring the key-export fix, incl. the overlay card's 28px content inset so the
address stays on one line), bounded to 85% of the window as an upper limit. Also
scale the responsive-QR size literals (280 fallback / 420 cap) by dpiScale() so the
QR renders at the intended size on HiDPI/font-scale. No change to the QR texture /
clipboard logic.

Found via a workflow audit of all 27 redesigned modals for the same anti-pattern —
the QR popup was the only other occurrence (0 false positives).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:10:49 -05:00
a5d44c8aca fix(overview): size the Export-Key modal to its content, not 85% of the window
The Export-Key dialog sized its card to a fixed 85% of the window width. On a large
monitor that's ~1570px, but the content is ~900px, and AddressCopyField centers its
text-clamped box within the available width — so the address/key fields floated far
from their left-aligned "Address:"/"Viewing Key:" labels and read as off-center
(more visible after the warning box became a left-aligned DialogWarningHeader).

Derive the card width from the address field's own natural (chunked) box width —
the widest deterministic element before the key is revealed — mirroring
AddressCopyField's boxW math, plus the overlay card's 28px-per-side content inset so
the address sits on one line under its label. Adapts to address type (z vs t) and
font scale with no magic width; bounded to 85% of the window as an upper safety
limit; the revealed key+QR layout adapts to whatever width results. Width-only
change — the key fetch/wipe/mask/close logic is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:10:49 -05:00
16 changed files with 293 additions and 82 deletions

View File

@@ -30,6 +30,7 @@
"address_book_added": "Adresse zum Buch hinzugefügt", "address_book_added": "Adresse zum Buch hinzugefügt",
"address_book_confirm_delete": "Löschen bestätigen?", "address_book_confirm_delete": "Löschen bestätigen?",
"address_book_count": "%zu Adressen gespeichert", "address_book_count": "%zu Adressen gespeichert",
"address_book_count_one": "%zu Adresse gespeichert",
"address_book_deleted": "Eintrag gelöscht", "address_book_deleted": "Eintrag gelöscht",
"address_book_edit": "Adresse bearbeiten", "address_book_edit": "Adresse bearbeiten",
"address_book_empty": "Keine gespeicherten Adressen. Klicken Sie auf 'Neue hinzufügen', um eine hinzuzufügen.", "address_book_empty": "Keine gespeicherten Adressen. Klicken Sie auf 'Neue hinzufügen', um eine hinzuzufügen.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Dirección agregada a la libreta", "address_book_added": "Dirección agregada a la libreta",
"address_book_confirm_delete": "¿Confirmar eliminación?", "address_book_confirm_delete": "¿Confirmar eliminación?",
"address_book_count": "%zu direcciones guardadas", "address_book_count": "%zu direcciones guardadas",
"address_book_count_one": "%zu dirección guardada",
"address_book_deleted": "Entrada eliminada", "address_book_deleted": "Entrada eliminada",
"address_book_edit": "Editar Dirección", "address_book_edit": "Editar Dirección",
"address_book_empty": "No hay direcciones guardadas. Haz clic en 'Agregar Nueva' para añadir una.", "address_book_empty": "No hay direcciones guardadas. Haz clic en 'Agregar Nueva' para añadir una.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Adresse ajoutée au carnet", "address_book_added": "Adresse ajoutée au carnet",
"address_book_confirm_delete": "Confirmer la suppression ?", "address_book_confirm_delete": "Confirmer la suppression ?",
"address_book_count": "%zu adresses enregistrées", "address_book_count": "%zu adresses enregistrées",
"address_book_count_one": "%zu adresse enregistrée",
"address_book_deleted": "Entrée supprimée", "address_book_deleted": "Entrée supprimée",
"address_book_edit": "Modifier l'adresse", "address_book_edit": "Modifier l'adresse",
"address_book_empty": "Aucune adresse enregistrée. Cliquez sur 'Ajouter' pour en créer une.", "address_book_empty": "Aucune adresse enregistrée. Cliquez sur 'Ajouter' pour en créer une.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "アドレスをアドレス帳に追加しました", "address_book_added": "アドレスをアドレス帳に追加しました",
"address_book_confirm_delete": "削除しますか?", "address_book_confirm_delete": "削除しますか?",
"address_book_count": "%zu 件のアドレスを保存済み", "address_book_count": "%zu 件のアドレスを保存済み",
"address_book_count_one": "%zu 件のアドレスを保存",
"address_book_deleted": "エントリを削除しました", "address_book_deleted": "エントリを削除しました",
"address_book_edit": "アドレスを編集", "address_book_edit": "アドレスを編集",
"address_book_empty": "保存されたアドレスがありません。「新規追加」をクリックして追加してください。", "address_book_empty": "保存されたアドレスがありません。「新規追加」をクリックして追加してください。",

View File

@@ -30,6 +30,7 @@
"address_book_added": "주소록에 주소를 추가했습니다", "address_book_added": "주소록에 주소를 추가했습니다",
"address_book_confirm_delete": "삭제하시겠습니까?", "address_book_confirm_delete": "삭제하시겠습니까?",
"address_book_count": "저장된 주소 %zu개", "address_book_count": "저장된 주소 %zu개",
"address_book_count_one": "주소 %zu개 저장됨",
"address_book_deleted": "항목이 삭제되었습니다", "address_book_deleted": "항목이 삭제되었습니다",
"address_book_edit": "주소 편집", "address_book_edit": "주소 편집",
"address_book_empty": "저장된 주소가 없습니다. '새로 추가'를 클릭하여 추가하세요.", "address_book_empty": "저장된 주소가 없습니다. '새로 추가'를 클릭하여 추가하세요.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Endereço adicionado ao livro", "address_book_added": "Endereço adicionado ao livro",
"address_book_confirm_delete": "Confirmar exclusão?", "address_book_confirm_delete": "Confirmar exclusão?",
"address_book_count": "%zu endereços salvos", "address_book_count": "%zu endereços salvos",
"address_book_count_one": "%zu endereço salvo",
"address_book_deleted": "Entrada excluída", "address_book_deleted": "Entrada excluída",
"address_book_edit": "Editar Endereço", "address_book_edit": "Editar Endereço",
"address_book_empty": "Nenhum endereço salvo. Clique em 'Adicionar Novo' para criar um.", "address_book_empty": "Nenhum endereço salvo. Clique em 'Adicionar Novo' para criar um.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Адрес добавлен в книгу", "address_book_added": "Адрес добавлен в книгу",
"address_book_confirm_delete": "Подтвердить удаление?", "address_book_confirm_delete": "Подтвердить удаление?",
"address_book_count": "%zu адресов сохранено", "address_book_count": "%zu адресов сохранено",
"address_book_count_one": "Сохранён %zu адрес",
"address_book_deleted": "Запись удалена", "address_book_deleted": "Запись удалена",
"address_book_edit": "Редактировать адрес", "address_book_edit": "Редактировать адрес",
"address_book_empty": "Нет сохранённых адресов. Нажмите 'Добавить новый', чтобы создать.", "address_book_empty": "Нет сохранённых адресов. Нажмите 'Добавить новый', чтобы создать.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "地址已添加到通讯录", "address_book_added": "地址已添加到通讯录",
"address_book_confirm_delete": "确认删除?", "address_book_confirm_delete": "确认删除?",
"address_book_count": "已保存 %zu 个地址", "address_book_count": "已保存 %zu 个地址",
"address_book_count_one": "已保存 %zu 个地址",
"address_book_deleted": "条目已删除", "address_book_deleted": "条目已删除",
"address_book_edit": "编辑地址", "address_book_edit": "编辑地址",
"address_book_empty": "没有保存的地址。点击'添加新地址'创建一个。", "address_book_empty": "没有保存的地址。点击'添加新地址'创建一个。",

View File

@@ -14,6 +14,7 @@
#include "app.h" #include "app.h"
#include "config/settings.h" #include "config/settings.h"
#include "data/address_book.h"
#include "ui/schema/skin_manager.h" #include "ui/schema/skin_manager.h"
#include "ui/notifications.h" #include "ui/notifications.h"
#include "ui/sidebar.h" #include "ui/sidebar.h"
@@ -90,6 +91,29 @@ const char* kDemoMnemonic =
const char* kDemoZAddr = const char* kDemoZAddr =
"zs1demoseedwalletreceiveaddressforuisweepcaptures00000000000000000000000000"; "zs1demoseedwalletreceiveaddressforuisweepcaptures00000000000000000000000000";
const char* kDemoTxid = "b647077c471fdd2877d35c9d8b70e3c785547fae618458b4068d913ee3d1dc4f"; const char* kDemoTxid = "b647077c471fdd2877d35c9d8b70e3c785547fae618458b4068d913ee3d1dc4f";
// Contacts-view sweep: seed a few demo contacts (Z + T types, some global) so the Cards/List/Table
// modes render with data, and restore the real book afterward. Uses sweepSetEntries (no disk write),
// so the user's persisted address book is never touched even if the sweep is interrupted.
std::vector<data::AddressBookEntry> s_contactsSweepBackup;
void seedSweepContacts(App& a)
{
s_contactsSweepBackup = a.addressBook().entries();
data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global
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.
e2.scope = a.activeWalletIdentityHash();
data::AddressBookEntry e3("cold savings",
"zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000", "long-term storage"); // Z, global
a.addressBook().sweepSetEntries({ e1, e2, e3 });
}
void restoreSweepContacts(App& a)
{
a.addressBook().sweepSetEntries(s_contactsSweepBackup);
s_contactsSweepBackup.clear();
if (a.settings()) a.settings()->setContactsViewMode(0);
}
} // namespace } // namespace
std::string App::screenshotDir() const std::string App::screenshotDir() const
@@ -373,6 +397,16 @@ void App::buildSweepCatalog()
add("modal-key-export", ui::NavPage::Overview, add("modal-key-export", ui::NavPage::Overview,
[](App&) { ui::KeyExportDialog::show(kDemoZAddr, ui::KeyExportDialog::KeyType::Private); }, [](App&) { ui::KeyExportDialog::show(kDemoZAddr, ui::KeyExportDialog::KeyType::Private); },
[](App&) { ui::KeyExportDialog::hide(); }); [](App&) { ui::KeyExportDialog::hide(); });
// Contacts address-list view modes, each seeded with demo contacts (restored after; no disk write).
add("contacts-cards", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(0); },
[](App& a) { restoreSweepContacts(a); });
add("contacts-list", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1); },
[](App& a) { restoreSweepContacts(a); });
add("contacts-table", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(2); },
[](App& a) { 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,

View File

@@ -174,6 +174,8 @@ bool Settings::load(const std::string& path)
} }
loadScalar(j, "portfolio_style", portfolio_style_); loadScalar(j, "portfolio_style", portfolio_style_);
if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0; if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0;
loadScalar(j, "contacts_view_mode", contacts_view_mode_);
if (contacts_view_mode_ < 0 || contacts_view_mode_ > 2) contacts_view_mode_ = 0;
loadScalar(j, "scanline_enabled", scanline_enabled_); loadScalar(j, "scanline_enabled", scanline_enabled_);
loadScalar(j, "console_line_accents", console_line_accents_); loadScalar(j, "console_line_accents", console_line_accents_);
loadScalar(j, "console_text_color", console_text_color_); loadScalar(j, "console_text_color", console_text_color_);
@@ -428,6 +430,7 @@ bool Settings::save(const std::string& path)
j["window_opacity"] = window_opacity_; j["window_opacity"] = window_opacity_;
j["balance_layout"] = balance_layout_; // saved as string ID j["balance_layout"] = balance_layout_; // saved as string ID
j["portfolio_style"] = portfolio_style_; j["portfolio_style"] = portfolio_style_;
j["contacts_view_mode"] = contacts_view_mode_;
j["scanline_enabled"] = scanline_enabled_; j["scanline_enabled"] = scanline_enabled_;
j["console_line_accents"] = console_line_accents_; j["console_line_accents"] = console_line_accents_;
j["console_text_color"] = console_text_color_; j["console_text_color"] = console_text_color_;

View File

@@ -184,6 +184,9 @@ public:
// Left/Right arrows on the Market tab (like the Overview layouts). // Left/Right arrows on the Market tab (like the Overview layouts).
int getPortfolioStyle() const { return portfolio_style_; } int getPortfolioStyle() const { return portfolio_style_; }
void setPortfolioStyle(int v) { portfolio_style_ = (v < 0 || v > 2) ? 0 : v; } void setPortfolioStyle(int v) { portfolio_style_ = (v < 0 || v > 2) ? 0 : v; }
// Contacts tab address-list view: 0 = cards, 1 = list, 2 = table.
int getContactsViewMode() const { return contacts_view_mode_; }
void setContactsViewMode(int v) { contacts_view_mode_ = (v < 0 || v > 2) ? 0 : v; }
// Console scanline effect // Console scanline effect
bool getScanlineEnabled() const { return scanline_enabled_; } bool getScanlineEnabled() const { return scanline_enabled_; }
@@ -490,6 +493,7 @@ private:
#endif #endif
std::string balance_layout_ = "classic"; std::string balance_layout_ = "classic";
int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero) int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero)
int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table)
bool scanline_enabled_ = true; bool scanline_enabled_ = true;
bool console_line_accents_ = true; // left color accent bars in console output bool console_line_accents_ = true; // left color accent bars in console output
bool console_text_color_ = true; // per-channel text coloring in console output bool console_text_color_ = true; // per-channel text coloring in console output

View File

@@ -109,6 +109,12 @@ public:
*/ */
size_t size() const { return entries_.size(); } size_t size() const { return entries_.size(); }
/**
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
*/
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); }
/** /**
* @brief Check if empty * @brief Check if empty
*/ */

View File

@@ -4,12 +4,15 @@
#include "contacts_tab.h" #include "contacts_tab.h"
#include "../../app.h" #include "../../app.h"
#include "../../config/settings.h"
#include "../../data/address_book.h" #include "../../data/address_book.h"
#include "../../util/i18n.h" #include "../../util/i18n.h"
#include "../../util/text_format.h" #include "../../util/text_format.h"
#include "../notifications.h" #include "../notifications.h"
#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 "../layout.h"
#include "imgui.h" #include "imgui.h"
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -215,8 +218,37 @@ void RenderContactsTab(App* app)
ImGui::BeginChild("##ContactsScroll", avail, false, ImGui::BeginChild("##ContactsScroll", avail, false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar); ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
// Toolbar // Toolbar — icon + label actions; the primary "Add New" is accented so it stands out.
if (material::TactileButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) { const float dp = ui::Layout::dpiScale();
ImFont* tbTxtF = S.resolveFont(actionBtn.font);
ImFont* tbIcoF = material::Type().iconSmall();
// Renders a tactile button with a leading Material icon (icon font) + label (text font). ImGui has no
// native two-font button, so draw the styled rect via TactileButton (empty ##id label) then paint the
// icon + label over it. `primary` accents the main action; the icon/label inherit the (disabled-aware)
// text alpha so they gray out correctly inside BeginDisabled().
auto toolbarBtn = [&](const char* id, const char* glyph, const char* label, bool primary) -> bool {
ImGui::PushFont(tbIcoF); const float iw = ImGui::CalcTextSize(glyph).x; ImGui::PopFont();
ImGui::PushFont(tbTxtF); const float tw = ImGui::CalcTextSize(label).x; ImGui::PopFont();
const float gap = 6.0f * dp;
const float w = ImGui::GetStyle().FramePadding.x * 2.0f + iw + gap + tw;
const float h = ImGui::GetFrameHeight();
if (primary) {
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
}
const ImVec2 p = ImGui::GetCursorScreenPos();
const bool clicked = material::TactileButton(id, ImVec2(w, h), tbTxtF);
if (primary) ImGui::PopStyleColor(3);
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImU32 col = ImGui::GetColorU32(ImGuiCol_Text);
const float sx = p.x + (w - (iw + gap + tw)) * 0.5f;
dl->AddText(tbIcoF, tbIcoF->LegacySize, ImVec2(sx, p.y + (h - tbIcoF->LegacySize) * 0.5f), col, glyph);
dl->AddText(tbTxtF, tbTxtF->LegacySize, ImVec2(sx + iw + gap, p.y + (h - tbTxtF->LegacySize) * 0.5f), col, label);
return clicked;
};
if (toolbarBtn("##ab_add", ICON_MD_PERSON_ADD, TR("address_book_add_new"), /*primary=*/true)) {
s_show_add_dialog = true; s_show_add_dialog = true;
s_focus_edit_field = true; s_focus_edit_field = true;
clearEditFields(); clearEditFields();
@@ -226,7 +258,7 @@ void RenderContactsTab(App* app)
if (!has_selection) ImGui::BeginDisabled(); if (!has_selection) ImGui::BeginDisabled();
if (material::TactileButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font))) if (toolbarBtn("##ab_edit", ICON_MD_EDIT, TR("edit"), false))
openEdit(); openEdit();
ImGui::SameLine(); ImGui::SameLine();
@@ -234,16 +266,36 @@ void RenderContactsTab(App* app)
// Delete — relabel to a visible confirm prompt while armed (not just a toast). // Delete — relabel to a visible confirm prompt while armed (not just a toast).
bool armed = has_selection && s_confirm_delete_idx == s_selected_index; bool armed = has_selection && s_confirm_delete_idx == s_selected_index;
const char* delLabel = armed ? TR("address_book_confirm_delete") : TR("delete"); const char* delLabel = armed ? TR("address_book_confirm_delete") : TR("delete");
if (material::TactileButton(delLabel, ImVec2(0,0), S.resolveFont(actionBtn.font))) if (toolbarBtn("##ab_del", ICON_MD_DELETE, delLabel, false))
doDelete(); doDelete();
ImGui::SameLine(); ImGui::SameLine();
if (material::TactileButton(TR("copy_address"), ImVec2(0,0), S.resolveFont(actionBtn.font))) if (toolbarBtn("##ab_copy", ICON_MD_CONTENT_COPY, TR("copy_address"), false))
doCopy(); doCopy();
if (!has_selection) ImGui::EndDisabled(); if (!has_selection) ImGui::EndDisabled();
// Address-list view toggle (Cards / List / Table) — right-aligned on the toolbar row; persisted.
int viewMode = app->settings() ? app->settings()->getContactsViewMode() : 0;
{
const char* segIcons[3] = { ICON_MD_VIEW_AGENDA, ICON_MD_VIEW_LIST, ICON_MD_TABLE_ROWS };
const float segH = ImGui::GetFrameHeight();
const float segW = 44.0f * dp * 3.0f;
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - segW));
ImVec2 segOrigin = ImGui::GetCursorScreenPos();
int segClk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segW, segH,
segIcons, 3, viewMode, material::Type().iconSmall(),
"##contactsView", dp);
if (segClk >= 0 && app->settings()) {
app->settings()->setContactsViewMode(segClk);
app->settings()->save();
viewMode = segClk;
}
ImGui::Dummy(ImVec2(segW, segH));
}
// Search / filter // Search / filter
ImGui::Spacing(); ImGui::Spacing();
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
@@ -268,31 +320,50 @@ void RenderContactsTab(App* app)
visibleRows.push_back(i); visibleRows.push_back(i);
} }
// Address list — size the table to fill the tab, leaving room for the count footer. // Address list — fill the tab, leaving room for the count footer.
float footerH = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; const float footerH = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y;
float tableH = ImGui::GetContentRegionAvail().y - footerH; float listH = ImGui::GetContentRegionAvail().y - footerH;
if (tableH < 120.0f) tableH = 120.0f; 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);
};
// Centered Material empty state (no contacts yet / no search match).
auto emptyState = [&](const char* iconGlyph, const char* msgKey) {
const char* msg = TR(msgKey);
ImFont* icoF = material::Type().iconLarge();
ImFont* txtF = material::Type().body2();
const float availW = ImGui::GetContentRegionAvail().x;
ImVec2 is = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, iconGlyph);
ImVec2 ts = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, 0, msg);
ImGui::SetCursorPos(ImVec2((availW - is.x) * 0.5f, ImGui::GetCursorPosY() + listH * 0.32f));
ImGui::PushFont(icoF);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceDisabled()), "%s", iconGlyph);
ImGui::PopFont();
ImGui::SetCursorPosX((availW - ts.x) * 0.5f);
material::Type().textColored(material::TypeStyle::Body2, material::OnSurfaceMedium(), msg);
};
if (viewMode == 2) {
// ── TABLE mode (material-ized): no outer/grid borders, row backgrounds, interactive sort. ──
if (ImGui::BeginTable("AddressBookTable", 3, if (ImGui::BeginTable("AddressBookTable", 3,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_Sortable | ImGuiTableFlags_RowBg | ImGuiTableFlags_Sortable |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY, ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY, ImVec2(0, listH)))
ImVec2(0, tableH)))
{ {
float labelColW = (addrTable.columns.count("label") && addrTable.columns.at("label").width > 0) ? addrTable.columns.at("label").width : 150; ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_DefaultSort, 1.5f);
float notesColW = (addrTable.columns.count("notes") && addrTable.columns.at("notes").width > 0) ? addrTable.columns.at("notes").width : 150; ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch, 2.6f);
ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_DefaultSort, labelColW); ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthStretch, 1.0f);
ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthFixed, notesColW);
ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow(); ImGui::TableHeadersRow();
// Sort the visible view by the active column (indices stay storage indices).
if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) { if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) {
if (specs->SpecsCount > 0) { if (specs->SpecsCount > 0) {
const ImGuiTableColumnSortSpecs& sc = specs->Specs[0]; const ImGuiTableColumnSortSpecs& sc = specs->Specs[0];
bool asc = sc.SortDirection != ImGuiSortDirection_Descending; bool asc = sc.SortDirection != ImGuiSortDirection_Descending;
const auto& entries = book.entries(); const auto& entries = book.entries();
std::stable_sort(visibleRows.begin(), visibleRows.end(), std::stable_sort(visibleRows.begin(), visibleRows.end(), [&](size_t a, size_t b) {
[&](size_t a, size_t b) {
const std::string* ka; const std::string* kb; const std::string* ka; const std::string* kb;
switch (sc.ColumnIndex) { switch (sc.ColumnIndex) {
case 1: ka = &entries[a].address; kb = &entries[b].address; break; case 1: ka = &entries[a].address; kb = &entries[b].address; break;
@@ -304,73 +375,137 @@ void RenderContactsTab(App* app)
}); });
} }
} }
if (book.empty()) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", TR("address_book_empty"));
} else if (visibleRows.empty()) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", TR("contacts_search_no_match"));
} else {
for (size_t vi = 0; vi < visibleRows.size(); ++vi) { for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
size_t i = visibleRows[vi]; size_t i = visibleRows[vi];
const auto& entry = book.entries()[i]; const auto& entry = book.entries()[i];
ImGui::TableNextRow(); ImGui::TableNextRow();
ImGui::PushID(static_cast<int>(i)); ImGui::PushID(static_cast<int>(i));
ImGui::TableNextColumn(); ImGui::TableNextColumn();
bool is_selected = (s_selected_index == static_cast<int>(i)); bool is_selected = (s_selected_index == static_cast<int>(i));
if (ImGui::Selectable(entry.label.c_str(), is_selected, if (ImGui::Selectable(entry.label.c_str(), is_selected,
ImGuiSelectableFlags_SpanAllColumns)) { ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) {
if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1; if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1;
s_selected_index = static_cast<int>(i); s_selected_index = static_cast<int>(i);
if (ImGui::IsMouseDoubleClicked(0)) openEdit();
} }
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
s_selected_index = static_cast<int>(i);
openEdit();
}
ImGui::TableNextColumn(); ImGui::TableNextColumn();
// Z/T type badge + address in normal (legible) text — not muted.
// Darker/more-saturated variants on light skins so the badge doesn't wash out.
bool shielded = isShieldedAddr(entry.address); bool shielded = isShieldedAddr(entry.address);
bool lightTheme = material::IsLightTheme(); ImGui::PushFont(material::Type().subtitle2());
ImVec4 zCol = lightTheme ? ImVec4(0.10f, 0.55f, 0.38f, 1.0f) : ImVec4(0.35f, 0.80f, 0.60f, 1.0f); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T");
ImVec4 tCol = lightTheme ? ImVec4(0.72f, 0.48f, 0.05f, 1.0f) : ImVec4(0.95f, 0.72f, 0.30f, 1.0f); ImGui::PopFont();
ImGui::TextColored(shielded ? zCol : tCol, "%s", shielded ? "Z" : "T"); ImGui::SameLine(0.0f, 6.0f * dp);
ImGui::SameLine(0.0f, 6.0f); std::string addr_display = util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate);
std::string addr_display = entry.address;
int addrTruncLen = (addrTable.columns.count("address") && addrTable.columns.at("address").truncate > 0) ? addrTable.columns.at("address").truncate : 40;
if (addr_display.length() > static_cast<size_t>(addrTruncLen)) {
addr_display = util::truncateMiddle(addr_display, addrFrontLbl.truncate, addrBackLbl.truncate);
}
ImGui::TextUnformatted(addr_display.c_str()); ImGui::TextUnformatted(addr_display.c_str());
if (ImGui::IsItemHovered()) { if (ImGui::IsItemHovered()) material::Tooltip("%s", entry.address.c_str());
material::Tooltip("%s", entry.address.c_str());
}
// Globe badge marks a global contact (shown in every wallet).
if (entry.isGlobal()) { if (entry.isGlobal()) {
ImGui::SameLine(0.0f, 8.0f); ImGui::SameLine(0.0f, 8.0f * dp);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()), ImGui::PushFont(material::Type().iconSmall());
"%s", ICON_MD_PUBLIC); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()), "%s", ICON_MD_PUBLIC);
ImGui::PopFont();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_badge_tt")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_badge_tt"));
} }
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::TextUnformatted(entry.notes.c_str()); ImGui::TextUnformatted(entry.notes.c_str());
ImGui::PopID(); ImGui::PopID();
} }
}
ImGui::EndTable(); ImGui::EndTable();
} }
} else {
// ── CARDS (0) / LIST (1) mode — tactile Material items, no grid lines. ──
const bool asCard = (viewMode == 0);
// Cards/list have no interactive sort header — sort by label ascending.
{
const auto& entries = book.entries();
std::stable_sort(visibleRows.begin(), visibleRows.end(), [&](size_t a, size_t b) {
return toLower(entries[a].label).compare(toLower(entries[b].label)) < 0;
});
}
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0,0,0,0));
ImGui::BeginChild("##contactList", ImVec2(0, listH), false, ImGuiWindowFlags_NoBackground);
if (visibleRows.empty()) {
emptyState(ICON_MD_CONTACTS, book.empty() ? "address_book_empty" : "contacts_search_no_match");
} else {
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0,0,0,0));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, asCard ? 8.0f * dp : 0.0f));
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* lblF = material::Type().subtitle2();
ImFont* adrF = material::Type().caption();
ImFont* icoF = material::Type().iconSmall();
const float pad = 12.0f * dp;
const float avR = 15.0f * dp;
const float rowH = avR * 2.0f + (asCard ? 16.0f * dp : 12.0f * dp);
const float round = 10.0f * dp;
for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
size_t i = visibleRows[vi];
const auto& entry = book.entries()[i];
ImGui::PushID(static_cast<int>(i));
const bool selected = (s_selected_index == static_cast<int>(i));
const ImVec2 p = ImGui::GetCursorScreenPos();
const float w = ImGui::GetContentRegionAvail().x;
if (ImGui::Selectable("##it", selected,
ImGuiSelectableFlags_SpanAvailWidth | ImGuiSelectableFlags_AllowDoubleClick,
ImVec2(0, rowH))) {
if (!selected) s_confirm_delete_idx = -1;
s_selected_index = static_cast<int>(i);
if (ImGui::IsMouseDoubleClicked(0)) openEdit();
}
const bool hov = ImGui::IsItemHovered();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
const ImVec2 mn = p, mx(p.x + w, p.y + rowH);
if (asCard) {
ImU32 fill = selected ? material::WithAlpha(material::Primary(), 42)
: hov ? material::WithAlpha(material::OnSurface(), 26)
: material::WithAlpha(material::OnSurface(), 12);
dl->AddRectFilled(mn, mx, fill, round);
if (selected) dl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 150), round, 0, 1.6f * dp);
} else {
if (selected) dl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 34), 0);
else if (hov) dl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 16), 0);
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.
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);
// Label (line 1) + muted address (line 2).
const float tx = mn.x + pad + avR * 2.0f + pad;
const float trailW = entry.isGlobal() ? 28.0f * dp : 0.0f;
const float textMaxX = mx.x - pad - trailW;
const float blockH = lblF->LegacySize + adrF->LegacySize + 3.0f * dp;
const float ty = mn.y + rowH * 0.5f - blockH * 0.5f;
dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true);
dl->AddText(lblF, lblF->LegacySize, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str());
dl->PopClipRect();
std::string addr = util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate);
dl->AddText(adrF, adrF->LegacySize, ImVec2(tx, ty + lblF->LegacySize + 3.0f * dp),
material::OnSurfaceMedium(), addr.c_str());
// Trailing globe badge for global contacts.
if (entry.isGlobal()) {
const ImVec2 gs = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, ICON_MD_PUBLIC);
dl->AddText(icoF, icoF->LegacySize,
ImVec2(mx.x - pad - gs.x, mn.y + rowH * 0.5f - gs.y * 0.5f),
material::OnSurfaceMedium(), ICON_MD_PUBLIC);
}
if (hov) material::Tooltip("%s", entry.address.c_str());
ImGui::PopID();
}
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
}
ImGui::EndChild();
ImGui::PopStyleColor();
}
// Status line // Status line — singular form for exactly one contact ("1 address saved", not "1 addresses saved").
ImGui::TextDisabled(TR("address_book_count"), book.size()); ImGui::TextDisabled(TR(book.size() == 1 ? "address_book_count_one" : "address_book_count"), book.size());
// Keyboard shortcuts — only when the tab owns focus (no field being edited, no modal up). // Keyboard shortcuts — only when the tab owns focus (no field being edited, no modal up).
if (!searchActive && !ImGui::IsAnyItemActive() && if (!searchActive && !ImGui::IsAnyItemActive() &&

View File

@@ -85,9 +85,20 @@ void KeyExportDialog::render(App* app)
(void)keyDisplay; (void)keyDisplay;
(void)copyBtn; (void)copyBtn;
// Modal scales to 85% of the window width. BeginOverlayDialog multiplies the width by // Size the card to the address field's own natural width — the widest deterministic element before
// Layout::dpiScale(); divide it out here so the final card is exactly 85% at any font scale. // the key is revealed — so the field fills the card and sits under its left-aligned "Address:" label
const float cardW = (0.85f * ImGui::GetMainViewport()->Size.x) / Layout::dpiScale(); // instead of floating centered in an over-wide card. This adapts to the address type (z vs t) and the
// font scale automatically (no magic width). Mirrors AddressCopyField's boxW math; the revealed
// key+QR layout adapts to whatever width this yields (it stacks the QR when narrow). Bounded to 85%
// of the window as an upper safety limit. BeginOverlayDialog re-applies dpiScale(), so divide it out.
const std::string chunkedAddr = widgets::ChunkString(s_address, 4);
const float addrPadX = ImGui::GetStyle().FramePadding.x + 4.0f; // matches AddressCopyField
const float addrBoxW = ImGui::CalcTextSize(chunkedAddr.c_str()).x + addrPadX * 2.0f + 2.0f;
// The BlurFloat overlay card insets content by 28px per side (draw_helpers BeginOverlayDialog); add
// that (+ a little slack) so the address box fits on one line under its label instead of wrapping.
const float wantW = addrBoxW + 28.0f * 2.0f + 12.0f;
const float maxW = 0.85f * ImGui::GetMainViewport()->Size.x;
const float cardW = (wantW < maxW ? wantW : maxW) / Layout::dpiScale();
material::OverlayDialogSpec ov; material::OverlayDialogSpec ov;
ov.title = title; ov.p_open = &s_open; ov.title = title; ov.p_open = &s_open;
ov.style = material::OverlayStyle::BlurFloat; ov.style = material::OverlayStyle::BlurFloat;

View File

@@ -64,9 +64,18 @@ void QRPopupDialog::render(App* app)
auto qr = S.drawElement("dialogs.qr-popup", "qr-code"); auto qr = S.drawElement("dialogs.qr-popup", "qr-code");
auto actionBtn = S.button("dialogs.qr-popup", "action-button"); auto actionBtn = S.button("dialogs.qr-popup", "action-button");
// Match the key-export modal: 85% of the window width (divide out the dpiScale that // Size the card to the address's own natural (chunked) box width — the widest content element — so
// BeginOverlayDialog re-applies, so the final card is exactly 85% at any font scale). // AddressCopyField fills the card under its label instead of floating centered in an over-wide card
const float cardW = (0.85f * ImGui::GetMainViewport()->Size.x) / Layout::dpiScale(); // (matches the key-export dialog). Adapts to address type + font scale; bounded to 85% of the window.
const float dp = Layout::dpiScale();
const std::string chunkedAddr = widgets::ChunkString(s_address, 4);
const float addrPadX = ImGui::GetStyle().FramePadding.x + 4.0f; // matches AddressCopyField
const float addrBoxW = ImGui::CalcTextSize(chunkedAddr.c_str()).x + addrPadX * 2.0f + 2.0f;
// The BlurFloat overlay card insets content by 28px per side (draw_helpers BeginOverlayDialog); add
// that (+ a little slack) so the address box fits on one line under its label instead of wrapping.
const float wantW = addrBoxW + 28.0f * 2.0f + 12.0f;
const float maxW = 0.85f * ImGui::GetMainViewport()->Size.x;
const float cardW = (wantW < maxW ? wantW : maxW) / dp;
material::OverlayDialogSpec ov; material::OverlayDialogSpec ov;
ov.title = TR("qr_title"); ov.p_open = &s_open; ov.title = TR("qr_title"); ov.p_open = &s_open;
ov.style = material::OverlayStyle::BlurFloat; ov.style = material::OverlayStyle::BlurFloat;
@@ -83,10 +92,10 @@ void QRPopupDialog::render(App* app)
// Center the QR code (responsive — larger, to suit the wider modal). // Center the QR code (responsive — larger, to suit the wider modal).
float window_width = ImGui::GetWindowWidth(); float window_width = ImGui::GetWindowWidth();
float qr_size = qr.size > 0 ? (float)qr.size : 280.0f; float qr_size = (qr.size > 0 ? (float)qr.size : 280.0f) * dp; // schema/fallback are logical px
const float responsive = window_width * 0.5f; const float responsive = window_width * 0.5f; // window_width is already dp-scaled
if (responsive > qr_size) qr_size = responsive; if (responsive > qr_size) qr_size = responsive;
if (qr_size > 420.0f) qr_size = 420.0f; if (qr_size > 420.0f * dp) qr_size = 420.0f * dp;
float padding = (window_width - qr_size) / 2.0f; float padding = (window_width - qr_size) / 2.0f;
if (padding < 0.0f) padding = 0.0f; if (padding < 0.0f) padding = 0.0f;

View File

@@ -1225,6 +1225,7 @@ void I18n::loadBuiltinEnglish()
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";
strings_["address_book_count_one"] = "%zu address saved";
strings_["address_book_deleted"] = "Entry deleted"; strings_["address_book_deleted"] = "Entry deleted";
strings_["address_book_edit"] = "Edit Address"; strings_["address_book_edit"] = "Edit Address";
strings_["address_book_empty"] = "No saved addresses. Click 'Add New' to add one."; strings_["address_book_empty"] = "No saved addresses. Click 'Add New' to add one.";