feat(ui): promote the address book to a Contacts sidebar tab

Phase 0b. The address book was buried in Settings behind a modal dialog.
Make it a first-class "Contacts" tab (which will also become the chat
roster), rendered inline in the main content area.

- New NavPage::Contacts (after History; ICON_MD_CONTACTS) +
  WalletUiSurface::Contacts. isUiSurfaceAvailable's `default: return true`
  shows it in BOTH variants; uiSurfaceNeedsWalletData's default keeps it
  usable before wallet data loads. All the touchpoints wired: NavPageSurface,
  GetNavIconMD, the app.cpp dispatch case, the app_network.cpp tracePageName
  case, and the `contacts` i18n label.
- New src/ui/windows/contacts_tab.{h,cpp}: RenderContactsTab lifts the
  toolbar + table + add/edit modal out of address_book_dialog, rendered in a
  BeginChild scroll region (peers_tab pattern) instead of an overlay; the
  add/edit form stays a modal layered over the tab. Reuses the existing
  address_book_* i18n keys and the dialogs.address-book schema.
- Delete address_book_dialog.{h,cpp}; remove its app.cpp render pump and the
  dead App::show_address_book_. The Settings "Address Book…" button now
  navigates to the tab (setCurrentPage) instead of opening the modal, so the
  Tools & Actions grid layout is untouched.
- CMake: swap the dialog sources for contacts_tab.

Behavior-preserving move; search/sort/keyboard/contrast land in 0d. Visual
check pending the per-theme screenshot sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 16:59:34 -05:00
parent 62998179ec
commit ee59a9e2cd
12 changed files with 310 additions and 335 deletions

View File

@@ -470,7 +470,7 @@ set(APP_SOURCES
src/ui/windows/transaction_details_dialog.cpp src/ui/windows/transaction_details_dialog.cpp
src/ui/windows/qr_popup_dialog.cpp src/ui/windows/qr_popup_dialog.cpp
src/ui/windows/validate_address_dialog.cpp src/ui/windows/validate_address_dialog.cpp
src/ui/windows/address_book_dialog.cpp src/ui/windows/contacts_tab.cpp
src/ui/windows/shield_dialog.cpp src/ui/windows/shield_dialog.cpp
src/ui/windows/request_payment_dialog.cpp src/ui/windows/request_payment_dialog.cpp
src/ui/windows/block_info_dialog.cpp src/ui/windows/block_info_dialog.cpp
@@ -594,7 +594,7 @@ set(APP_HEADERS
src/ui/windows/transaction_details_dialog.h src/ui/windows/transaction_details_dialog.h
src/ui/windows/qr_popup_dialog.h src/ui/windows/qr_popup_dialog.h
src/ui/windows/validate_address_dialog.h src/ui/windows/validate_address_dialog.h
src/ui/windows/address_book_dialog.h src/ui/windows/contacts_tab.h
src/ui/windows/shield_dialog.h src/ui/windows/shield_dialog.h
src/ui/windows/request_payment_dialog.h src/ui/windows/request_payment_dialog.h
src/ui/windows/block_info_dialog.h src/ui/windows/block_info_dialog.h

View File

@@ -41,7 +41,7 @@
#include "ui/windows/transaction_details_dialog.h" #include "ui/windows/transaction_details_dialog.h"
#include "ui/windows/qr_popup_dialog.h" #include "ui/windows/qr_popup_dialog.h"
#include "ui/windows/validate_address_dialog.h" #include "ui/windows/validate_address_dialog.h"
#include "ui/windows/address_book_dialog.h" #include "ui/windows/contacts_tab.h"
#include "ui/windows/shield_dialog.h" #include "ui/windows/shield_dialog.h"
#include "ui/windows/request_payment_dialog.h" #include "ui/windows/request_payment_dialog.h"
#include "ui/windows/block_info_dialog.h" #include "ui/windows/block_info_dialog.h"
@@ -1691,6 +1691,9 @@ void App::render()
case ui::NavPage::History: case ui::NavPage::History:
ui::RenderTransactionsTab(this); ui::RenderTransactionsTab(this);
break; break;
case ui::NavPage::Contacts:
ui::RenderContactsTab(this);
break;
case ui::NavPage::Mining: case ui::NavPage::Mining:
ui::RenderMiningTab(this); ui::RenderMiningTab(this);
break; break;
@@ -1889,9 +1892,6 @@ void App::render()
// Validate address dialog (triggered from Edit menu) // Validate address dialog (triggered from Edit menu)
ui::ValidateAddressDialog::render(this); ui::ValidateAddressDialog::render(this);
// Address book dialog (triggered from Edit menu)
ui::AddressBookDialog::render(this);
// Shield/merge dialog (triggered from Wallet menu) // Shield/merge dialog (triggered from Wallet menu)
ui::ShieldDialog::render(this); ui::ShieldDialog::render(this);

View File

@@ -621,7 +621,6 @@ private:
bool show_import_key_ = false; bool show_import_key_ = false;
bool show_export_key_ = false; bool show_export_key_ = false;
bool show_backup_ = false; bool show_backup_ = false;
bool show_address_book_ = false;
// Embedded daemon state // Embedded daemon state
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities()); bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());

