Bump DRAGONX_LITE_VERSION 1.0.0 -> 1.1.0: since lite-1.0.0 the Lite variant
gained the whole 2.0.x shared UI/UX + diagnostics + i18n + perf work and this
session's chat delete/block — a minor bump (new features, no breaking change).
Make the FAQ variant-aware (walletFaq(fullNode)) so the Lite build reads
correctly: "What is ObsidianDragonLite?", encryption in the Wallet tab (not
Node & Security), migrate-to-seed hidden, node-specific answers reworded
neutrally ("the wallet syncs"), a new "Lite Wallet" subcategory (server model +
privacy tradeoff) standing in for the hidden Daemon group, and the redundant
single "Wallet" group tab dropped when there's no Daemon group to switch to.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
204 lines
8.5 KiB
C++
204 lines
8.5 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "faq_dialog.h"
|
|
#include "faq_content.h"
|
|
#include "../../app.h"
|
|
#include "../../util/i18n.h"
|
|
#include "../../util/text_format.h"
|
|
#include "../../embedded/IconsMaterialDesign.h"
|
|
#include "../schema/ui_schema.h"
|
|
#include "../layout.h"
|
|
#include "../material/type.h"
|
|
#include "../material/colors.h"
|
|
#include "../material/draw_helpers.h"
|
|
#include "imgui.h"
|
|
|
|
#include <algorithm>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
namespace dragonx {
|
|
namespace ui {
|
|
|
|
namespace {
|
|
|
|
// Persists across frames (the dialog is re-entered each frame while open). Group 0 = Wallet,
|
|
// 1 = Daemon. `expanded` keys are FaqEntry::questionKey (stable string literals from faq_content).
|
|
struct FaqDialogState {
|
|
int group = 0;
|
|
char search[128] = "";
|
|
std::unordered_map<std::string, bool> expanded;
|
|
};
|
|
FaqDialogState s_faq;
|
|
|
|
bool entryMatches(const faq::FaqEntry& e, const char* query)
|
|
{
|
|
return util::containsIgnoreCase(TR(e.questionKey), query) ||
|
|
util::containsIgnoreCase(TR(e.answerKey), query);
|
|
}
|
|
|
|
// One answer body: wrapped, muted. Shared by the collapsible (non-search) and search paths.
|
|
void renderAnswer(const char* answerKey)
|
|
{
|
|
ImGui::Indent(Layout::spacingMd());
|
|
ImGui::PushFont(material::Type().body2());
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
|
ImGui::TextWrapped("%s", TR(answerKey));
|
|
ImGui::PopStyleColor();
|
|
ImGui::PopFont();
|
|
ImGui::Unindent(Layout::spacingMd());
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
}
|
|
|
|
} // namespace
|
|
|
|
void RenderFaqDialog(App* app, bool* p_open)
|
|
{
|
|
auto& S = schema::UI();
|
|
auto win = S.window("dialogs.faq");
|
|
const float dp = Layout::dpiScale();
|
|
|
|
const bool daemonAvailable = app && app->supportsFullNodeLifecycleActions();
|
|
if (!daemonAvailable) s_faq.group = 0; // no Daemon tab in lite builds
|
|
|
|
// Floating "BlurFloat" modal, matching the Wallets dialog: live-blur backdrop, no boxed card, a
|
|
// plain heading (no ✕ — a Close button sits at the bottom). Roomy fixed width; height capped to the
|
|
// viewport so the content flexes + scrolls on small / HiDPI screens.
|
|
const float vpH = ImGui::GetMainViewport()->Size.y;
|
|
const float wantH = (win.height > 0 ? win.height : 640.0f) * dp;
|
|
material::OverlayDialogSpec spec;
|
|
spec.title = TR("faq_title");
|
|
spec.p_open = p_open;
|
|
spec.style = material::OverlayStyle::BlurFloat;
|
|
spec.cardWidth = (win.width > 0 ? win.width : 860.0f);
|
|
spec.cardHeight = std::min(wantH, vpH * 0.86f) / dp;
|
|
spec.idSuffix = "faq";
|
|
if (!material::BeginOverlayDialog(spec)) {
|
|
return;
|
|
}
|
|
// Esc closes (ImGui consumes Esc itself while the search box is being edited, so this only
|
|
// fires when the field isn't capturing it).
|
|
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) *p_open = false;
|
|
|
|
// Subtitle under the plain heading, matching the Wallets dialog's intro caption.
|
|
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(),
|
|
TR(daemonAvailable ? "faq_intro" : "faq_intro_lite"));
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
|
|
const float contentW = ImGui::GetContentRegionAvail().x;
|
|
|
|
// ── Group tabs: Wallet | Daemon — only when there's a choice. Lite has just the Wallet group, so a
|
|
// lone "Wallet" selector is redundant; skip it entirely (the group is already pinned to 0 above).
|
|
if (daemonAvailable) {
|
|
const float gap = ImGui::GetStyle().ItemSpacing.x;
|
|
const float tabW = (contentW - gap) / 2.0f;
|
|
auto tab = [&](const char* label, int idx) {
|
|
const bool active = (s_faq.group == idx);
|
|
if (active) {
|
|
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::Primary()));
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnPrimary()));
|
|
}
|
|
if (material::TactileButton(label, ImVec2(tabW, 0))) s_faq.group = idx;
|
|
if (active) ImGui::PopStyleColor(2);
|
|
};
|
|
tab(TR("faq_group_wallet"), 0);
|
|
ImGui::SameLine();
|
|
tab(TR("faq_group_daemon"), 1);
|
|
}
|
|
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
|
|
|
// ── Search ──
|
|
ImGui::SetNextItemWidth(contentW);
|
|
ImGui::InputTextWithHint("##FaqSearch", TR("faq_search_hint"), s_faq.search, sizeof(s_faq.search));
|
|
const bool searching = s_faq.search[0] != '\0';
|
|
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
|
|
// ── Scrollable Q&A body ── (reserve room for the Close button footer below)
|
|
const float footerH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingMd();
|
|
float bodyH = ImGui::GetContentRegionAvail().y - footerH;
|
|
if (bodyH < 80.0f * dp) bodyH = 80.0f * dp;
|
|
// Inner padding gives the content breathing room and, on the right, a clear gap to the LEFT of the
|
|
// scrollbar (WindowPadding.x is exactly that gap); the scrollbar itself is made chunkier than the
|
|
// app default. NoScrollWithMouse + ApplySmoothScroll gives the wheel eased scrolling, matching the
|
|
// Wallets dialog / Settings page (ApplySmoothScroll owns the wheel input and lerps ScrollY).
|
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingMd(), Layout::spacingXs()));
|
|
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 16.0f * dp);
|
|
ImGui::BeginChild("##FaqScroll", ImVec2(0, bodyH), false,
|
|
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollWithMouse);
|
|
material::ApplySmoothScroll();
|
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
|
|
|
const auto& groups = (s_faq.group == 1 && daemonAvailable) ? faq::daemonFaq() : faq::walletFaq(daemonAvailable);
|
|
bool anyShown = false;
|
|
|
|
bool firstSection = true;
|
|
for (const auto& subcat : groups) {
|
|
// Collect the entries visible under the current search.
|
|
std::vector<const faq::FaqEntry*> visible;
|
|
for (const auto& e : subcat.entries) {
|
|
if (!searching || entryMatches(e, s_faq.search)) visible.push_back(&e);
|
|
}
|
|
if (visible.empty()) continue;
|
|
|
|
// Section break: generous space above every section after the first, so topic groups read as
|
|
// clearly separated bands rather than one uniform list.
|
|
if (!firstSection) ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
|
|
firstSection = false;
|
|
anyShown = true;
|
|
|
|
// Section header — accent overline + a thin full-width rule beneath it, anchoring the group
|
|
// above its (brighter, normal-case) question rows.
|
|
material::Type().textColored(material::TypeStyle::Overline,
|
|
material::Primary(), TR(subcat.titleKey));
|
|
{
|
|
const ImVec2 rp = ImGui::GetCursorScreenPos();
|
|
const float rw = ImGui::GetContentRegionAvail().x;
|
|
dl->AddLine(ImVec2(rp.x, rp.y + dp), ImVec2(rp.x + rw, rp.y + dp),
|
|
material::Divider(), 1.0f * dp);
|
|
}
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
|
|
|
for (const auto* e : visible) {
|
|
const float rowW = ImGui::GetContentRegionAvail().x;
|
|
if (searching) {
|
|
// Search results: show question + answer directly (no collapsing).
|
|
ImGui::PushFont(material::Type().subtitle2());
|
|
ImGui::TextWrapped("%s", TR(e->questionKey));
|
|
ImGui::PopFont();
|
|
renderAnswer(e->answerKey);
|
|
} else {
|
|
bool& exp = s_faq.expanded[e->questionKey];
|
|
std::string id = std::string("##faq_") + e->questionKey;
|
|
material::CollapsibleHeader(dl, id.c_str(), TR(e->questionKey), exp, rowW,
|
|
material::Type().subtitle2(), material::OnSurface());
|
|
if (exp) renderAnswer(e->answerKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!anyShown) {
|
|
ImGui::Dummy(ImVec2(0, Layout::spacingLg()));
|
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()));
|
|
ImGui::TextWrapped("%s", TR("faq_no_results"));
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleVar(2); // ScrollbarSize, WindowPadding
|
|
|
|
// Close button footer (BlurFloat has no ✕ in the heading).
|
|
const float closeW = 120.0f * dp;
|
|
material::BeginOverlayDialogFooter(closeW, false);
|
|
if (material::TactileButton(TR("close"), ImVec2(closeW, 0))) *p_open = false;
|
|
|
|
material::EndOverlayDialog();
|
|
}
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|