From ee59a9e2cd3eba591d8bcb25b4720d906d7b5f04 Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 5 Jul 2026 16:59:34 -0500 Subject: [PATCH] feat(ui): promote the address book to a Contacts sidebar tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CMakeLists.txt | 4 +- src/app.cpp | 10 +- src/app.h | 3 +- src/app_network.cpp | 1 + src/ui/pages/settings_page.cpp | 3 +- src/ui/sidebar.h | 4 + src/ui/windows/address_book_dialog.cpp | 279 ------------------------- src/ui/windows/address_book_dialog.h | 45 ---- src/ui/windows/contacts_tab.cpp | 269 ++++++++++++++++++++++++ src/ui/windows/contacts_tab.h | 25 +++ src/util/i18n.cpp | 1 + src/wallet/wallet_capabilities.h | 1 + 12 files changed, 310 insertions(+), 335 deletions(-) delete mode 100644 src/ui/windows/address_book_dialog.cpp delete mode 100644 src/ui/windows/address_book_dialog.h create mode 100644 src/ui/windows/contacts_tab.cpp create mode 100644 src/ui/windows/contacts_tab.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 3a4b7d8..d1485c2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -470,7 +470,7 @@ set(APP_SOURCES src/ui/windows/transaction_details_dialog.cpp src/ui/windows/qr_popup_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/request_payment_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/qr_popup_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/request_payment_dialog.h src/ui/windows/block_info_dialog.h diff --git a/src/app.cpp b/src/app.cpp index e7a2d48..028ec56 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -41,7 +41,7 @@ #include "ui/windows/transaction_details_dialog.h" #include "ui/windows/qr_popup_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/request_payment_dialog.h" #include "ui/windows/block_info_dialog.h" @@ -1691,6 +1691,9 @@ void App::render() case ui::NavPage::History: ui::RenderTransactionsTab(this); break; + case ui::NavPage::Contacts: + ui::RenderContactsTab(this); + break; case ui::NavPage::Mining: ui::RenderMiningTab(this); break; @@ -1888,10 +1891,7 @@ void App::render() // Validate address dialog (triggered from Edit menu) ui::ValidateAddressDialog::render(this); - - // Address book dialog (triggered from Edit menu) - ui::AddressBookDialog::render(this); - + // Shield/merge dialog (triggered from Wallet menu) ui::ShieldDialog::render(this); diff --git a/src/app.h b/src/app.h index 99eb7a2..ec41ff8 100644 --- a/src/app.h +++ b/src/app.h @@ -621,8 +621,7 @@ private: bool show_import_key_ = false; bool show_export_key_ = false; bool show_backup_ = false; - bool show_address_book_ = false; - + // Embedded daemon state bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities()); std::string daemon_status_; diff --git a/src/app_network.cpp b/src/app_network.cpp index 4ad083f..73a4c48 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -100,6 +100,7 @@ const char* tracePageName(ui::NavPage page) case ui::NavPage::Send: return "Send tab"; case ui::NavPage::Receive: return "Receive 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::Market: return "Market tab"; case ui::NavPage::Console: return "Console tab"; diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 58f28ff..637692f 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -33,7 +33,6 @@ #include "../material/type.h" #include "../material/colors.h" #include "../windows/validate_address_dialog.h" -#include "../windows/address_book_dialog.h" #include "../windows/shield_dialog.h" #include "../windows/request_payment_dialog.h" #include "../windows/block_info_dialog.h" @@ -1260,7 +1259,7 @@ void RenderSettingsPage(App* app) { bw = std::max(minBtnW, bw); 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")); ImGui::SameLine(0, btnSpacing); if (TactileButton(TR("settings_validate_address"), ImVec2(bw, 0), S.resolveFont("button"))) diff --git a/src/ui/sidebar.h b/src/ui/sidebar.h index e1ffab7..0f8614a 100644 --- a/src/ui/sidebar.h +++ b/src/ui/sidebar.h @@ -25,6 +25,7 @@ enum class NavPage { Send, Receive, History, + Contacts, // --- separator --- Mining, Market, @@ -51,6 +52,7 @@ inline const NavItem kNavItems[] = { { "Send", NavPage::Send, nullptr, "send", nullptr }, { "Receive", NavPage::Receive, nullptr, "receive", nullptr }, { "History", NavPage::History, nullptr, "history", nullptr }, + { "Contacts", NavPage::Contacts, nullptr, "contacts", nullptr }, { "Mining", NavPage::Mining, "TOOLS", "mining", "tools" }, { "Market", NavPage::Market, nullptr, "market", nullptr }, { "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::Receive: return wallet::WalletUiSurface::Receive; case NavPage::History: return wallet::WalletUiSurface::History; + case NavPage::Contacts: return wallet::WalletUiSurface::Contacts; case NavPage::Mining: return wallet::WalletUiSurface::Mining; case NavPage::Market: return wallet::WalletUiSurface::Market; 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::Receive: return ICON_MD_CALL_RECEIVED; case NavPage::History: return ICON_MD_HISTORY; + case NavPage::Contacts: return ICON_MD_CONTACTS; case NavPage::Mining: return ICON_MD_CONSTRUCTION; case NavPage::Market: return ICON_MD_TRENDING_UP; case NavPage::Console: return ICON_MD_TERMINAL; diff --git a/src/ui/windows/address_book_dialog.cpp b/src/ui/windows/address_book_dialog.cpp deleted file mode 100644 index 2b0be1b..0000000 --- a/src/ui/windows/address_book_dialog.cpp +++ /dev/null @@ -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 - -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(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(i)); - - ImGui::TableNextColumn(); - bool is_selected = (s_selected_index == static_cast(i)); - if (ImGui::Selectable(entry.label.c_str(), is_selected, - ImGuiSelectableFlags_SpanAllColumns)) { - s_selected_index = static_cast(i); - } - - // Double-click to edit - if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) { - s_selected_index = static_cast(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(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 diff --git a/src/ui/windows/address_book_dialog.h b/src/ui/windows/address_book_dialog.h deleted file mode 100644 index 120cd55..0000000 --- a/src/ui/windows/address_book_dialog.h +++ /dev/null @@ -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 diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp new file mode 100644 index 0000000..95ea52f --- /dev/null +++ b/src/ui/windows/contacts_tab.cpp @@ -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 + +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(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(i)); + + ImGui::TableNextColumn(); + bool is_selected = (s_selected_index == static_cast(i)); + if (ImGui::Selectable(entry.label.c_str(), is_selected, + ImGuiSelectableFlags_SpanAllColumns)) { + s_selected_index = static_cast(i); + } + + // Double-click to edit + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) { + s_selected_index = static_cast(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(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 diff --git a/src/ui/windows/contacts_tab.h b/src/ui/windows/contacts_tab.h new file mode 100644 index 0000000..c735619 --- /dev/null +++ b/src/ui/windows/contacts_tab.h @@ -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 diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 2382c9d..bd8063d 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -210,6 +210,7 @@ void I18n::loadBuiltinEnglish() strings_["receive"] = "Receive"; strings_["transactions"] = "Transactions"; strings_["history"] = "History"; + strings_["contacts"] = "Contacts"; strings_["mining"] = "Mining"; strings_["peers"] = "Peers"; strings_["market"] = "Market"; diff --git a/src/wallet/wallet_capabilities.h b/src/wallet/wallet_capabilities.h index 46bfe50..1c173a3 100644 --- a/src/wallet/wallet_capabilities.h +++ b/src/wallet/wallet_capabilities.h @@ -34,6 +34,7 @@ enum class WalletUiSurface { Send, Receive, History, + Contacts, // address book / chat roster; available in both variants (default: return true) Mining, Market, Console,