View File

@@ -100,6 +100,7 @@ const char* tracePageName(ui::NavPage page)
case ui::NavPage::Send: return "Send tab"; case ui::NavPage::Send: return "Send tab";
case ui::NavPage::Receive: return "Receive tab"; case ui::NavPage::Receive: return "Receive tab";
case ui::NavPage::History: return "History tab"; case ui::NavPage::History: return "History tab";
case ui::NavPage::Contacts: return "Contacts tab";
case ui::NavPage::Mining: return "Mining tab"; case ui::NavPage::Mining: return "Mining tab";
case ui::NavPage::Market: return "Market tab"; case ui::NavPage::Market: return "Market tab";
case ui::NavPage::Console: return "Console tab"; case ui::NavPage::Console: return "Console tab";

View File

@@ -33,7 +33,6 @@
#include "../material/type.h" #include "../material/type.h"
#include "../material/colors.h" #include "../material/colors.h"
#include "../windows/validate_address_dialog.h" #include "../windows/validate_address_dialog.h"
#include "../windows/address_book_dialog.h"
#include "../windows/shield_dialog.h" #include "../windows/shield_dialog.h"
#include "../windows/request_payment_dialog.h" #include "../windows/request_payment_dialog.h"
#include "../windows/block_info_dialog.h" #include "../windows/block_info_dialog.h"
@@ -1260,7 +1259,7 @@ void RenderSettingsPage(App* app) {
bw = std::max(minBtnW, bw); bw = std::max(minBtnW, bw);
if (TactileButton(TR("settings_address_book"), ImVec2(bw, 0), S.resolveFont("button"))) if (TactileButton(TR("settings_address_book"), ImVec2(bw, 0), S.resolveFont("button")))
AddressBookDialog::show(); app->setCurrentPage(ui::NavPage::Contacts); // now a top-level tab
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_address_book")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_address_book"));
ImGui::SameLine(0, btnSpacing); ImGui::SameLine(0, btnSpacing);
if (TactileButton(TR("settings_validate_address"), ImVec2(bw, 0), S.resolveFont("button"))) if (TactileButton(TR("settings_validate_address"), ImVec2(bw, 0), S.resolveFont("button")))

View File

@@ -25,6 +25,7 @@ enum class NavPage {
Send, Send,
Receive, Receive,
History, History,
Contacts,
// --- separator --- // --- separator ---
Mining, Mining,
Market, Market,
@@ -51,6 +52,7 @@ inline const NavItem kNavItems[] = {
{ "Send", NavPage::Send, nullptr, "send", nullptr }, { "Send", NavPage::Send, nullptr, "send", nullptr },
{ "Receive", NavPage::Receive, nullptr, "receive", nullptr }, { "Receive", NavPage::Receive, nullptr, "receive", nullptr },
{ "History", NavPage::History, nullptr, "history", nullptr }, { "History", NavPage::History, nullptr, "history", nullptr },
{ "Contacts", NavPage::Contacts, nullptr, "contacts", nullptr },
{ "Mining", NavPage::Mining, "TOOLS", "mining", "tools" }, { "Mining", NavPage::Mining, "TOOLS", "mining", "tools" },
{ "Market", NavPage::Market, nullptr, "market", nullptr }, { "Market", NavPage::Market, nullptr, "market", nullptr },
{ "Console", NavPage::Console, "ADVANCED","console", "advanced" }, { "Console", NavPage::Console, "ADVANCED","console", "advanced" },
@@ -78,6 +80,7 @@ inline wallet::WalletUiSurface NavPageSurface(NavPage page)
case NavPage::Send: return wallet::WalletUiSurface::Send; case NavPage::Send: return wallet::WalletUiSurface::Send;
case NavPage::Receive: return wallet::WalletUiSurface::Receive; case NavPage::Receive: return wallet::WalletUiSurface::Receive;
case NavPage::History: return wallet::WalletUiSurface::History; case NavPage::History: return wallet::WalletUiSurface::History;
case NavPage::Contacts: return wallet::WalletUiSurface::Contacts;
case NavPage::Mining: return wallet::WalletUiSurface::Mining; case NavPage::Mining: return wallet::WalletUiSurface::Mining;
case NavPage::Market: return wallet::WalletUiSurface::Market; case NavPage::Market: return wallet::WalletUiSurface::Market;
case NavPage::Console: return wallet::WalletUiSurface::Console; case NavPage::Console: return wallet::WalletUiSurface::Console;
@@ -103,6 +106,7 @@ inline const char* GetNavIconMD(NavPage page)
case NavPage::Send: return ICON_MD_CALL_MADE; case NavPage::Send: return ICON_MD_CALL_MADE;
case NavPage::Receive: return ICON_MD_CALL_RECEIVED; case NavPage::Receive: return ICON_MD_CALL_RECEIVED;
case NavPage::History: return ICON_MD_HISTORY; case NavPage::History: return ICON_MD_HISTORY;
case NavPage::Contacts: return ICON_MD_CONTACTS;
case NavPage::Mining: return ICON_MD_CONSTRUCTION; case NavPage::Mining: return ICON_MD_CONSTRUCTION;
case NavPage::Market: return ICON_MD_TRENDING_UP; case NavPage::Market: return ICON_MD_TRENDING_UP;
case NavPage::Console: return ICON_MD_TERMINAL; case NavPage::Console: return ICON_MD_TERMINAL;

View File

@@ -1,279 +0,0 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "address_book_dialog.h"
#include "../../app.h"
#include "../../data/address_book.h"
#include "../../util/i18n.h"
#include "../../util/text_format.h"
#include "../notifications.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
#include "imgui.h"
#include <cstring>
namespace dragonx {
namespace ui {
// Static member initialization
bool AddressBookDialog::s_open = false;
int AddressBookDialog::s_selected_index = -1;
bool AddressBookDialog::s_show_add_dialog = false;
bool AddressBookDialog::s_show_edit_dialog = false;
char AddressBookDialog::s_edit_label[128] = "";
char AddressBookDialog::s_edit_address[512] = "";
char AddressBookDialog::s_edit_notes[512] = "";
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
if (destSize == 0) return;
std::strncpy(dest, source.c_str(), destSize - 1);
dest[destSize - 1] = '\0';
}
void AddressBookDialog::show()
{
s_open = true;
s_selected_index = -1;
s_show_add_dialog = false;
s_show_edit_dialog = false;
// The address book is App-owned and loaded once at startup (App::init); it stays
// authoritative for the app lifetime and self-saves on every mutation, so there is
// nothing to reload here. (show() is static and has no App* to reach it anyway.)
}
bool AddressBookDialog::isOpen()
{
return s_open;
}
void AddressBookDialog::render(App* app)
{
if (!s_open) return;
auto& S = schema::UI();
auto win = S.window("dialogs.address-book");
auto addrTable = S.table("dialogs.address-book", "address-table");
auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label");
auto addrBackLbl = S.label("dialogs.address-book", "address-back-label");
auto addrInput = S.input("dialogs.address-book", "address-input");
auto notesInput = S.input("dialogs.address-book", "notes-input");
auto actionBtn = S.button("dialogs.address-book", "action-button");
auto clearEditFields = []() {
s_edit_label[0] = '\0';
s_edit_address[0] = '\0';
s_edit_notes[0] = '\0';
};
auto loadEditFields = [](const data::AddressBookEntry& entry) {
copyEditField(s_edit_label, sizeof(s_edit_label), entry.label);
copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
};
auto renderEntryDialog = [&]() {
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";
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth());
float formW = addrInput.width > 0 ? addrInput.width : Layout::kDialogFormWidth();
float actionW = actionBtn.width > 0 ? actionBtn.width : Layout::kDialogActionWidth();
float actionGap = actionBtn.gap > 0 ? actionBtn.gap : Layout::kDialogActionGap();
float notesH = notesInput.height > 0 ? notesInput.height : 60.0f;
if (material::BeginOverlayDialog(title, open, dialogW, 0.94f,
Layout::kDialogCompactBottomRatio(), id)) {
material::LabeledInput(TR("label"), isEdit ? "##EditLabel" : "##AddLabel",
s_edit_label, sizeof(s_edit_label), formW);
ImGui::Spacing();
material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress",
s_edit_address, sizeof(s_edit_address), formW);
if (!isEdit) {
ImGui::SameLine();
if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
const char* clipboard = ImGui::GetClipboardText();
if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard);
}
}
ImGui::Spacing();
material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes",
s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH));
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
float totalActionsW = actionW * 2.0f + actionGap;
material::BeginOverlayDialogFooter(totalActionsW);
if (!canSubmit) ImGui::BeginDisabled();
const char* primaryLabel = isEdit ? TR("save") : TR("add");
if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
// 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());
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
return s;
};
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
if (isEdit) {
if (app->addressBook().updateEntry(s_selected_index, entry)) {
Notifications::instance().success(TR("address_book_updated"));
s_show_edit_dialog = false;
} else {
Notifications::instance().error(TR("address_book_update_failed"));
}
} else {
if (app->addressBook().addEntry(entry)) {
Notifications::instance().success(TR("address_book_added"));
s_show_add_dialog = false;
} else {
Notifications::instance().error(TR("address_book_exists"));
}
}
}
if (!canSubmit) ImGui::EndDisabled();
ImGui::SameLine(0, actionGap);
if (material::StyledButton(TR("cancel"), ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
*open = false;
}
material::EndOverlayDialog();
}
};
if (material::BeginOverlayDialog(TR("address_book_title"), &s_open, win.width, 0.94f)) {
auto& book = app->addressBook();
// Toolbar
if (material::StyledButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
s_show_add_dialog = true;
clearEditFields();
}
ImGui::SameLine();
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
if (!has_selection) ImGui::BeginDisabled();
if (material::StyledButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) {
const auto& entry = book.entries()[s_selected_index];
loadEditFields(entry);
s_show_edit_dialog = true;
}
}
ImGui::SameLine();
static int s_confirmDeleteIdx = -1;
if (material::StyledButton(TR("delete"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) {
if (s_confirmDeleteIdx == s_selected_index) {
book.removeEntry(s_selected_index);
s_selected_index = -1;
s_confirmDeleteIdx = -1;
Notifications::instance().success(TR("address_book_deleted"));
} else {
// Require a second click to confirm (no undo for a removed contact).
s_confirmDeleteIdx = s_selected_index;
Notifications::instance().warning("Click Delete again to remove this entry.");
}
}
}
ImGui::SameLine();
if (material::StyledButton(TR("copy_address"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) {
ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str());
Notifications::instance().info(TR("address_copied"));
}
}
if (!has_selection) ImGui::EndDisabled();
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Address list
if (ImGui::BeginTable("AddressBookTable", 3,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY,
ImVec2(0, addrTable.bottomReserve > 0 ? -addrTable.bottomReserve : -35)))
{
float labelColW = (addrTable.columns.count("label") && addrTable.columns.at("label").width > 0) ? addrTable.columns.at("label").width : 150;
float notesColW = (addrTable.columns.count("notes") && addrTable.columns.at("notes").width > 0) ? addrTable.columns.at("notes").width : 150;
ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthFixed, labelColW);
ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthFixed, notesColW);
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow();
if (book.empty()) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", TR("address_book_empty"));
} else {
for (size_t i = 0; i < book.size(); i++) {
const auto& entry = book.entries()[i];
ImGui::TableNextRow();
ImGui::PushID(static_cast<int>(i));
ImGui::TableNextColumn();
bool is_selected = (s_selected_index == static_cast<int>(i));
if (ImGui::Selectable(entry.label.c_str(), is_selected,
ImGuiSelectableFlags_SpanAllColumns)) {
s_selected_index = static_cast<int>(i);
}
// Double-click to edit
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
s_selected_index = static_cast<int>(i);
loadEditFields(entry);
s_show_edit_dialog = true;
}
ImGui::TableNextColumn();
// Truncate long addresses
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::TextDisabled("%s", addr_display.c_str());
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", entry.address.c_str());
}
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", entry.notes.c_str());
ImGui::PopID();
}
}
ImGui::EndTable();
}
// Status line
ImGui::TextDisabled(TR("address_book_count"), book.size());
material::EndOverlayDialog();
}
renderEntryDialog();
}
} // namespace ui
} // namespace dragonx

View File

@@ -1,45 +0,0 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
namespace dragonx {
class App;
namespace ui {
/**
* @brief Address book dialog for managing saved addresses
*/
class AddressBookDialog {
public:
/**
* @brief Show the address book dialog
*/
static void show();
/**
* @brief Render the dialog (call every frame)
* @param app Pointer to app instance
*/
static void render(App* app);
/**
* @brief Check if dialog is currently open
*/
static bool isOpen();
private:
static bool s_open;
static int s_selected_index;
static bool s_show_add_dialog;
static bool s_show_edit_dialog;
static char s_edit_label[128];
static char s_edit_address[512];
static char s_edit_notes[512];
};
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,269 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "contacts_tab.h"
#include "../../app.h"
#include "../../data/address_book.h"
#include "../../util/i18n.h"
#include "../../util/text_format.h"
#include "../notifications.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
#include "imgui.h"
#include <cstring>
namespace dragonx {
namespace ui {
// Tab state (formerly AddressBookDialog's class statics). One selection/edit
// buffer set for the whole tab; the add/edit form is a modal layered on top.
static int s_selected_index = -1;
static bool s_show_add_dialog = false;
static bool s_show_edit_dialog = false;
static char s_edit_label[128] = "";
static char s_edit_address[512] = "";
static char s_edit_notes[512] = "";
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
if (destSize == 0) return;
std::strncpy(dest, source.c_str(), destSize - 1);
dest[destSize - 1] = '\0';
}
void RenderContactsTab(App* app)
{
auto& S = schema::UI();
// Reuse the existing address-book schema/column config for the table + add/edit form.
auto addrTable = S.table("dialogs.address-book", "address-table");
auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label");
auto addrBackLbl = S.label("dialogs.address-book", "address-back-label");
auto addrInput = S.input("dialogs.address-book", "address-input");
auto notesInput = S.input("dialogs.address-book", "notes-input");
auto actionBtn = S.button("dialogs.address-book", "action-button");
auto clearEditFields = []() {
s_edit_label[0] = '\0';
s_edit_address[0] = '\0';
s_edit_notes[0] = '\0';
};
auto loadEditFields = [](const data::AddressBookEntry& entry) {
copyEditField(s_edit_label, sizeof(s_edit_label), entry.label);
copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
};
// Add/edit form — a modal popup layered over the tab.
auto renderEntryDialog = [&]() {
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";
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth());
float formW = addrInput.width > 0 ? addrInput.width : Layout::kDialogFormWidth();
float actionW = actionBtn.width > 0 ? actionBtn.width : Layout::kDialogActionWidth();
float actionGap = actionBtn.gap > 0 ? actionBtn.gap : Layout::kDialogActionGap();
float notesH = notesInput.height > 0 ? notesInput.height : 60.0f;
if (material::BeginOverlayDialog(title, open, dialogW, 0.94f,
Layout::kDialogCompactBottomRatio(), id)) {
material::LabeledInput(TR("label"), isEdit ? "##EditLabel" : "##AddLabel",
s_edit_label, sizeof(s_edit_label), formW);
ImGui::Spacing();
material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress",
s_edit_address, sizeof(s_edit_address), formW);
if (!isEdit) {
ImGui::SameLine();
if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
const char* clipboard = ImGui::GetClipboardText();
if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard);
}
}
ImGui::Spacing();
material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes",
s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH));
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
float totalActionsW = actionW * 2.0f + actionGap;
material::BeginOverlayDialogFooter(totalActionsW);
if (!canSubmit) ImGui::BeginDisabled();
const char* primaryLabel = isEdit ? TR("save") : TR("add");
if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
// 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());
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
return s;
};
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
if (isEdit) {
if (app->addressBook().updateEntry(s_selected_index, entry)) {
Notifications::instance().success(TR("address_book_updated"));
s_show_edit_dialog = false;
} else {
Notifications::instance().error(TR("address_book_update_failed"));
}
} else {
if (app->addressBook().addEntry(entry)) {
Notifications::instance().success(TR("address_book_added"));
s_show_add_dialog = false;
} else {
Notifications::instance().error(TR("address_book_exists"));
}
}
}
if (!canSubmit) ImGui::EndDisabled();
ImGui::SameLine(0, actionGap);
if (material::StyledButton(TR("cancel"), ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
*open = false;
}
material::EndOverlayDialog();
}
};
// Inline tab content lives in a scroll child (mirrors peers_tab / explorer_tab).
ImVec2 avail = ImGui::GetContentRegionAvail();
ImGui::BeginChild("##ContactsScroll", avail, false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
auto& book = app->addressBook();
// Toolbar
if (material::StyledButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
s_show_add_dialog = true;
clearEditFields();
}
ImGui::SameLine();
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
if (!has_selection) ImGui::BeginDisabled();
if (material::StyledButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) {
const auto& entry = book.entries()[s_selected_index];
loadEditFields(entry);
s_show_edit_dialog = true;
}
}
ImGui::SameLine();
static int s_confirmDeleteIdx = -1;
if (material::StyledButton(TR("delete"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) {
if (s_confirmDeleteIdx == s_selected_index) {
book.removeEntry(s_selected_index);
s_selected_index = -1;
s_confirmDeleteIdx = -1;
Notifications::instance().success(TR("address_book_deleted"));
} else {
// Require a second click to confirm (no undo for a removed contact).
s_confirmDeleteIdx = s_selected_index;
Notifications::instance().warning("Click Delete again to remove this entry.");
}
}
}
ImGui::SameLine();
if (material::StyledButton(TR("copy_address"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) {
ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str());
Notifications::instance().info(TR("address_copied"));
}
}
if (!has_selection) ImGui::EndDisabled();
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Address list — size the table to fill the tab, leaving room for the count footer.
float footerH = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y;
float tableH = ImGui::GetContentRegionAvail().y - footerH;
if (tableH < 120.0f) tableH = 120.0f;
if (ImGui::BeginTable("AddressBookTable", 3,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY,
ImVec2(0, tableH)))
{
float labelColW = (addrTable.columns.count("label") && addrTable.columns.at("label").width > 0) ? addrTable.columns.at("label").width : 150;
float notesColW = (addrTable.columns.count("notes") && addrTable.columns.at("notes").width > 0) ? addrTable.columns.at("notes").width : 150;
ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthFixed, labelColW);
ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthFixed, notesColW);
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow();
if (book.empty()) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", TR("address_book_empty"));
} else {
for (size_t i = 0; i < book.size(); i++) {
const auto& entry = book.entries()[i];
ImGui::TableNextRow();
ImGui::PushID(static_cast<int>(i));
ImGui::TableNextColumn();
bool is_selected = (s_selected_index == static_cast<int>(i));
if (ImGui::Selectable(entry.label.c_str(), is_selected,
ImGuiSelectableFlags_SpanAllColumns)) {
s_selected_index = static_cast<int>(i);
}
// Double-click to edit
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
s_selected_index = static_cast<int>(i);
loadEditFields(entry);
s_show_edit_dialog = true;
}
ImGui::TableNextColumn();
// Truncate long addresses
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::TextDisabled("%s", addr_display.c_str());
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", entry.address.c_str());
}
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", entry.notes.c_str());
ImGui::PopID();
}
}
ImGui::EndTable();
}
// Status line
ImGui::TextDisabled(TR("address_book_count"), book.size());
ImGui::EndChild();
renderEntryDialog();
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,25 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
namespace dragonx {
class App;
namespace ui {
/**
* @brief Render the Contacts tab (the address book, promoted out of Settings).
*
* Doubles as the future chat roster. Reads/writes the App-owned AddressBook
* (App::addressBook()). Renders inline in the main content area; the add/edit
* form is a modal popup layered over the tab.
*
* @param app Pointer to the app instance.
*/
void RenderContactsTab(App* app);
} // namespace ui
} // namespace dragonx

View File

@@ -210,6 +210,7 @@ void I18n::loadBuiltinEnglish()
strings_["receive"] = "Receive"; strings_["receive"] = "Receive";
strings_["transactions"] = "Transactions"; strings_["transactions"] = "Transactions";
strings_["history"] = "History"; strings_["history"] = "History";
strings_["contacts"] = "Contacts";
strings_["mining"] = "Mining"; strings_["mining"] = "Mining";
strings_["peers"] = "Peers"; strings_["peers"] = "Peers";
strings_["market"] = "Market"; strings_["market"] = "Market";

View File

@@ -34,6 +34,7 @@ enum class WalletUiSurface {
Send, Send,
Receive, Receive,
History, History,
Contacts, // address book / chat roster; available in both variants (default: return true)
Mining, Mining,
Market, Market,
Console, Console,