diff --git a/.github/workflows/gui-functional-tests.yml b/.github/workflows/gui-functional-tests.yml index 427937133a..aa0937f401 100644 --- a/.github/workflows/gui-functional-tests.yml +++ b/.github/workflows/gui-functional-tests.yml @@ -161,6 +161,7 @@ jobs: python3 test/functional/qml_test_password_wallet.py python3 test/functional/qml_test_send_receive.py python3 test/functional/qml_test_addresses.py + python3 test/functional/qml_test_address_label_sync.py python3 test/functional/qml_test_send_review.py python3 test/functional/qml_test_psbt.py python3 test/functional/qml_test_wallet_migration.py diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 9f9b24235d..583d7a6bfc 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -59,6 +59,7 @@ controls/ContextMenuDivider.qml controls/ContextMenuPicker.qml controls/ContextMenuToggle.qml + controls/ActivityCalendar.qml controls/ContinueButton.qml controls/DropdownButton.qml controls/CoreCheckBox.qml diff --git a/qml/controls/ActivityCalendar.qml b/qml/controls/ActivityCalendar.qml new file mode 100644 index 0000000000..f39d29f996 --- /dev/null +++ b/qml/controls/ActivityCalendar.qml @@ -0,0 +1,532 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 +import org.bitcoincore.qt 1.0 + +// Date-range calendar for the Activity custom date range filter: From/To +// display, month/year navigation, a weekday header, and a day grid with range +// selection, hover preview, and keyboard support. It owns the selection state; +// the caller reads startDate/endDate (or startIso/endIso) and valid/hasSelection +// and drives it with seed()/reset()/refreshToday(). It does not touch the model: +// applying or clearing the filter is left to the caller. +ColumnLayout { + id: root + spacing: 8 + + // Public interface ------------------------------------------------------ + + // The picked range as local Date values (null until picked). + readonly property var startDate: root.customFrom + readonly property var endDate: root.customTo + // The picked range as yyyy-MM-dd strings ("" until picked). Pass these to + // the model rather than the Dates: a JS Date converted straight to QDate can + // shift a day across time zones. + readonly property string startIso: root.customFrom ? root.isoDate(root.customFrom) : "" + readonly property string endIso: root.customTo ? root.isoDate(root.customTo) : "" + // Both ends picked (a complete range), and any end picked. + readonly property bool valid: root.customFrom !== null && root.customTo !== null + readonly property bool hasSelection: root.customFrom !== null || root.customTo !== null + + // Clear the picked range so a new one can be chosen. + function reset() { + root.customFrom = null + root.customTo = null + root.pickingEnd = false + root.hoverDate = null + } + + // Refresh "today" (call when the calendar is shown) so a page left up past + // midnight does not keep marking yesterday as today, and drop the focus ring. + function refreshToday() { + root.todayDate = new Date() + root.calendarFocusDate = null + } + + // Seed the calendar with an existing range (or nulls to start clean) and + // show the month of its start. + function seed(from, to) { + root.customFrom = from + root.customTo = to + root.pickingEnd = false + root.hoverDate = null + root.showCalendarMonth(from) + } + + // Internal state -------------------------------------------------------- + + // Dates are shown in the same long format as the rest of the app (see + // Transaction::dateTimeString). Note Qt.formatDate with a format string + // renders C-locale English month names in Qt 6, the same as + // Transaction::dateTimeString; that consistency is why the chips use it. + readonly property string activityDateFormat: "MMMM d, yyyy" + + // customFrom and customTo are local Date values (or null). While pickingEnd + // is true the start is chosen and the next click sets the end; hoverDate + // previews the range on desktop. The calendar shows calMonth/calYear. + property var customFrom: null + property var customTo: null + property bool pickingEnd: false + property var hoverDate: null + property int calMonth: (new Date()).getMonth() + property int calYear: (new Date()).getFullYear() + // First day of the week for the locale, as Qt::DayOfWeek + // (1 = Monday .. 7 = Sunday). The mod-7 arithmetic in calendarCells() and + // the weekday header maps it onto JS getDay()'s 0 = Sunday .. 6 = Saturday + // numbering (7 % 7 == 0). + readonly property int weekStart: Qt.locale().firstDayOfWeek + property var todayDate: new Date() + // Day the keyboard focus ring is on while the day grid has active focus; + // null until the grid is first focused after opening. + property var calendarFocusDate: null + + function formatRangeDate(date) { + if (!date || isNaN(date.getTime())) { + return "" + } + return Qt.formatDate(date, root.activityDateFormat) + } + + function isoDate(date) { + return Qt.formatDate(date, "yyyy-MM-dd") + } + + function sameDay(a, b) { + return a && b && root.isoDate(a) === root.isoDate(b) + } + + // The two ends of the range currently in play: the committed range, or the + // tentative one while hovering after the first click. + function rangeBounds() { + if (root.customFrom && root.customTo) { + return [root.customFrom, root.customTo] + } + if (root.pickingEnd && root.customFrom && root.hoverDate) { + return root.hoverDate < root.customFrom + ? [root.hoverDate, root.customFrom] + : [root.customFrom, root.hoverDate] + } + return null + } + + function isRangeEndpoint(date) { + // Check the picked ends directly so the start lights up on the click, + // not only once the mouse moves and a hover range exists. + return root.sameDay(date, root.customFrom) + || root.sameDay(date, root.customTo) + || (root.pickingEnd && root.customFrom && root.hoverDate + && root.sameDay(date, root.hoverDate)) + } + + function isInRange(date) { + const b = root.rangeBounds() + return b !== null && date >= b[0] && date <= b[1] + } + + // Cells for the displayed month: leading blanks (null) so the first day + // lands under its weekday for the locale, then one Date per day. + function calendarCells() { + var cells = [] + var firstDow = new Date(root.calYear, root.calMonth, 1).getDay() + var lead = (firstDow - root.weekStart + 7) % 7 + for (var i = 0; i < lead; ++i) { + cells.push(null) + } + var days = new Date(root.calYear, root.calMonth + 1, 0).getDate() + for (var d = 1; d <= days; ++d) { + cells.push(new Date(root.calYear, root.calMonth, d)) + } + return cells + } + + function showCalendarMonth(date) { + var base = (date && !isNaN(date.getTime())) ? date : new Date() + root.calMonth = base.getMonth() + root.calYear = base.getFullYear() + } + + function stepCalendarMonth(delta) { + var m = root.calMonth + delta + var y = root.calYear + while (m < 0) { m += 12; y -= 1 } + while (m > 11) { m -= 12; y += 1 } + root.calMonth = m + root.calYear = y + } + + // Seed the keyboard focus ring when the day grid gains focus (or after the + // displayed month changed under it): prefer the picked start, then today, + // when they are in the displayed month. + function ensureCalendarFocusDate() { + var f = root.calendarFocusDate + if (f && f.getMonth() === root.calMonth && f.getFullYear() === root.calYear) { + return + } + var seed = root.customFrom || root.todayDate + if (seed.getMonth() === root.calMonth && seed.getFullYear() === root.calYear) { + root.calendarFocusDate = new Date(seed.getFullYear(), seed.getMonth(), seed.getDate()) + } else { + root.calendarFocusDate = new Date(root.calYear, root.calMonth, 1) + } + } + + function moveCalendarFocus(deltaDays) { + root.ensureCalendarFocusDate() + var f = root.calendarFocusDate + var d = new Date(f.getFullYear(), f.getMonth(), f.getDate() + deltaDays) + root.calendarFocusDate = d + root.showCalendarMonth(d) + } + + // Month (or, with delta +-12, year) step that carries the focus ring along, + // clamping the day to the target month's length. + function stepCalendarFocusMonth(delta) { + root.ensureCalendarFocusDate() + var f = root.calendarFocusDate + var m = f.getMonth() + delta + var y = f.getFullYear() + while (m < 0) { m += 12; y -= 1 } + while (m > 11) { m -= 12; y += 1 } + var days = new Date(y, m + 1, 0).getDate() + root.calendarFocusDate = new Date(y, m, Math.min(f.getDate(), days)) + root.showCalendarMonth(root.calendarFocusDate) + } + + // Range selection: the first click sets the start, the next sets the end + // (reordered if earlier). Once both ends are set the range is locked; reset() + // clears it to pick again. + function selectCalendarDate(date) { + if (root.customFrom && root.customTo) { + return + } + if (!root.pickingEnd) { + root.customFrom = date + root.customTo = null + root.pickingEnd = true + } else { + if (date < root.customFrom) { + root.customTo = root.customFrom + root.customFrom = date + } else { + root.customTo = date + } + root.pickingEnd = false + } + root.hoverDate = null + } + + // Accessible label for a calendar day: the full localized date plus its + // selection state, so the state is announced to a screen reader and not + // conveyed by color alone. + function dayCellAccessibleName(cell) { + var base = Qt.formatDate(cell.modelData, root.activityDateFormat) + if (cell.endpoint) { + //: Screen reader label for a chosen start or end day in the Activity custom date range calendar. %1 is the date. + return qsTr("%1, selected range endpoint").arg(base) + } + if (cell.ranged) { + //: Screen reader label for a day inside the chosen Activity date range. %1 is the date. + return qsTr("%1, in selected range").arg(base) + } + if (cell.isToday) { + //: Screen reader label for today in the Activity date range calendar. %1 is the date. + return qsTr("%1, today").arg(base) + } + return base + } + + // UI -------------------------------------------------------------------- + + // From / To display. The highlighted chip is the endpoint the next calendar + // click will set. + RowLayout { + Layout.fillWidth: true + spacing: 8 + + DateRangeChip { + objectName: "activityDateFromChip" + Layout.fillWidth: true + //: Label for the start date of the Activity custom date range. + label: qsTr("From") + value: root.formatRangeDate(root.customFrom) + // Lit while choosing the start; nothing lit once both ends are set + // (the range is locked until Reset). + highlighted: !root.pickingEnd && !root.customFrom + } + + DateRangeChip { + objectName: "activityDateToChip" + Layout.fillWidth: true + //: Label for the end date of the Activity custom date range. + label: qsTr("To") + value: root.formatRangeDate(root.customTo) + highlighted: root.pickingEnd + } + } + + // Month / year navigation: single chevrons step a month, double chevrons + // jump a year (to reach distant dates fast). + RowLayout { + Layout.fillWidth: true + spacing: 2 + + CalNavButton { + objectName: "calendarPrevYear" + iconSource: "image://images/caret-left" + doubled: true + //: Accessibility label for the Activity calendar control that jumps back one year. + accessibleName: qsTr("Previous year") + onClicked: root.stepCalendarMonth(-12) + } + CalNavButton { + objectName: "calendarPrev" + iconSource: "image://images/caret-left" + //: Accessibility label for the Activity calendar control that steps back one month. + accessibleName: qsTr("Previous month") + onClicked: root.stepCalendarMonth(-1) + } + + CoreText { + Layout.fillWidth: true + // toLocaleDateString localizes the month name (a Qt.formatDate + // format string would render C-locale English), matching the + // localized weekday header. + text: (new Date(root.calYear, root.calMonth, 1)).toLocaleDateString(Qt.locale(), "MMMM yyyy") + horizontalAlignment: Text.AlignHCenter + color: Theme.color.neutral9 + font.pixelSize: 15 + bold: true + } + + CalNavButton { + objectName: "calendarNext" + iconSource: "image://images/caret-right" + //: Accessibility label for the Activity calendar control that steps forward one month. + accessibleName: qsTr("Next month") + onClicked: root.stepCalendarMonth(1) + } + CalNavButton { + objectName: "calendarNextYear" + iconSource: "image://images/caret-right" + doubled: true + //: Accessibility label for the Activity calendar control that jumps forward one year. + accessibleName: qsTr("Next year") + onClicked: root.stepCalendarMonth(12) + } + } + + // Weekday header, ordered by the locale's first day of week to match the day + // grid. Use a Grid positioner, never a Quick Layout: a Repeater inside a + // RowLayout/ColumnLayout/GridLayout hits a Qt 6.4 use-after-free that + // SIGSEGVs only headed. + Grid { + Layout.alignment: Qt.AlignHCenter + columns: 7 + spacing: 0 + + Repeater { + model: 7 + delegate: CoreText { + required property int index + readonly property int jsDay: (root.weekStart + index) % 7 + width: 36 + height: 22 + text: Qt.locale().dayName(jsDay === 0 ? 7 : jsDay, Locale.ShortFormat) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: Theme.color.neutral6 + font.pixelSize: 12 + } + } + } + + // Day grid: one ItemDelegate per cell so each day is independently clickable + // (and reachable by the test bridge). + Grid { + id: dayGrid + objectName: "activityCalendarGrid" + Layout.alignment: Qt.AlignHCenter + columns: 7 + columnSpacing: 0 + rowSpacing: 2 + + // The grid is a single tab stop; arrows move the focus ring between + // days, PageUp/PageDown step a month (with Ctrl, a year), Enter or Space + // picks the day. + activeFocusOnTab: true + onActiveFocusChanged: { + if (activeFocus) { + root.ensureCalendarFocusDate() + } + } + Keys.onPressed: function(event) { + switch (event.key) { + case Qt.Key_Left: root.moveCalendarFocus(-1); break + case Qt.Key_Right: root.moveCalendarFocus(1); break + case Qt.Key_Up: root.moveCalendarFocus(-7); break + case Qt.Key_Down: root.moveCalendarFocus(7); break + case Qt.Key_PageUp: + root.stepCalendarFocusMonth(event.modifiers & Qt.ControlModifier ? -12 : -1) + break + case Qt.Key_PageDown: + root.stepCalendarFocusMonth(event.modifiers & Qt.ControlModifier ? 12 : 1) + break + case Qt.Key_Return: + case Qt.Key_Enter: + case Qt.Key_Space: + root.ensureCalendarFocusDate() + root.selectCalendarDate(root.calendarFocusDate) + break + default: + return + } + event.accepted = true + } + + // Clear the hover preview when the pointer leaves the grid. + HoverHandler { + enabled: AppMode.isDesktop + onHoveredChanged: if (!hovered) root.hoverDate = null + } + + Repeater { + model: root.calendarCells() + delegate: ItemDelegate { + id: dayCell + required property var modelData + width: 36 + height: 32 + padding: 0 + enabled: modelData !== null + objectName: modelData ? "calendarDay_" + root.isoDate(modelData) : "" + Accessible.role: Accessible.Button + Accessible.name: modelData ? root.dayCellAccessibleName(dayCell) : "" + + readonly property bool endpoint: modelData !== null && root.isRangeEndpoint(modelData) + readonly property bool ranged: modelData !== null && root.isInRange(modelData) + readonly property bool isToday: modelData !== null && root.sameDay(modelData, root.todayDate) + readonly property bool keyFocused: modelData !== null && dayGrid.activeFocus + && root.calendarFocusDate !== null && root.sameDay(modelData, root.calendarFocusDate) + + onClicked: { + if (modelData) { + root.selectCalendarDate(modelData) + } + } + + HoverHandler { + enabled: AppMode.isDesktop && dayCell.modelData !== null + onHoveredChanged: if (hovered) root.hoverDate = dayCell.modelData + } + + background: Rectangle { + visible: dayCell.modelData !== null + radius: 4 + color: dayCell.endpoint + ? Theme.color.orange + : (dayCell.ranged ? Theme.color.neutral3 : "transparent") + // The 2px neutral keyboard focus ring wins over the 1px + // orange today ring, so the focused day is always + // identifiable. + border.width: dayCell.keyFocused ? 2 : 1 + border.color: dayCell.keyFocused + ? Theme.color.neutral9 + : dayCell.isToday && !dayCell.endpoint + ? Theme.color.orange + : "transparent" + } + + contentItem: CoreText { + text: dayCell.modelData ? dayCell.modelData.getDate() : "" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + color: dayCell.endpoint ? Theme.color.white : Theme.color.neutral9 + font.pixelSize: 13 + // Mark today by weight too, so it does not rely on the ring color alone. + bold: dayCell.isToday + } + } + } + } + + // Private sub-components ------------------------------------------------- + + component DateRangeChip: Rectangle { + id: chip + property string label: "" + property string value: "" + property bool highlighted: false + //: Read for an Activity date range endpoint that has no date picked yet. + readonly property string accessibleValue: chip.value !== "" ? chip.value : qsTr("no date selected") + implicitHeight: 46 + radius: 5 + color: Theme.color.neutral2 + border.color: chip.highlighted ? Theme.color.orange : "transparent" + border.width: 1 + Accessible.role: Accessible.StaticText + Accessible.name: chip.highlighted + //: Accessibility name of the active Activity date range endpoint. %1 is "From" or "To", %2 the picked date or "no date selected". + ? qsTr("%1, %2, the next calendar selection sets this date").arg(chip.label).arg(chip.accessibleValue) + //: Accessibility name of an inactive Activity date range endpoint. %1 is "From" or "To", %2 the picked date or "no date selected". + : qsTr("%1, %2").arg(chip.label).arg(chip.accessibleValue) + ColumnLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.topMargin: 6 + anchors.bottomMargin: 6 + spacing: 1 + CoreText { + Layout.fillWidth: true + text: chip.label + color: Theme.color.neutral7 + font.pixelSize: 11 + // Weight marks the active endpoint too, so the state does not + // rely on the border color alone. + bold: chip.highlighted + horizontalAlignment: Text.AlignLeft + } + CoreText { + Layout.fillWidth: true + //: Placeholder in the Activity date range From/To field before a date is picked. + text: chip.value !== "" ? chip.value : qsTr("Select date") + color: chip.value !== "" ? Theme.color.neutral9 : Theme.color.neutral6 + font.pixelSize: 14 + horizontalAlignment: Text.AlignLeft + elide: Text.ElideRight + } + } + } + + // A calendar month/year step button. One caret steps a month; the year jump + // shows two carets, composed from the caret already in the Bitcoin Icons set + // (the set has no double-caret glyph). + component CalNavButton: ItemDelegate { + id: navButton + property url iconSource + property bool doubled: false + property string accessibleName: "" + implicitWidth: 28 + implicitHeight: 28 + padding: 0 + Accessible.role: Accessible.Button + Accessible.name: navButton.accessibleName + contentItem: Item { + Row { + anchors.centerIn: parent + spacing: -5 + Repeater { + model: navButton.doubled ? 2 : 1 + delegate: Icon { + source: navButton.iconSource + color: Theme.color.orange + size: 14 + } + } + } + } + } +} diff --git a/qml/models/activityfilterproxymodel.cpp b/qml/models/activityfilterproxymodel.cpp index 2b25805770..2ea7efc52a 100644 --- a/qml/models/activityfilterproxymodel.cpp +++ b/qml/models/activityfilterproxymodel.cpp @@ -155,6 +155,80 @@ void ActivityFilterProxyModel::setDisplayUnit(int display_unit) Q_EMIT displayUnitChanged(); } +CAmount ActivityFilterProxyModel::minAmount() const +{ + return m_min_amount; +} + +void ActivityFilterProxyModel::setMinAmount(CAmount min_amount) +{ + // Anything below zero clears the filter; amounts are always non-negative. + const CAmount normalized = min_amount < 0 ? -1 : min_amount; + if (m_min_amount == normalized) return; + +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); +#endif + m_min_amount = normalized; +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + endFilterChange(QSortFilterProxyModel::Direction::Rows); +#else + invalidateFilter(); +#endif + Q_EMIT minAmountChanged(); + Q_EMIT countChanged(); +} + +QDate ActivityFilterProxyModel::rangeStart() const +{ + return m_range_start; +} + +void ActivityFilterProxyModel::setRangeStart(const QDate& range_start) +{ + if (m_range_start == range_start) return; + +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); +#endif + m_range_start = range_start; +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + endFilterChange(QSortFilterProxyModel::Direction::Rows); +#else + invalidateFilter(); +#endif + Q_EMIT rangeChanged(); + Q_EMIT countChanged(); +} + +QDate ActivityFilterProxyModel::rangeEnd() const +{ + return m_range_end; +} + +void ActivityFilterProxyModel::setRangeEnd(const QDate& range_end) +{ + if (m_range_end == range_end) return; + +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); +#endif + m_range_end = range_end; +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + endFilterChange(QSortFilterProxyModel::Direction::Rows); +#else + invalidateFilter(); +#endif + Q_EMIT rangeChanged(); + Q_EMIT countChanged(); +} + +void ActivityFilterProxyModel::setCustomRange(const QString& start_iso, const QString& end_iso) +{ + setRangeStart(QDate::fromString(start_iso, Qt::ISODate)); + setRangeEnd(QDate::fromString(end_iso, Qt::ISODate)); +} + int ActivityFilterProxyModel::count() const { return rowCount(); @@ -167,6 +241,14 @@ bool ActivityFilterProxyModel::filterAcceptsRow(int source_row, const QModelInde const QModelIndex source_index = sourceModel()->index(source_row, 0, source_parent); if (!source_index.isValid()) return false; + // A payment request whose address already has a real transaction is only + // surfaced under the Payment request filter; everywhere else it is hidden so + // it does not duplicate that address's real transaction row. + if (source_index.data(ActivityListModel::IsUsedAddressRequestRole).toBool() + && m_type_filter != PaymentRequest) { + return false; + } + if (!dateMatches(source_index.data(ActivityListModel::TimestampRole).toLongLong())) { return false; } @@ -176,6 +258,15 @@ bool ActivityFilterProxyModel::filterAcceptsRow(int source_row, const QModelInde return false; } + if (m_min_amount >= 0) { + const CAmount net_amount = source_index.data(ActivityListModel::NetAmountSatRole).toLongLong(); + // Compare absolute value so a send and a receive of the same size both + // pass a threshold, matching the Qt Widgets amount filter. + if (qAbs(net_amount) < m_min_amount) { + return false; + } + } + const QString search = m_search_text.trimmed(); if (!search.isEmpty()) { const QString address = source_index.data(ActivityListModel::AddressRole).toString(); @@ -214,9 +305,10 @@ ActivityFilterProxyModel::TypeFilter ActivityFilterProxyModel::filterTypeForInde return Mined; case Transaction::SendToAddress: case Transaction::SendToOther: + return Sent; case Transaction::Other: default: - return Sent; + return Other; } } @@ -264,10 +356,25 @@ bool ActivityFilterProxyModel::dateMatches(qint64 timestamp) const start_date = QDate(current_date.year(), current_date.month(), 1); end_date = start_date.addMonths(1); break; + case LastMonth: + end_date = QDate(current_date.year(), current_date.month(), 1); + start_date = end_date.addMonths(-1); + break; case ThisYear: start_date = QDate(current_date.year(), 1, 1); end_date = start_date.addYears(1); break; + case CustomRange: { + const QDateTime row_time = QDateTime::fromSecsSinceEpoch(timestamp); + if (m_range_start.isValid() && row_time < QDateTime(m_range_start, QTime(0, 0))) { + return false; + } + // The end date is inclusive, so accept the whole of that day. + if (m_range_end.isValid() && row_time >= QDateTime(m_range_end.addDays(1), QTime(0, 0))) { + return false; + } + return true; + } case DateAll: return true; } diff --git a/qml/models/activityfilterproxymodel.h b/qml/models/activityfilterproxymodel.h index e3009ef86f..637c65ee23 100644 --- a/qml/models/activityfilterproxymodel.h +++ b/qml/models/activityfilterproxymodel.h @@ -5,7 +5,10 @@ #ifndef BITCOIN_QML_MODELS_ACTIVITYFILTERPROXYMODEL_H #define BITCOIN_QML_MODELS_ACTIVITYFILTERPROXYMODEL_H +#include + #include +#include #include #include #include @@ -18,6 +21,9 @@ class ActivityFilterProxyModel : public QSortFilterProxyModel Q_PROPERTY(DateFilter dateFilter READ dateFilter WRITE setDateFilter NOTIFY dateFilterChanged) Q_PROPERTY(TypeFilter typeFilter READ typeFilter WRITE setTypeFilter NOTIFY typeFilterChanged) Q_PROPERTY(int displayUnit READ displayUnit WRITE setDisplayUnit NOTIFY displayUnitChanged) + Q_PROPERTY(qint64 minAmount READ minAmount WRITE setMinAmount NOTIFY minAmountChanged) + Q_PROPERTY(QDate rangeStart READ rangeStart WRITE setRangeStart NOTIFY rangeChanged) + Q_PROPERTY(QDate rangeEnd READ rangeEnd WRITE setRangeEnd NOTIFY rangeChanged) Q_PROPERTY(int count READ count NOTIFY countChanged) public: @@ -26,7 +32,9 @@ class ActivityFilterProxyModel : public QSortFilterProxyModel Today, ThisWeek, ThisMonth, - ThisYear + LastMonth, + ThisYear, + CustomRange }; Q_ENUM(DateFilter) @@ -36,6 +44,7 @@ class ActivityFilterProxyModel : public QSortFilterProxyModel Sent, SentToSelf, Mined, + Other, PaymentRequest }; Q_ENUM(TypeFilter) @@ -57,6 +66,20 @@ class ActivityFilterProxyModel : public QSortFilterProxyModel int displayUnit() const; void setDisplayUnit(int display_unit); + CAmount minAmount() const; + void setMinAmount(CAmount min_amount); + + QDate rangeStart() const; + void setRangeStart(const QDate& range_start); + + QDate rangeEnd() const; + void setRangeEnd(const QDate& range_end); + + // Set the custom range from ISO yyyy-MM-dd strings. QML passes strings + // rather than assigning the QDate properties directly because the + // JavaScript Date to QDate conversion shifts the day across time zones. + Q_INVOKABLE void setCustomRange(const QString& start_iso, const QString& end_iso); + int count() const; Q_INVOKABLE bool exportCsv(const QString& path) const; @@ -66,6 +89,8 @@ class ActivityFilterProxyModel : public QSortFilterProxyModel void dateFilterChanged(); void typeFilterChanged(); void displayUnitChanged(); + void minAmountChanged(); + void rangeChanged(); void countChanged(); protected: @@ -82,6 +107,9 @@ class ActivityFilterProxyModel : public QSortFilterProxyModel DateFilter m_date_filter{DateAll}; TypeFilter m_type_filter{TypeAll}; int m_display_unit{0}; + CAmount m_min_amount{-1}; + QDate m_range_start; + QDate m_range_end; }; #endif // BITCOIN_QML_MODELS_ACTIVITYFILTERPROXYMODEL_H diff --git a/qml/models/activitylistmodel.cpp b/qml/models/activitylistmodel.cpp index 59461624d8..001b78007f 100644 --- a/qml/models/activitylistmodel.cpp +++ b/qml/models/activitylistmodel.cpp @@ -50,7 +50,11 @@ void ActivityListModel::updateTransactionStatus(QSharedPointer tx) void ActivityListModel::updateTransactionLabel(QSharedPointer tx) const { - if (m_wallet_model == nullptr) { + // Pending receive requests carry their own label, set when the request is + // saved and refreshed when it is edited. Re-reading the address book here + // would overwrite an edited request label with the older stored address + // label, so leave pending requests untouched. + if (m_wallet_model == nullptr || tx->isPendingRequest) { return; } @@ -97,6 +101,8 @@ QVariant ActivityListModel::data(const QModelIndex &index, int role) const return tx->requestId; case NetAmountSatRole: return QVariant::fromValue(tx->netAmount()); + case IsUsedAddressRequestRole: + return tx->isUsedAddressRequest; default: return QVariant(); } @@ -120,6 +126,7 @@ QHash ActivityListModel::roleNames() const roles[IsPendingRequestRole] = "isPendingRequest"; roles[RequestIdRole] = "requestId"; roles[NetAmountSatRole] = "netAmountSat"; + roles[IsUsedAddressRequestRole] = "isUsedAddressRequest"; return roles; } @@ -205,7 +212,7 @@ void ActivityListModel::addPendingReceiveRequests() for (int i = 0; i < history->rowCount(); ++i) { QModelIndex idx = history->index(i); QString address = history->data(idx, ReceiveRequestHistoryModel::AddressRole).toString(); - if (address.isEmpty() || existing_addresses.contains(address)) continue; + if (address.isEmpty()) continue; QString label = history->data(idx, ReceiveRequestHistoryModel::LabelRole).toString(); CAmount amount = history->data(idx, ReceiveRequestHistoryModel::AmountSatRole).toLongLong(); @@ -213,12 +220,17 @@ void ActivityListModel::addPendingReceiveRequests() qint64 timestamp = QDateTime::fromString(dateIso, Qt::ISODate).toSecsSinceEpoch(); QString reqId = history->data(idx, ReceiveRequestHistoryModel::IdRole).toString(); - addReceiveRequest(address, label, amount, timestamp, reqId); + // A request whose address already has a real transaction is still + // materialized (used_address), but the proxy shows it only under the + // Payment request filter so it does not duplicate the real row. + const bool used_address = existing_addresses.contains(address); + addReceiveRequest(address, label, amount, timestamp, reqId, used_address); } } void ActivityListModel::addReceiveRequest(const QString& address, const QString& label, - CAmount amount, qint64 timestamp, const QString& requestId) + CAmount amount, qint64 timestamp, const QString& requestId, + bool used_address) { uint256 zero_hash; auto tx = QSharedPointer::create(zero_hash, timestamp, @@ -226,11 +238,17 @@ void ActivityListModel::addReceiveRequest(const QString& address, const QString& tx->label = label.isEmpty() ? tr("Payment request") : label; tx->status = Transaction::Unconfirmed; tx->isPendingRequest = true; + tx->isUsedAddressRequest = used_address; tx->requestId = requestId; beginInsertRows(QModelIndex(), 0, 0); m_transactions.push_front(tx); - m_pending_request_addresses.insert(address); + // A used-address request has no unfulfilled payment to wait for, so it is + // not tracked for fulfillment; only unused pending requests promote to a + // real row when a matching transaction arrives. + if (!used_address) { + m_pending_request_addresses.insert(address); + } endInsertRows(); Q_EMIT countChanged(); } @@ -329,25 +347,18 @@ void ActivityListModel::fulfillPendingRequest(int index, const QSharedPointer pending = m_transactions.at(index); - pending->hash = real_tx->hash; - pending->status = real_tx->status; - pending->depth = real_tx->depth; - pending->time = real_tx->time; - pending->credit = real_tx->credit; - pending->debit = real_tx->debit; - pending->type = real_tx->type; - pending->idx = real_tx->idx; - pending->txid = real_tx->txid; - pending->countsForBalance = real_tx->countsForBalance; - pending->involvesWatchAddress = real_tx->involvesWatchAddress; - pending->isPendingRequest = false; - if (!real_tx->label.isEmpty()) { - pending->label = real_tx->label; - } - + // The request's address now has a real transaction. Rather than consuming + // the request in place, keep it as a used-address request (surfaced only + // under the Payment request filter) and insert the transaction as its own + // row, so the live list matches the state rebuilt on reload. + pending->isUsedAddressRequest = true; m_pending_request_addresses.remove(pending->address); - Q_EMIT dataChanged(this->index(index), this->index(index)); + + beginInsertRows(QModelIndex(), 0, 0); + m_transactions.push_front(real_tx); + endInsertRows(); + Q_EMIT countChanged(); } void ActivityListModel::subscribeToCoreSignals() diff --git a/qml/models/activitylistmodel.h b/qml/models/activitylistmodel.h index 70e379885d..8547e2b5e3 100644 --- a/qml/models/activitylistmodel.h +++ b/qml/models/activitylistmodel.h @@ -43,7 +43,8 @@ class ActivityListModel : public QAbstractListModel TimestampRole, IsPendingRequestRole, RequestIdRole, - NetAmountSatRole + NetAmountSatRole, + IsUsedAddressRequestRole }; Q_INVOKABLE void reload(); @@ -54,8 +55,12 @@ class ActivityListModel : public QAbstractListModel QHash roleNames() const override; void setDisplayUnit(int unit); + // A payment request row. Set used_address when the request's address + // already has a real transaction; the row is then only shown under the + // Payment request filter and is not tracked for pending-request fulfillment. void addReceiveRequest(const QString& address, const QString& label, - CAmount amount, qint64 timestamp, const QString& requestId); + CAmount amount, qint64 timestamp, const QString& requestId, + bool used_address = false); void updateReceiveRequest(const QString& requestId, const QString& label, CAmount amount); void removePendingReceiveRequest(const QString& requestId); diff --git a/qml/models/receiverequesthistorymodel.cpp b/qml/models/receiverequesthistorymodel.cpp index 2f438e82fb..4053e403f3 100644 --- a/qml/models/receiverequesthistorymodel.cpp +++ b/qml/models/receiverequesthistorymodel.cpp @@ -162,6 +162,18 @@ QVariantList ReceiveRequestHistoryModel::matchingEntriesForAddress(const QString return matches; } +std::vector ReceiveRequestHistoryModel::entriesForAddress(const QString& address) const +{ + std::vector matches; + if (address.isEmpty()) return matches; + for (const auto& entry : m_entries) { + if (QString::fromStdString(entry.recipient.address) == address) { + matches.push_back(entry); + } + } + return matches; +} + std::optional ReceiveRequestHistoryModel::entryById(const QString& request_id) const { bool ok{false}; diff --git a/qml/models/receiverequesthistorymodel.h b/qml/models/receiverequesthistorymodel.h index 2a049e3e2a..f4e301d0bd 100644 --- a/qml/models/receiverequesthistorymodel.h +++ b/qml/models/receiverequesthistorymodel.h @@ -50,6 +50,10 @@ class ReceiveRequestHistoryModel : public QAbstractListModel void prependOrReplace(const QmlRecentRequestEntry& entry); bool removeByRequestId(const QString& request_id); Q_INVOKABLE QVariantList matchingEntriesForAddress(const QString& address) const; + // Same match as matchingEntriesForAddress, but returns the entries so a + // caller can update and re-store them (used to sync a request label with an + // edited address book label). + std::vector entriesForAddress(const QString& address) const; std::optional entryById(const QString& request_id) const; int64_t maxId() const; diff --git a/qml/models/transaction.cpp b/qml/models/transaction.cpp index 51c7132dc6..e4ae853422 100644 --- a/qml/models/transaction.cpp +++ b/qml/models/transaction.cpp @@ -58,6 +58,7 @@ QString Transaction::prettyAmount(int display_unit) const QString Transaction::dateTimeString() const { + if (isUsedAddressRequest) return tr("Received"); if (isPendingRequest) return tr("Pending receive"); QDateTime dateTime = QDateTime::fromSecsSinceEpoch(time); diff --git a/qml/models/transaction.h b/qml/models/transaction.h index 9d12ae28ff..f12eb2eb92 100644 --- a/qml/models/transaction.h +++ b/qml/models/transaction.h @@ -71,6 +71,10 @@ class Transaction : public QObject bool countsForBalance; bool involvesWatchAddress; bool isPendingRequest{false}; + // A payment request whose address already has a real transaction. It is + // materialized so the Payment request filter can surface it, but hidden + // elsewhere so it does not duplicate the address's real transaction row. + bool isUsedAddressRequest{false}; QString requestId; static QList> fromWalletTx(const interfaces::WalletTx& tx); diff --git a/qml/models/walletqmlmodel.cpp b/qml/models/walletqmlmodel.cpp index 82b8c0ae90..469e0d01e7 100644 --- a/qml/models/walletqmlmodel.cpp +++ b/qml/models/walletqmlmodel.cpp @@ -975,6 +975,17 @@ bool WalletQmlModel::saveCurrentPaymentRequest() } } + // Keep the address book label in sync with the request label, so the + // Addresses page and any other address-book reader reflect an edited + // request, matching what getNewDestination writes at creation time. + // Only write when this save actually changes the label, and never let an + // empty request label clear a label the user may have set independently + // on the Addresses page. + const QString request_label = m_current_payment_request->label(); + if (!request_label.isEmpty() && request_label != getAddressLabel(m_current_payment_request->address())) { + setAddressLabel(m_current_payment_request->address(), request_label); + } + m_current_payment_request->setIsEditing(false); if (m_detail_payment_request && m_detail_payment_request->id() == m_current_payment_request->id()) { @@ -1203,7 +1214,55 @@ bool WalletQmlModel::setAddressLabel(const QString& address, const QString& labe return false; } - return m_wallet->setAddressBook(destination, label.toStdString(), purpose); + if (!m_wallet->setAddressBook(destination, label.toStdString(), purpose)) { + return false; + } + + // Keep any payment request for this address in step with the edited label, + // so editing it on the Addresses page also updates the request and its + // Activity row instead of letting them diverge. This is the reverse of + // saveCurrentPaymentRequest, which writes an edited request label back to + // the address book; together they keep both surfaces on one label. + syncPaymentRequestLabelToAddress(address, label); + return true; +} + +void WalletQmlModel::syncPaymentRequestLabelToAddress(const QString& address, const QString& label) +{ + if (!m_wallet || !m_receive_requests) { + return; + } + + const CTxDestination destination{DecodeDestination(address.toStdString())}; + if (!IsValidDestination(destination)) { + return; + } + + for (QmlRecentRequestEntry entry : m_receive_requests->entriesForAddress(address)) { + // Already in step, e.g. when saveCurrentPaymentRequest just wrote this + // label to the address book: nothing to re-store. + if (QString::fromStdString(entry.recipient.label) == label) { + continue; + } + entry.recipient.label = label.toStdString(); + const QString request_id{QString::number(entry.id)}; + m_wallet->setAddressReceiveRequest(destination, request_id.toStdString(), + ReceiveRequestHistoryModel::SerializeEntry(entry)); + m_receive_requests->prependOrReplace(entry); + if (m_activity_list_model) { + m_activity_list_model->updateReceiveRequest(request_id, label, entry.recipient.amount); + } + // A detail page or editor holding this request would otherwise keep + // showing its stale copy until reloaded. Skip the editor mid-edit so + // an unsaved draft is not clobbered. + if (m_detail_payment_request && m_detail_payment_request->id() == request_id) { + m_detail_payment_request->setLabel(label); + } + if (m_current_payment_request && m_current_payment_request->id() == request_id && + !m_current_payment_request->isEditing()) { + m_current_payment_request->setLabel(label); + } + } } std::vector WalletQmlModel::getAddresses() const diff --git a/qml/models/walletqmlmodel.h b/qml/models/walletqmlmodel.h index 368eec99da..693e789fb3 100644 --- a/qml/models/walletqmlmodel.h +++ b/qml/models/walletqmlmodel.h @@ -177,6 +177,9 @@ class WalletQmlModel : public QObject int64_t& block_time) const; QString getAddressLabel(const QString& address) const; bool setAddressLabel(const QString& address, const QString& label); + // Propagate an edited address book label to any payment request saved for + // that address (the reverse of the request-save address book sync). + void syncPaymentRequestLabelToAddress(const QString& address, const QString& label); std::vector getAddresses() const; std::map addressBalances() const; std::set usedAddresses() const; diff --git a/qml/pages/wallet/Activity.qml b/qml/pages/wallet/Activity.qml index 172dc4a481..89a5be2299 100644 --- a/qml/pages/wallet/Activity.qml +++ b/qml/pages/wallet/Activity.qml @@ -47,6 +47,7 @@ PageStack { initialItem: RowLayout { Page { id: root + objectName: "activityPage" Layout.alignment: Qt.AlignCenter Layout.fillHeight: true @@ -61,14 +62,17 @@ PageStack { readonly property bool activityFiltersActive: activityFilterProxy.searchText.trim().length > 0 || activityFilterProxy.dateFilter !== ActivityFilterProxyModel.DateAll || activityFilterProxy.typeFilter !== ActivityFilterProxyModel.TypeAll + || activityFilterProxy.minAmount >= 0 function clearFilters() { activityFilterProxy.searchText = "" activityFilterProxy.dateFilter = ActivityFilterProxyModel.DateAll activityFilterProxy.typeFilter = ActivityFilterProxyModel.TypeAll + activityFilterProxy.minAmount = -1 searchField.text = "" datePopup.close() typePopup.close() + amountPopup.close() } function toggleFilters() { @@ -108,13 +112,92 @@ PageStack { return qsTr("This week") case ActivityFilterProxyModel.ThisMonth: return qsTr("This month") + case ActivityFilterProxyModel.LastMonth: + //: Activity date filter option for the previous calendar month. + return qsTr("Last month") case ActivityFilterProxyModel.ThisYear: return qsTr("This year") + case ActivityFilterProxyModel.CustomRange: + //: Activity date filter option for a user-picked start and end date. + return qsTr("Custom range") default: return qsTr("All dates") } } + function amountFilterText() { + if (activityFilterProxy.minAmount >= 0) { + //: Activity amount filter button: shows the active minimum with its unit, e.g. "≥ 0.50000000 ₿". + return qsTr("≥ %1").arg(amountFormatter.displayWithUnit) + } + //: Activity amount filter button default: no minimum amount is set. + return qsTr("Any amount") + } + + // Placeholder and input mask for the minimum amount field, per + // display unit. The integer digits keep any accepted value below + // MAX_MONEY (2.1e15 sat) and within the JS exact integer range for + // the display round trip. + // + // The unit is a BitcoinAmount.Unit value, compared by number rather + // than by name: QML only exposes enum values whose name begins with + // a capital letter, so BitcoinAmount.mBTC and BitcoinAmount.uBTC + // read as undefined here and every comparison against them is false. + function minAmountPlaceholder(unit) { + switch (unit) { + case 3: return "0" // SAT + case 2: return "0.00" // uBTC (bits) + case 1: return "0.00000" // mBTC + default: return "0.00000000" // BTC + } + } + + function minAmountPattern(unit) { + switch (unit) { + case 3: return /^[0-9]{0,15}$/ // SAT + case 2: return /^[0-9]{0,13}(\.[0-9]{0,2})?$/ // uBTC (bits) + case 1: return /^[0-9]{0,10}(\.[0-9]{0,5})?$/ // mBTC + default: return /^[0-9]{0,8}(\.[0-9]{0,8})?$/ // BTC + } + } + + // A filter popup right-aligns to its button, but a wide popup on a + // button near the left edge (the date preset menu, sized for the + // calendar) would spill past the page. Clamp x so the popup stays + // within the page horizontally instead of overflowing the list area. + function filterPopupX(button, popup) { + const anchorX = button.mapToItem(root, 0, 0).x + const rightAligned = button.width - popup.width + const minX = -anchorX + const maxX = root.width - popup.width - anchorX + return Math.max(minX, Math.min(rightAligned, maxX)) + } + + // A QDate read back from the model reaches QML as a JS Date at UTC + // midnight; rebuild it at local midnight on the same calendar day so + // seeding the calendar does not read it a day early west of UTC (the + // read-side mirror of the Apply path, which hands the model ISO + // strings for the same reason). + function modelDateToLocal(date) { + return (date && !isNaN(date.getTime())) + ? new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + : null + } + + function applyMinAmount() { + var trimmed = minAmountField.text.trim() + if (trimmed.length === 0) { + activityFilterProxy.minAmount = -1 + amountPopup.close() + return + } + amountParser.unit = optionsModel.displayUnit + amountParser.display = trimmed + var sats = amountParser.satoshi + activityFilterProxy.minAmount = sats > 0 ? sats : -1 + amountPopup.close() + } + function typeFilterText() { switch (activityFilterProxy.typeFilter) { case ActivityFilterProxyModel.Received: @@ -125,6 +208,9 @@ PageStack { return qsTr("Sent to yourself") case ActivityFilterProxyModel.Mined: return qsTr("Mined") + case ActivityFilterProxyModel.Other: + //: Activity type filter option for transactions that are not received, sent, sent to yourself, or mined. + return qsTr("Other") case ActivityFilterProxyModel.PaymentRequest: return qsTr("Payment request") default: @@ -163,7 +249,8 @@ PageStack { return qsTr("Once you send or receive bitcoin, your transactions will appear here.") } if (activityFiltersActive) { - return qsTr("Try changing your search, date, or type filters.") + //: Empty state hint when the active search, date, type, or amount filters match no transactions. + return qsTr("Try changing your search, date, type, or amount filters.") } return "" } @@ -187,6 +274,15 @@ PageStack { displayUnit: optionsModel.displayUnit } + // Helpers for the amount filter: amountFormatter tracks the active + // minimum for display; amountParser parses typed input on Apply. + BitcoinAmount { + id: amountFormatter + unit: optionsModel.displayUnit + satoshi: Math.max(0, activityFilterProxy.minAmount) + } + BitcoinAmount { id: amountParser } + AppSettings { id: activitySettings property alias searchFiltersVisible: root.filtersVisible @@ -263,13 +359,13 @@ PageStack { } } - RowLayout { + ColumnLayout { id: filterRow anchors.left: parent.left anchors.right: parent.right anchors.top: activityHeader.bottom anchors.topMargin: 10 - spacing: 15 + spacing: 10 visible: root.filtersVisible height: visible ? implicitHeight : 0 @@ -277,7 +373,6 @@ PageStack { id: searchField objectName: "activitySearchField" Layout.fillWidth: true - Layout.minimumWidth: 160 implicitHeight: 37 leftPadding: 15 rightPadding: clearSearchButton.visible ? 36 : 10 @@ -311,24 +406,41 @@ PageStack { } } - DropdownButton { - id: dateFilterButton - objectName: "activityDateFilterButton" - text: root.dateFilterText() - textColor: Theme.color.neutral7 - textAlignment: Text.AlignHCenter - opened: datePopup.visible - onClicked: datePopup.opened ? datePopup.close() : datePopup.open() - } + RowLayout { + Layout.fillWidth: true + spacing: 15 + + DropdownButton { + id: dateFilterButton + objectName: "activityDateFilterButton" + text: root.dateFilterText() + textColor: Theme.color.neutral7 + textAlignment: Text.AlignHCenter + opened: datePopup.visible + onClicked: datePopup.opened ? datePopup.close() : datePopup.open() + } - DropdownButton { - id: typeFilterButton - objectName: "activityTypeFilterButton" - text: root.typeFilterText() - textColor: Theme.color.neutral7 - textAlignment: Text.AlignHCenter - opened: typePopup.visible - onClicked: typePopup.opened ? typePopup.close() : typePopup.open() + DropdownButton { + id: typeFilterButton + objectName: "activityTypeFilterButton" + text: root.typeFilterText() + textColor: Theme.color.neutral7 + textAlignment: Text.AlignHCenter + opened: typePopup.visible + onClicked: typePopup.opened ? typePopup.close() : typePopup.open() + } + + DropdownButton { + id: amountFilterButton + objectName: "activityAmountFilterButton" + text: root.amountFilterText() + textColor: Theme.color.neutral7 + textAlignment: Text.AlignHCenter + opened: amountPopup.visible + onClicked: amountPopup.opened ? amountPopup.close() : amountPopup.open() + } + + Item { Layout.fillWidth: true } } } } @@ -440,6 +552,7 @@ PageStack { required property bool canBump required property string replacedByTxid required property bool isPendingRequest + required property bool isUsedAddressRequest required property string requestId HoverHandler { @@ -472,6 +585,7 @@ PageStack { transactionType: delegate.type transactionStatus: delegate.status isPendingRequest: delegate.isPendingRequest + isUsedAddressRequest: delegate.isUsedAddressRequest } contentItem: RowLayout { @@ -628,33 +742,159 @@ PageStack { id: datePopup objectName: "activityDateFilterPopup" parent: dateFilterButton - x: datePopup.parent.width - datePopup.width + x: root.filterPopupX(dateFilterButton, datePopup) y: datePopup.parent.height + 2 + minMenuWidth: 280 modal: true dim: false + property bool dateCustomExpanded: false + + onOpened: { + calendar.refreshToday() + var applied = activityFilterProxy.dateFilter === ActivityFilterProxyModel.CustomRange + datePopup.dateCustomExpanded = applied + // Only seed the draft from an applied range; otherwise start + // clean, so a reset or a discarded selection does not reappear. + if (applied) { + calendar.seed(root.modelDateToLocal(activityFilterProxy.rangeStart), + root.modelDateToLocal(activityFilterProxy.rangeEnd)) + } else { + calendar.seed(null, null) + } + } + + onClosed: { + // Restore the pane to match the applied filter on close, so a + // reopen shows the right pane from the first frame instead of + // flashing the calendar before it collapses back to presets. + datePopup.dateCustomExpanded = + activityFilterProxy.dateFilter === ActivityFilterProxyModel.CustomRange + } + ContextMenuPicker { + // Hidden while picking a custom range so the popup shows the + // calendar instead of presets plus calendar (which overflows). + visible: !datePopup.dateCustomExpanded objectNameRole: "objectName" currentValue: activityFilterProxy.dateFilter model: [ - { text: qsTr("All"), value: ActivityFilterProxyModel.DateAll, objectName: "activityDateAll" }, - { text: qsTr("Today"), value: ActivityFilterProxyModel.Today, objectName: "activityDateToday" }, - { text: qsTr("This week"), value: ActivityFilterProxyModel.ThisWeek, objectName: "activityDateThisWeek" }, - { text: qsTr("This month"), value: ActivityFilterProxyModel.ThisMonth, objectName: "activityDateThisMonth" }, - { text: qsTr("This year"), value: ActivityFilterProxyModel.ThisYear, objectName: "activityDateThisYear" } + { text: qsTr("All"), value: ActivityFilterProxyModel.DateAll, objectName: "activityDateAll" }, + { text: qsTr("Today"), value: ActivityFilterProxyModel.Today, objectName: "activityDateToday" }, + { text: qsTr("This week"), value: ActivityFilterProxyModel.ThisWeek, objectName: "activityDateThisWeek" }, + { text: qsTr("This month"), value: ActivityFilterProxyModel.ThisMonth, objectName: "activityDateThisMonth" }, + //: Activity date filter menu entry for the previous calendar month. + { text: qsTr("Last month"), value: ActivityFilterProxyModel.LastMonth, objectName: "activityDateLastMonth" }, + { text: qsTr("This year"), value: ActivityFilterProxyModel.ThisYear, objectName: "activityDateThisYear" }, + //: Activity date filter menu entry that opens the custom start/end date calendar. + { text: qsTr("Custom range"), value: ActivityFilterProxyModel.CustomRange, objectName: "activityDateCustomRange" } ] onActivated: function(value) { + if (value === ActivityFilterProxyModel.CustomRange) { + datePopup.dateCustomExpanded = true + return + } activityFilterProxy.dateFilter = value + datePopup.dateCustomExpanded = false datePopup.close() } } + + ColumnLayout { + Layout.fillWidth: true + Layout.leftMargin: 8 + Layout.rightMargin: 8 + Layout.bottomMargin: 6 + spacing: 8 + visible: datePopup.dateCustomExpanded + + // Return to the preset list. + ItemDelegate { + objectName: "activityDatePresetsBack" + Layout.fillWidth: true + implicitHeight: 30 + padding: 0 + //: Accessibility label for the control that leaves the custom date range calendar and returns to the date presets. + Accessible.name: qsTr("Back to date presets") + Accessible.role: Accessible.Button + contentItem: RowLayout { + spacing: 4 + Icon { + source: "image://images/caret-left" + color: Theme.color.orange + size: 14 + } + CoreText { + Layout.fillWidth: true + //: Activity custom date range: link back to the list of date presets. + text: qsTr("Presets") + color: Theme.color.orange + font.pixelSize: 14 + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + } + } + onClicked: datePopup.dateCustomExpanded = false + } + + Separator { Layout.fillWidth: true } + + ActivityCalendar { + id: calendar + Layout.fillWidth: true + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 2 + + TextButton { + objectName: "activityDateRangeReset" + //: Button that clears the Activity custom date range. + text: qsTr("Reset") + textSize: 15 + // Nothing to reset until a date has been picked. + enabled: calendar.hasSelection + textColor: enabled ? Theme.color.neutral7 : Theme.color.neutral5 + // A disabled control still receives hover, which would + // trigger the button's hover recolor; gate it on enabled. + hoverEnabled: enabled && AppMode.isDesktop + // Clear the picked dates and drop the applied filter, + // but stay in the calendar so a new range can be picked. + onClicked: { + calendar.reset() + activityFilterProxy.dateFilter = ActivityFilterProxyModel.DateAll + } + } + + Item { Layout.fillWidth: true } + + TextButton { + objectName: "activityDateRangeApply" + //: Button that applies the picked Activity custom date range. + text: qsTr("Apply") + textSize: 15 + enabled: calendar.valid + textColor: enabled ? Theme.color.orange : Theme.color.neutral5 + hoverEnabled: enabled && AppMode.isDesktop + // Pass ISO strings rather than the Dates: a JS Date + // converted straight to QDate can shift a day across + // time zones. + onClicked: { + activityFilterProxy.setCustomRange(calendar.startIso, calendar.endIso) + activityFilterProxy.dateFilter = ActivityFilterProxyModel.CustomRange + datePopup.close() + } + } + } + } } ContextMenu { id: typePopup objectName: "activityTypeFilterPopup" parent: typeFilterButton - x: typePopup.parent.width - typePopup.width + x: root.filterPopupX(typeFilterButton, typePopup) y: typePopup.parent.height + 2 modal: true dim: false @@ -668,6 +908,8 @@ PageStack { { text: qsTr("Sent"), value: ActivityFilterProxyModel.Sent, objectName: "activityTypeSent" }, { text: qsTr("Sent to yourself"), value: ActivityFilterProxyModel.SentToSelf, objectName: "activityTypeSentToSelf" }, { text: qsTr("Mined"), value: ActivityFilterProxyModel.Mined, objectName: "activityTypeMined" }, + //: Activity type filter menu entry for other transaction types. + { text: qsTr("Other"), value: ActivityFilterProxyModel.Other, objectName: "activityTypeOther" }, { text: qsTr("Payment request"), value: ActivityFilterProxyModel.PaymentRequest, objectName: "activityTypePaymentRequest" } ] onActivated: function(value) { @@ -676,6 +918,106 @@ PageStack { } } } + + ContextMenu { + id: amountPopup + objectName: "activityAmountFilterPopup" + parent: amountFilterButton + x: root.filterPopupX(amountFilterButton, amountPopup) + y: amountFilterButton.height + 2 + minMenuWidth: 280 + modal: true + dim: false + + onOpened: { + minAmountField.text = activityFilterProxy.minAmount >= 0 ? amountFormatter.display : "" + minAmountField.forceActiveFocus() + } + + ColumnLayout { + Layout.fillWidth: true + Layout.margins: 6 + spacing: 6 + + CoreText { + //: Heading of the Activity amount filter popup. + text: qsTr("Minimum amount") + color: Theme.color.neutral7 + font.pixelSize: 13 + horizontalAlignment: Text.AlignLeft + } + + RowLayout { + Layout.fillWidth: true + spacing: 8 + + ActivityFilterInput { + id: minAmountField + objectName: "activityMinAmountField" + Layout.fillWidth: true + inputMethodHints: Qt.ImhFormattedNumbersOnly + placeholderText: root.minAmountPlaceholder(amountFormatter.unit) + validator: RegularExpressionValidator { + regularExpression: root.minAmountPattern(amountFormatter.unit) + } + onAccepted: root.applyMinAmount() + } + + CoreText { + text: amountFormatter.unitLabel + color: Theme.color.neutral7 + font.pixelSize: 15 + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 2 + + TextButton { + objectName: "activityMinAmountReset" + //: Button that clears the Activity minimum amount filter. + text: qsTr("Reset") + textSize: 15 + textColor: Theme.color.neutral7 + onClicked: { + minAmountField.text = "" + activityFilterProxy.minAmount = -1 + amountPopup.close() + } + } + + Item { Layout.fillWidth: true } + + TextButton { + objectName: "activityMinAmountApply" + //: Button that applies the Activity minimum amount filter. + text: qsTr("Apply") + textSize: 15 + onClicked: root.applyMinAmount() + } + } + } + } + + component ActivityFilterInput: TextField { + implicitHeight: 37 + leftPadding: 12 + rightPadding: 12 + topPadding: 0 + bottomPadding: 0 + color: Theme.color.neutral9 + placeholderTextColor: Theme.color.neutral7 + font.family: "BitcoinCoreSans" + font.pixelSize: 15 + verticalAlignment: TextInput.AlignVCenter + selectByMouse: true + background: Rectangle { + color: Theme.color.neutral2 + radius: 5 + } + } + } } } diff --git a/qml/pages/wallet/ActivityTransactionVisuals.qml b/qml/pages/wallet/ActivityTransactionVisuals.qml index 4ba99d9baa..ff80d3ae3d 100644 --- a/qml/pages/wallet/ActivityTransactionVisuals.qml +++ b/qml/pages/wallet/ActivityTransactionVisuals.qml @@ -13,6 +13,7 @@ QtObject { property int transactionType: 0 property int transactionStatus: 0 property bool isPendingRequest: false + property bool isUsedAddressRequest: false readonly property bool incoming: root.transactionType == Transaction.RecvWithAddress || root.transactionType == Transaction.RecvFromOther @@ -20,15 +21,20 @@ QtObject { readonly property bool confirmedLike: root.transactionStatus == Transaction.Confirmed || root.transactionStatus == Transaction.Immature + // Requests use the receive triangle like any incoming entry; the color + // carries the state (see iconColor): a pending request is purple, a + // used-address request has been paid so it takes the green receive color. readonly property url iconSource: root.transactionType == Transaction.Generated ? "qrc:/icons/coinbase" : root.incoming ? "qrc:/icons/triangle-down" : "qrc:/icons/triangle-up" - readonly property color iconColor: root.isPendingRequest - ? Theme.color.purple - : root.confirmedLike - ? root.incoming ? Theme.color.green : Theme.color.orange - : Theme.color.blue + readonly property color iconColor: root.isUsedAddressRequest + ? Theme.color.green + : root.isPendingRequest + ? Theme.color.purple + : root.confirmedLike + ? root.incoming ? Theme.color.green : Theme.color.orange + : Theme.color.blue readonly property color amountColor: root.incoming ? Theme.color.green : Theme.color.neutral9 } diff --git a/qml/pages/wallet/RequestPayment.qml b/qml/pages/wallet/RequestPayment.qml index 5bd1a460af..b2cd42bd22 100644 --- a/qml/pages/wallet/RequestPayment.qml +++ b/qml/pages/wallet/RequestPayment.qml @@ -681,6 +681,15 @@ Page { noteSelfInput.text = root.requestValue("noteSelf") } } + function onLabelChanged() { + // The field's declarative binding is gone once anything + // assigns its text, so follow an external label change + // (the Addresses page reverse sync) by hand. Only while + // locked: an in-progress edit must not be clobbered. + if (!root.requestIsEditing()) { + nameInput.text = root.requestValue("label") + } + } } Connections { diff --git a/test/functional/qml_test_activity_filter_export.py b/test/functional/qml_test_activity_filter_export.py index 922f72c3c2..373b744afc 100644 --- a/test/functional/qml_test_activity_filter_export.py +++ b/test/functional/qml_test_activity_filter_export.py @@ -9,14 +9,22 @@ import re import sys import time -from datetime import datetime +from datetime import date, datetime from urllib.parse import urlparse +from qml_driver import QmlDriverError from qml_test_harness import dump_qml_tree from qml_test_receive import WALLET_NAME, _create_request, _import_wallet, _open_receive, _request_qr_payload from qml_wallet_test_lib import WalletFlowHarness, rpc_call, wait_for_rpc +def _add_months(d, delta): + """Return the first of the month `delta` months from `d`'s month.""" + index = d.month - 1 + delta + year = d.year + index // 12 + return date(year, index % 12 + 1, 1) + + def parse_args(): parser = argparse.ArgumentParser( description="Activity search/filter/export GUI functional test", @@ -257,7 +265,7 @@ def run_test(save_screenshots=False, screenshot_root=None): gui.wait_for_property( "activityEmptyStateDescription", "text", - "Try changing your search, date, or type filters.", + "Try changing your search, date, type, or amount filters.", timeout_ms=10000, ) checkpoints.checkpoint("filtered-empty Activity state shown", gui) @@ -272,6 +280,119 @@ def run_test(save_screenshots=False, screenshot_root=None): gui.wait_for_property("activityFilterProxyModel", "count", 2, timeout_ms=10000) checkpoints.checkpoint("search controls closed and filters reset", gui) + # Re-open the filter controls to exercise the amount and custom-range + # filters from a clean, fully reset state. + gui.click("activitySearchToggle") + gui.wait_for_property("activitySearchToggle", "checked", True, timeout_ms=5000) + gui.wait_for_property("activityFilterProxyModel", "count", 2, timeout_ms=10000) + + # Minimum amount filter. The display unit is sats here, so a 100000 sat + # (0.001 BTC) minimum keeps the 50 BTC mined row and drops the 0.0001 + # BTC (10000 sat) payment request. + gui.click("activityAmountFilterButton") + checkpoints.checkpoint("amount filter menu opened", gui) + gui.set_text("activityMinAmountField", "100000") + gui.click("activityMinAmountApply") + gui.wait_for_property("activityFilterProxyModel", "count", 1, timeout_ms=10000) + checkpoints.checkpoint("minimum amount filter applied", gui) + + min_amount_export_path = os.path.join(harness.tmpdir, "activity-min-amount.csv") + min_amount_csv = _export_activity_csv(gui, min_amount_export_path) + assert '"Mined"' in min_amount_csv, f"Min-amount export missing mined row: {min_amount_csv!r}" + assert '"Payment request"' not in min_amount_csv, f"Min-amount export kept request row: {min_amount_csv!r}" + gui.click("activityExportResultCloseButton") + + gui.click("activityAmountFilterButton") + gui.click("activityMinAmountReset") + gui.wait_for_property("activityFilterProxyModel", "count", 2, timeout_ms=10000) + checkpoints.checkpoint("minimum amount filter cleared", gui) + + # Custom date range via the calendar picker. Navigate one month back + # and pick a window there; it is entirely in the past, so today's rows + # drop out of the list. + gui.click("activityDateFilterButton") + gui.click("activityDateCustomRange") + checkpoints.checkpoint("custom date range calendar revealed", gui) + gui.click("calendarPrev") + gui.settle() + + # The displayed month comes from the GUI clock at popup-open while the + # target days come from Python's clock, so a month rollover between the + # two leaves the computed day names outside the shown month. Probe the + # grid for the expected month and fall back one further month if a + # rollover happened; the fallback probe raising is a real failure. + last_month = _add_months(date.today().replace(day=1), -1) + try: + gui.wait_for_object(f"calendarDay_{last_month.replace(day=10).isoformat()}", timeout_ms=5000) + except QmlDriverError: + last_month = _add_months(last_month, -1) + gui.wait_for_object(f"calendarDay_{last_month.replace(day=10).isoformat()}", timeout_ms=5000) + from_day = last_month.replace(day=10).isoformat() + to_day = last_month.replace(day=20).isoformat() + + gui.click(f"calendarDay_{from_day}") + gui.click(f"calendarDay_{to_day}") + gui.click("activityDateRangeApply") + gui.wait_for_property("activityFilterProxyModel", "count", 0, timeout_ms=10000) + date_filter = gui.get_property("activityFilterProxyModel", "dateFilter") + assert date_filter in (6, "CustomRange"), f"Custom range was not applied: {date_filter!r}" + checkpoints.checkpoint("past custom range excludes today's rows", gui) + + gui.click("activityDateFilterButton") + gui.click("activityDateRangeReset") + gui.wait_for_property("activityFilterProxyModel", "count", 2, timeout_ms=10000) + cleared_date_filter = gui.get_property("activityFilterProxyModel", "dateFilter") + assert cleared_date_filter in (0, "DateAll"), f"Custom range reset failed: {cleared_date_filter!r}" + checkpoints.checkpoint("custom date range cleared", gui) + + # Issue #726: paying a pending request's address keeps the request under + # the Payment request filter as a used-address request, with no reload, + # and surfaces the real transaction as its own row in the default view. + # Toggle the controls to guarantee a clean, popup-free starting state. + gui.click("activitySearchToggle") + gui.wait_for_property("activitySearchToggle", "checked", False, timeout_ms=5000) + gui.click("activitySearchToggle") + gui.wait_for_property("activitySearchToggle", "checked", True, timeout_ms=5000) + gui.wait_for_property("activityFilterProxyModel", "count", 2, timeout_ms=10000) + + gui.click("activityTypeFilterButton") + gui.click("activityTypePaymentRequest") + gui.wait_for_property("activityFilterProxyModel", "count", 1, timeout_ms=10000) + checkpoints.checkpoint("pending request shown under Payment request filter", gui) + + # Mine a block to the request address to fulfill the pending request live. + rpc_call(harness.gui_rpc_port, "generatetoaddress", [1, payment_request_address]) + + # The address now has its own mined transaction row; waiting on it is the + # synchronization point for the live fulfillment. + gui.click("activityTypeFilterButton") + gui.click("activityTypeMined") + gui.set_text("activitySearchField", payment_request_address) + gui.wait_for_property("activityFilterProxyModel", "count", 1, timeout_ms=20000) + checkpoints.checkpoint("request address gained its own mined transaction row", gui) + + # The request itself is still surfaced under the Payment request filter, + # now as a used-address request, without a reload. Before the live + # fulfillment fix it dropped out of the filter until the wallet reloaded. + gui.click("activityTypeFilterButton") + gui.click("activityTypePaymentRequest") + gui.wait_for_property("activityFilterProxyModel", "count", 1, timeout_ms=10000) + used_request_export_path = os.path.join(harness.tmpdir, "activity-used-request.csv") + used_request_csv = _export_activity_csv(gui, used_request_export_path) + assert '"Payment request"' in used_request_csv, f"Fulfilled request left the Payment request filter: {used_request_csv!r}" + assert '"Alice"' in used_request_csv, f"Used-address request missing its label: {used_request_csv!r}" + assert f'"{payment_request_address}"' in used_request_csv, f"Used-address request missing its address: {used_request_csv!r}" + gui.click("activityExportResultCloseButton") + checkpoints.checkpoint("fulfilled request stays a used-address request under the filter", gui) + + # In the default view the request is hidden, so it does not duplicate the + # real transaction to its address; only the two mined rows remain. + gui.set_text("activitySearchField", "") + gui.click("activityTypeFilterButton") + gui.click("activityTypeAll") + gui.wait_for_property("activityFilterProxyModel", "count", 2, timeout_ms=10000) + checkpoints.checkpoint("used-address request hidden from the default Activity view", gui) + txid, expected_amount = _activity_transaction_row(gui) gui.invoke("activityStack", "navigateToTransaction", [txid]) gui.wait_for_page("activityDetailsPage", timeout_ms=10000) diff --git a/test/functional/qml_test_address_label_sync.py b/test/functional/qml_test_address_label_sync.py new file mode 100644 index 0000000000..c8423b8f3b --- /dev/null +++ b/test/functional/qml_test_address_label_sync.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Reverse label sync: editing an address label on the Addresses page updates the +matching payment request and its Activity row, not just the address book.""" + +import sys + +from qml_test_harness import dump_qml_tree +from qml_test_receive import ( + _address_from_bip21, + _create_request, + _import_wallet, + _open_activity, + _open_receive, + _request_qr_payload, +) +from qml_test_addresses import open_address_list_from_settings +from qml_wallet_test_lib import WalletFlowHarness + +ORIGINAL_LABEL = "Alice" +NEW_LABEL = "Renamed on Addresses page" + + +def _address_row_index(gui, address): + count = gui.get_property("addressListView", "count") + for row in range(count): + if gui.get_list_item_property("addressListView", row, "address") == address: + return row + raise AssertionError(f"Address {address!r} not found in the address list") + + +def _activity_request_label(gui): + # No funding in this flow, so the pending request is the only Activity row. + gui.wait_for_property("activityListView", "count", lambda c: c >= 1, timeout_ms=10000) + # Force the delegate to instantiate offscreen before reading its label. + gui.set_property("activityListView", "currentIndex", 0) + gui.invoke("activityListView", "forceLayout") + gui.settle() + return gui.get_list_item_property("activityListView", 0, "label") + + +def run_test(): + harness = WalletFlowHarness("qml_address_label_sync", port_offset=75) + try: + gui = _import_wallet(harness) + + # Save a payment request labelled ORIGINAL_LABEL; creating it also labels + # its receive address in the address book. + _open_receive(gui) + _create_request(gui, "0.0001", ORIGINAL_LABEL, "pizza") + request_address = _address_from_bip21(_request_qr_payload(gui)) + + # The pending request shows the original label in Activity. + _open_activity(gui) + assert _activity_request_label(gui) == ORIGINAL_LABEL, ( + f"Expected Activity request label {ORIGINAL_LABEL!r}, " + f"got {_activity_request_label(gui)!r}" + ) + + # Edit the label on the Addresses page. + open_address_list_from_settings(gui) + row = _address_row_index(gui, request_address) + assert gui.get_list_item_property("addressListView", row, "label") == ORIGINAL_LABEL + gui.click_list_item("addressListView", row, "addressRowNoteButton") + gui.wait_for_object("addressLabelInput", timeout_ms=5000) + gui.set_text("addressLabelInput", NEW_LABEL) + gui.click("addressLabelSaveButton") + gui.settle() + + # Surface 1: the Addresses page reflects the edit. + row = _address_row_index(gui, request_address) + assert gui.get_list_item_property("addressListView", row, "label") == NEW_LABEL, ( + "Addresses page did not show the edited label" + ) + + # Surface 2: the reverse sync carried the edit to the request's Activity row. + _open_activity(gui) + actual = _activity_request_label(gui) + assert actual == NEW_LABEL, ( + "Activity request row did not follow the Addresses-page label edit: " + f"expected {NEW_LABEL!r}, got {actual!r}" + ) + + # Surface 3: the Receive tab kept the request open in its locked editor + # the whole time; switching back must show the edited label, not the + # stale text the form held when the label was edited elsewhere. + _open_receive(gui) + editor_label = gui.get_text("requestPaymentYourNameInput") + assert editor_label == NEW_LABEL, ( + "Held-open request editor did not follow the Addresses-page label edit: " + f"expected {NEW_LABEL!r}, got {editor_label!r}" + ) + + print("Address label reverse-sync flow passed.") + return 0 + except Exception as err: # noqa: BLE001 - preserve GUI context on failures + print(f"\nFAILED [qml_address_label_sync]: {err}", file=sys.stderr) + import traceback + traceback.print_exc() + try: + dump_qml_tree(harness.driver) + except Exception: # noqa: BLE001 + pass + return 1 + finally: + harness.stop() + + +if __name__ == "__main__": + sys.exit(run_test()) diff --git a/test/mocks/mockwallet.h b/test/mocks/mockwallet.h index 52eefba232..bbe8434aa8 100644 --- a/test/mocks/mockwallet.h +++ b/test/mocks/mockwallet.h @@ -135,6 +135,8 @@ class MockWallet : public StubWallet MOCK_METHOD(CoinsList, listCoins, (), (override)); MOCK_METHOD(OutputType, getDefaultAddressType, (), (override)); MOCK_METHOD((std::unique_ptr), handleTransactionChanged, (TransactionChangedFn), (override)); + MOCK_METHOD(interfaces::WalletTx, getWalletTx, (const Txid&), (override)); + MOCK_METHOD(bool, tryGetTxStatus, (const Txid&, interfaces::WalletTxStatus&, int&, int64_t&), (override)); MOCK_METHOD(bool, transactionCanBeBumped, (const Txid&), (override)); MOCK_METHOD(bool, createBumpTransaction, (const Txid&, const wallet::CCoinControl&, std::vector&, CAmount&, CAmount&, CMutableTransaction&), (override)); MOCK_METHOD(bool, signBumpTransaction, (CMutableTransaction&), (override)); diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index 0fef97273c..f003993a1b 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -2784,7 +2784,8 @@ class MockActivityListModel : public QAbstractListModel IsPendingRequestRole, RequestIdRole, TimestampRole, - NetAmountSatRole + NetAmountSatRole, + IsUsedAddressRequestRole }; int rowCount(const QModelIndex& parent = QModelIndex{}) const override @@ -2798,6 +2799,16 @@ class MockActivityListModel : public QAbstractListModel QVariant data(const QModelIndex& index, int role) const override { if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) return {}; + if (index.row() == m_used_request_row) { + switch (role) { + case IsPendingRequestRole: return true; + case IsUsedAddressRequestRole: return true; + case TxidRole: return QString{}; + case RequestIdRole: return QStringLiteral("used-req-1"); + default: break; + } + } + if (role == IsUsedAddressRequestRole) return false; if (index.row() == 0) { switch (role) { case AddressRole: return QStringLiteral("bcrt1qreceiveaddress"); @@ -2856,6 +2867,7 @@ class MockActivityListModel : public QAbstractListModel {RequestIdRole, "requestId"}, {TimestampRole, "timestamp"}, {NetAmountSatRole, "netAmountSat"}, + {IsUsedAddressRequestRole, "isUsedAddressRequest"}, }; } @@ -2869,11 +2881,23 @@ class MockActivityListModel : public QAbstractListModel Q_EMIT countChanged(); } + // Marks one row as a used-address payment request (-1 for none), so tests + // can cover the paid-request rendering path in the Activity delegate. + Q_INVOKABLE void setUsedAddressRequestRowForTest(int row) + { + if (m_used_request_row == row) return; + beginResetModel(); + m_used_request_row = row; + endResetModel(); + Q_EMIT countChanged(); + } + Q_SIGNALS: void countChanged(); private: int m_count{2}; + int m_used_request_row{-1}; }; class MockActivityFilterProxyModel : public QSortFilterProxyModel @@ -2883,6 +2907,9 @@ class MockActivityFilterProxyModel : public QSortFilterProxyModel Q_PROPERTY(DateFilter dateFilter READ dateFilter WRITE setDateFilter NOTIFY dateFilterChanged) Q_PROPERTY(TypeFilter typeFilter READ typeFilter WRITE setTypeFilter NOTIFY typeFilterChanged) Q_PROPERTY(int displayUnit READ displayUnit WRITE setDisplayUnit NOTIFY displayUnitChanged) + Q_PROPERTY(qint64 minAmount READ minAmount WRITE setMinAmount NOTIFY minAmountChanged) + Q_PROPERTY(QDate rangeStart READ rangeStart WRITE setRangeStart NOTIFY rangeChanged) + Q_PROPERTY(QDate rangeEnd READ rangeEnd WRITE setRangeEnd NOTIFY rangeChanged) Q_PROPERTY(int count READ count NOTIFY countChanged) public: @@ -2891,7 +2918,9 @@ class MockActivityFilterProxyModel : public QSortFilterProxyModel Today, ThisWeek, ThisMonth, - ThisYear + LastMonth, + ThisYear, + CustomRange }; Q_ENUM(DateFilter) @@ -2901,6 +2930,7 @@ class MockActivityFilterProxyModel : public QSortFilterProxyModel Sent, SentToSelf, Mined, + Other, PaymentRequest }; Q_ENUM(TypeFilter) @@ -2991,6 +3021,64 @@ class MockActivityFilterProxyModel : public QSortFilterProxyModel Q_EMIT displayUnitChanged(); } + qint64 minAmount() const { return m_min_amount; } + void setMinAmount(qint64 min_amount) + { + const qint64 normalized = min_amount < 0 ? -1 : min_amount; + if (m_min_amount == normalized) return; +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); +#endif + m_min_amount = normalized; +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + endFilterChange(QSortFilterProxyModel::Direction::Rows); +#else + invalidateFilter(); +#endif + Q_EMIT minAmountChanged(); + Q_EMIT countChanged(); + } + + QDate rangeStart() const { return m_range_start; } + void setRangeStart(const QDate& range_start) + { + if (m_range_start == range_start) return; +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); +#endif + m_range_start = range_start; +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + endFilterChange(QSortFilterProxyModel::Direction::Rows); +#else + invalidateFilter(); +#endif + Q_EMIT rangeChanged(); + Q_EMIT countChanged(); + } + + QDate rangeEnd() const { return m_range_end; } + void setRangeEnd(const QDate& range_end) + { + if (m_range_end == range_end) return; +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + beginFilterChange(); +#endif + m_range_end = range_end; +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + endFilterChange(QSortFilterProxyModel::Direction::Rows); +#else + invalidateFilter(); +#endif + Q_EMIT rangeChanged(); + Q_EMIT countChanged(); + } + + Q_INVOKABLE void setCustomRange(const QString& start_iso, const QString& end_iso) + { + setRangeStart(QDate::fromString(start_iso, Qt::ISODate)); + setRangeEnd(QDate::fromString(end_iso, Qt::ISODate)); + } + int count() const { return rowCount(); } Q_INVOKABLE bool exportCsv(const QString& path) const @@ -3014,6 +3102,8 @@ class MockActivityFilterProxyModel : public QSortFilterProxyModel void dateFilterChanged(); void typeFilterChanged(); void displayUnitChanged(); + void minAmountChanged(); + void rangeChanged(); void countChanged(); private: @@ -3021,6 +3111,9 @@ class MockActivityFilterProxyModel : public QSortFilterProxyModel DateFilter m_date_filter{DateAll}; TypeFilter m_type_filter{TypeAll}; int m_display_unit{0}; + qint64 m_min_amount{-1}; + QDate m_range_start; + QDate m_range_end; }; class MockDesktopWindowBehaviorModel : public QObject diff --git a/test/qml/tst_activity.qml b/test/qml/tst_activity.qml index b4976cf36d..1f26d73000 100644 --- a/test/qml/tst_activity.qml +++ b/test/qml/tst_activity.qml @@ -4,6 +4,8 @@ import QtQuick 2.15 import QtTest 1.2 +import org.bitcoincore.qt 1.0 +import "../../qml/controls" import "../../qml/pages/wallet" TestCase { @@ -17,6 +19,7 @@ TestCase { testWalletModel.lastLoadedPaymentRequestId = "" testWalletModel.lastLoadedPaymentRequestDetailId = "" testActivityListModel.setCountForTest(2) + testActivityListModel.setUsedAddressRequestRowForTest(-1) nodeModel.setBlockSyncActiveForTest(false) nodeModel.verificationProgress = 1.0 walletController.openReceiveRequests = 0 @@ -31,6 +34,18 @@ TestCase { } } + Component { + id: activityCalendarComponent + + ActivityCalendar {} + } + + Component { + id: transactionVisualsComponent + + ActivityTransactionVisuals {} + } + Component { id: activityDetailsComponent @@ -104,7 +119,7 @@ TestCase { const title = findActivityEmptyStateText(page, "activityEmptyStateTitle") const description = findActivityEmptyStateText(page, "activityEmptyStateDescription") tryCompare(title, "text", "No activity matches your filters.") - compare(description.text, "Try changing your search, date, or type filters.") + compare(description.text, "Try changing your search, date, type, or amount filters.") } function test_non_empty_activity_still_shows_transaction_rows() { @@ -183,4 +198,220 @@ TestCase { walletController.setSelectedWallet("third-wallet") tryCompare(page, "depth", 1) } + + function test_minimum_amount_filter_applies_and_clears() { + const page = createTemporaryObject(activityComponent, this) + verify(page !== null) + + const proxy = findChild(page, "activityFilterProxyModel") + const field = findChild(page, "activityMinAmountField") + const applyButton = findChild(page, "activityMinAmountApply") + const resetButton = findChild(page, "activityMinAmountReset") + verify(proxy !== null) + verify(field !== null) + verify(applyButton !== null) + verify(resetButton !== null) + + // Default display unit is BTC, so "0.5" parses to 50,000,000 sat. + compare(optionsModel.displayUnit, 0) + field.text = "0.5" + applyButton.clicked() + compare(proxy.minAmount, 50000000) + + resetButton.clicked() + compare(proxy.minAmount, -1) + } + + function test_minimum_amount_filter_parses_every_display_unit() { + const page = createTemporaryObject(activityComponent, this) + verify(page !== null) + + const proxy = findChild(page, "activityFilterProxyModel") + const field = findChild(page, "activityMinAmountField") + const applyButton = findChild(page, "activityMinAmountApply") + verify(proxy !== null) + verify(field !== null) + verify(applyButton !== null) + + // The threshold is typed in the active display unit, so it must parse + // as mBTC and bits too, not only as BTC and sats. + const cases = [ + {unit: 0, text: "0.5", sats: 50000000}, + {unit: 1, text: "0.5", sats: 50000}, + {unit: 2, text: "0.5", sats: 50}, + {unit: 3, text: "50", sats: 50}, + ] + try { + for (const c of cases) { + optionsModel.displayUnit = c.unit + field.text = c.text + applyButton.clicked() + compare(proxy.minAmount, c.sats) + } + } finally { + // Leave the shared display unit as found for the other tests. + optionsModel.displayUnit = 0 + } + + // The field's placeholder and input mask follow the unit as well, so a + // mBTC or bits threshold is not masked to BTC precision. + const activityPage = findChild(page, "activityPage") + verify(activityPage !== null) + // Units by number: QML cannot name BitcoinAmount.mBTC / uBTC (enum + // values starting with a lowercase letter are not exposed). + compare(activityPage.minAmountPlaceholder(0), "0.00000000") // BTC + compare(activityPage.minAmountPlaceholder(1), "0.00000") // mBTC + compare(activityPage.minAmountPlaceholder(2), "0.00") // uBTC + compare(activityPage.minAmountPlaceholder(3), "0") // SAT + verify(activityPage.minAmountPattern(1).test("1.12345")) + verify(!activityPage.minAmountPattern(1).test("1.123456")) + verify(activityPage.minAmountPattern(2).test("1.12")) + verify(!activityPage.minAmountPattern(2).test("1.123")) + verify(!activityPage.minAmountPattern(3).test("1.5")) + } + + function test_custom_date_range_filter_applies() { + const page = createTemporaryObject(activityComponent, this) + verify(page !== null) + + const proxy = findChild(page, "activityFilterProxyModel") + const applyButton = findChild(page, "activityDateRangeApply") + verify(proxy !== null) + verify(applyButton !== null) + + // The calendar opens on the current month; pick day 10 (From) and day + // 20 (To), which exist in every month. Clicking a day delegate fires + // its clicked() handler the same way a real tap does. + const now = new Date() + const fromIso = Qt.formatDate(new Date(now.getFullYear(), now.getMonth(), 10), "yyyy-MM-dd") + const toIso = Qt.formatDate(new Date(now.getFullYear(), now.getMonth(), 20), "yyyy-MM-dd") + // Repeater-generated cells are visual children of the grid but not + // QObject children, so look them up via the grid rather than findChild. + const grid = findChild(page, "activityCalendarGrid") + verify(grid !== null) + function dayCell(iso) { + for (var i = 0; i < grid.children.length; ++i) { + if (grid.children[i].objectName === "calendarDay_" + iso) { + return grid.children[i] + } + } + return null + } + const fromDay = dayCell(fromIso) + const toDay = dayCell(toIso) + verify(fromDay !== null) + verify(toDay !== null) + + fromDay.clicked() // From is active by default, then advances to To + toDay.clicked() + applyButton.clicked() + + compare(proxy.dateFilter, ActivityFilterProxyModel.CustomRange) + // Assert via the same formatting the UI uses, so the round trip is + // checked independently of the local time zone. + compare(Qt.formatDate(proxy.rangeStart, "yyyy-MM-dd"), fromIso) + compare(Qt.formatDate(proxy.rangeEnd, "yyyy-MM-dd"), toIso) + } + + function test_calendar_keyboard_focus_helpers() { + const calendar = createTemporaryObject(activityCalendarComponent, this) + verify(calendar !== null) + + // Offscreen tests cannot drive real key events through the popup, so + // exercise the functions the grid's Keys handler calls directly. + // A month far in the past keeps "today" out of the seeding path. + calendar.calMonth = 0 + calendar.calYear = 2001 + calendar.customFrom = null + calendar.calendarFocusDate = null + + calendar.ensureCalendarFocusDate() + compare(calendar.isoDate(calendar.calendarFocusDate), "2001-01-01") + + // Left from the first of the month crosses into the previous month + // and carries the displayed month along. + calendar.moveCalendarFocus(-1) + compare(calendar.isoDate(calendar.calendarFocusDate), "2000-12-31") + compare(calendar.calMonth, 11) + compare(calendar.calYear, 2000) + + // Down (a week) moves back into January. + calendar.moveCalendarFocus(7) + compare(calendar.isoDate(calendar.calendarFocusDate), "2001-01-07") + compare(calendar.calMonth, 0) + + // A month step clamps the day to the target month's length. + calendar.calendarFocusDate = new Date(2001, 0, 31) + calendar.stepCalendarFocusMonth(1) + compare(calendar.isoDate(calendar.calendarFocusDate), "2001-02-28") + compare(calendar.calMonth, 1) + + // A picked start inside the displayed month seeds the focus ring. + calendar.calendarFocusDate = null + calendar.customFrom = new Date(2001, 1, 10) + calendar.ensureCalendarFocusDate() + compare(calendar.isoDate(calendar.calendarFocusDate), "2001-02-10") + } + + function test_used_address_request_row_gets_paid_treatment() { + testActivityListModel.setUsedAddressRequestRowForTest(0) + + const page = createTemporaryObject(activityComponent, this) + verify(page !== null) + + let row = null + tryVerify(function() { + row = findChild(page, "activityItem_pending_0") + return row !== null + }) + compare(row.isPendingRequest, true) + compare(row.isUsedAddressRequest, true) + } + + function test_transaction_visuals_distinguish_paid_from_pending_request() { + const pending = createTemporaryObject(transactionVisualsComponent, this, { + transactionType: Transaction.RecvWithAddress, + transactionStatus: Transaction.Unconfirmed, + isPendingRequest: true + }) + const paid = createTemporaryObject(transactionVisualsComponent, this, { + transactionType: Transaction.RecvWithAddress, + transactionStatus: Transaction.Unconfirmed, + isPendingRequest: true, + isUsedAddressRequest: true + }) + verify(pending !== null) + verify(paid !== null) + + compare(pending.iconSource, "qrc:/icons/triangle-down") + compare(pending.iconColor, Theme.color.purple) + compare(paid.iconSource, "qrc:/icons/triangle-down") + compare(paid.iconColor, Theme.color.green) + } + + function test_custom_date_range_reopen_preserves_dates() { + const page = createTemporaryObject(activityComponent, this) + verify(page !== null) + + const proxy = findChild(page, "activityFilterProxyModel") + const activityPage = findChild(page, "activityPage") + const calendar = createTemporaryObject(activityCalendarComponent, this) + verify(proxy !== null) + verify(activityPage !== null) + verify(calendar !== null) + + // Apply a fixed range through the timezone-safe ISO setter. + proxy.setCustomRange("2025-06-10", "2025-06-20") + + // Reopening the date popup re-seeds the calendar draft from + // proxy.rangeStart / rangeEnd, which reach QML as UTC-midnight JS Dates. + // modelDateToLocal must keep the same calendar day; before it, the local + // formatting read them a day early west of UTC and Apply then shifted the + // applied range by a day on every reopen. Drive the whole reopen path + // (convert, seed, read back the ISO the Apply button sends to the model). + calendar.seed(activityPage.modelDateToLocal(proxy.rangeStart), + activityPage.modelDateToLocal(proxy.rangeEnd)) + compare(calendar.startIso, "2025-06-10") + compare(calendar.endIso, "2025-06-20") + } } diff --git a/test/qml/tst_requestpayment.qml b/test/qml/tst_requestpayment.qml index 4b5a4618e0..b02a179050 100644 --- a/test/qml/tst_requestpayment.qml +++ b/test/qml/tst_requestpayment.qml @@ -413,6 +413,33 @@ TestCase { compare(testPaymentRequest.amount.display, "0.00200000") } + function test_lockedEditorNameFollowsExternalLabelChange() { + const page = createTemporaryObject(requestPaymentComponent, this) + verify(page !== null) + page.wallet = testWalletModel + page.request = testPaymentRequest + + const nameField = findChild(page, "requestPaymentYourNameInput") + verify(nameField !== null) + + // Typing replaces the field's declarative binding, like a real session. + nameField.text = "Alice" + testPaymentRequest.label = "Alice" + testWalletModel.commitPaymentRequest() + compare(nameField.text, "Alice") + + // An Addresses page edit reverse-syncs the held request's label while + // the form is locked; the field must follow. + testPaymentRequest.label = "Bob" + compare(nameField.text, "Bob") + + // Mid-edit, an external label change must not clobber the draft. + testPaymentRequest.edit() + nameField.text = "Unsaved draft" + testPaymentRequest.label = "Carol" + compare(nameField.text, "Unsaved draft") + } + function test_editingRequestDeleteAction_removes_and_clears_request() { const page = createTemporaryObject(requestPaymentComponent, this) verify(page !== null) diff --git a/test/test_activityfilterproxymodel.cpp b/test/test_activityfilterproxymodel.cpp index 677af6a48f..3b33c86e2f 100644 --- a/test/test_activityfilterproxymodel.cpp +++ b/test/test_activityfilterproxymodel.cpp @@ -26,6 +26,7 @@ struct ActivityRow { qint64 timestamp{0}; QString txid; bool pending_request{false}; + bool used_address_request{false}; qlonglong net_amount_sat{0}; }; @@ -63,6 +64,8 @@ class TestActivityListModel : public QAbstractListModel return row.pending_request ? QString{} : row.txid; case ActivityListModel::IsPendingRequestRole: return row.pending_request; + case ActivityListModel::IsUsedAddressRequestRole: + return row.used_address_request; case ActivityListModel::NetAmountSatRole: return row.net_amount_sat; default: @@ -83,6 +86,7 @@ class TestActivityListModel : public QAbstractListModel {ActivityListModel::TimestampRole, "timestamp"}, {ActivityListModel::TxIdRole, "txid"}, {ActivityListModel::IsPendingRequestRole, "isPendingRequest"}, + {ActivityListModel::IsUsedAddressRequestRole, "isUsedAddressRequest"}, {ActivityListModel::NetAmountSatRole, "netAmountSat"}, }; } @@ -136,7 +140,10 @@ class ActivityFilterProxyModelTests : public QObject private Q_SLOTS: void searchMatchesLabelAddressAndTxid(); void filtersByDateBuckets(); + void filtersByCustomDateRange(); void filtersByTypeBucketsAndKeepsPendingRequestsExclusive(); + void usedAddressRequestsOnlyVisibleUnderPaymentRequestFilter(); + void filtersByMinimumAmount(); void sortsByTimestampDescending(); void exportsCurrentFilteredRowsToCsv(); void exportsCsvUsingDisplayUnit(); @@ -175,6 +182,7 @@ void ActivityFilterProxyModelTests::filtersByDateBuckets() const QDate start_of_month{today.year(), today.month(), 1}; const QDate start_of_year{today.year(), 1, 1}; const QDate start_of_next_week = start_of_week.addDays(7); + const QDate start_of_last_month = start_of_month.addMonths(-1); const QDate start_of_next_month = start_of_month.addMonths(1); const QDate start_of_next_year = start_of_year.addYears(1); @@ -189,6 +197,8 @@ void ActivityFilterProxyModelTests::filtersByDateBuckets() MakeRow("Month start", Transaction::RecvWithAddress, TimestampForLocalDate(start_of_month)), MakeRow("Before month", Transaction::RecvWithAddress, TimestampForLocalDate(start_of_month.addDays(-1))), MakeRow("Next month", Transaction::RecvWithAddress, TimestampForLocalDate(start_of_next_month)), + MakeRow("Last month start", Transaction::RecvWithAddress, TimestampForLocalDate(start_of_last_month)), + MakeRow("Before last month", Transaction::RecvWithAddress, TimestampForLocalDate(start_of_last_month.addDays(-1))), MakeRow("Year start", Transaction::RecvWithAddress, TimestampForLocalDate(start_of_year)), MakeRow("Before year", Transaction::RecvWithAddress, TimestampForLocalDate(start_of_year.addDays(-1))), MakeRow("Next year", Transaction::RecvWithAddress, TimestampForLocalDate(start_of_next_year)), @@ -212,6 +222,12 @@ void ActivityFilterProxyModelTests::filtersByDateBuckets() QVERIFY(!ContainsLabel(proxy, "Before month")); QVERIFY(!ContainsLabel(proxy, "Next month")); + proxy.setDateFilter(ActivityFilterProxyModel::LastMonth); + QVERIFY(ContainsLabel(proxy, "Last month start")); + QVERIFY(ContainsLabel(proxy, "Before month")); + QVERIFY(!ContainsLabel(proxy, "Before last month")); + QVERIFY(!ContainsLabel(proxy, "Month start")); + proxy.setDateFilter(ActivityFilterProxyModel::ThisYear); QVERIFY(ContainsLabel(proxy, "Year start")); QVERIFY(!ContainsLabel(proxy, "Before year")); @@ -242,11 +258,12 @@ void ActivityFilterProxyModelTests::filtersByTypeBucketsAndKeepsPendingRequestsE QCOMPARE(proxy.index(0, 0).data(ActivityListModel::LabelRole).toString(), QString{"Received"}); proxy.setTypeFilter(ActivityFilterProxyModel::Sent); - QCOMPARE(proxy.rowCount(), 2); - QVERIFY(ContainsLabel(proxy, "Sent")); - QVERIFY(ContainsLabel(proxy, "Other")); - QVERIFY(!ContainsLabel(proxy, "Self")); - QVERIFY(!ContainsLabel(proxy, "Request")); + QCOMPARE(proxy.rowCount(), 1); + QCOMPARE(proxy.index(0, 0).data(ActivityListModel::LabelRole).toString(), QString{"Sent"}); + + proxy.setTypeFilter(ActivityFilterProxyModel::Other); + QCOMPARE(proxy.rowCount(), 1); + QCOMPARE(proxy.index(0, 0).data(ActivityListModel::LabelRole).toString(), QString{"Other"}); proxy.setTypeFilter(ActivityFilterProxyModel::SentToSelf); QCOMPARE(proxy.rowCount(), 1); @@ -261,6 +278,44 @@ void ActivityFilterProxyModelTests::filtersByTypeBucketsAndKeepsPendingRequestsE QCOMPARE(proxy.index(0, 0).data(ActivityListModel::LabelRole).toString(), QString{"Request"}); } +void ActivityFilterProxyModelTests::usedAddressRequestsOnlyVisibleUnderPaymentRequestFilter() +{ + // A request whose address already has a real transaction is materialized so + // the Payment request filter can surface it, but it must stay hidden in the + // default view so it does not duplicate that address's real row. + ActivityRow received = MakeRow("Received", Transaction::RecvWithAddress, 30, "tx-received"); + ActivityRow pending = MakeRow("Pending", Transaction::RecvWithAddress, 20); + pending.pending_request = true; + ActivityRow used = MakeRow("Used", Transaction::RecvWithAddress, 10); + used.pending_request = true; + used.used_address_request = true; + + TestActivityListModel source; + source.setRows({received, pending, used}); + + ActivityFilterProxyModel proxy; + proxy.setSourceModel(&source); + + // Default view (all types): the used-address request is hidden. + QCOMPARE(proxy.rowCount(), 2); + QVERIFY(ContainsLabel(proxy, "Received")); + QVERIFY(ContainsLabel(proxy, "Pending")); + QVERIFY(!ContainsLabel(proxy, "Used")); + + // Payment request filter: both requests show, the real transaction does not. + proxy.setTypeFilter(ActivityFilterProxyModel::PaymentRequest); + QCOMPARE(proxy.rowCount(), 2); + QVERIFY(ContainsLabel(proxy, "Pending")); + QVERIFY(ContainsLabel(proxy, "Used")); + QVERIFY(!ContainsLabel(proxy, "Received")); + + // Any other type filter still hides the used-address request. + proxy.setTypeFilter(ActivityFilterProxyModel::Received); + QCOMPARE(proxy.rowCount(), 1); + QVERIFY(ContainsLabel(proxy, "Received")); + QVERIFY(!ContainsLabel(proxy, "Used")); +} + void ActivityFilterProxyModelTests::sortsByTimestampDescending() { TestActivityListModel source; @@ -371,6 +426,59 @@ void ActivityFilterProxyModelTests::exportsCsvEscapesSignedRowsAndHandlesFailure QVERIFY(!proxy.exportCsv(temp_dir.filePath("missing/activity.csv"))); } +void ActivityFilterProxyModelTests::filtersByCustomDateRange() +{ + const QDate start{2025, 6, 10}; + const QDate end{2025, 6, 20}; + + TestActivityListModel source; + source.setRows({ + MakeRow("Before", Transaction::RecvWithAddress, TimestampForLocalDate(start.addDays(-1))), + MakeRow("On start", Transaction::RecvWithAddress, TimestampForLocalDate(start)), + MakeRow("Inside", Transaction::RecvWithAddress, TimestampForLocalDate(QDate(2025, 6, 15))), + MakeRow("On end", Transaction::RecvWithAddress, TimestampForLocalDate(end)), + MakeRow("After", Transaction::RecvWithAddress, TimestampForLocalDate(end.addDays(1))), + }); + + ActivityFilterProxyModel proxy; + proxy.setSourceModel(&source); + proxy.setRangeStart(start); + proxy.setRangeEnd(end); + proxy.setDateFilter(ActivityFilterProxyModel::CustomRange); + + QCOMPARE(proxy.rowCount(), 3); + QVERIFY(ContainsLabel(proxy, "On start")); // lower bound inclusive + QVERIFY(ContainsLabel(proxy, "Inside")); + QVERIFY(ContainsLabel(proxy, "On end")); // upper bound inclusive (whole day) + QVERIFY(!ContainsLabel(proxy, "Before")); + QVERIFY(!ContainsLabel(proxy, "After")); +} + +void ActivityFilterProxyModelTests::filtersByMinimumAmount() +{ + ActivityRow small = MakeRow("Small", Transaction::RecvWithAddress, 10); + small.net_amount_sat = 50'000; + ActivityRow big_receive = MakeRow("Big receive", Transaction::RecvWithAddress, 20); + big_receive.net_amount_sat = 200'000; + ActivityRow big_send = MakeRow("Big send", Transaction::SendToAddress, 30); + big_send.net_amount_sat = -200'000; + + TestActivityListModel source; + source.setRows({small, big_receive, big_send}); + + ActivityFilterProxyModel proxy; + proxy.setSourceModel(&source); + + proxy.setMinAmount(100'000); + QCOMPARE(proxy.rowCount(), 2); + QVERIFY(ContainsLabel(proxy, "Big receive")); + QVERIFY(ContainsLabel(proxy, "Big send")); // absolute value matches the threshold + QVERIFY(!ContainsLabel(proxy, "Small")); + + proxy.setMinAmount(-1); // clearing restores every row + QCOMPARE(proxy.rowCount(), 3); +} + #ifdef BITCOINQML_NO_TEST_MAIN #include BITCOINQML_REGISTER_QT_TEST(ActivityFilterProxyModelTests) diff --git a/test/test_transaction.cpp b/test/test_transaction.cpp index b7f78092b6..aefb6cd5a6 100644 --- a/test/test_transaction.cpp +++ b/test/test_transaction.cpp @@ -67,6 +67,7 @@ private Q_SLOTS: void fromWalletTx_hidesSenderChangeOutput(); void fromWalletTx_mixedDebitKeepsNegativeNetAmount(); void fromWalletTx_showsIncomingPaymentToChangeAddress(); + void dateTimeString_usedAddressRequestReadsReceived(); }; void TransactionTests::initTestCase() @@ -135,6 +136,24 @@ void TransactionTests::fromWalletTx_showsIncomingPaymentToChangeAddress() QCOMPARE(parts.at(0)->prettyAmount(), QStringLiteral("+5.00000000")); } +// A used-address request is a paid request, so its status reads "Received" +// rather than the "Pending receive" shown for a request still awaiting payment. +void TransactionTests::dateTimeString_usedAddressRequestReadsReceived() +{ + uint256 zero_hash; + + Transaction pending{zero_hash, /*time=*/500, Transaction::RecvWithAddress, + /*address=*/"addr", /*debit=*/0, /*credit=*/COIN_VALUE}; + pending.isPendingRequest = true; + QCOMPARE(pending.dateTimeString(), QStringLiteral("Pending receive")); + + Transaction paid{zero_hash, /*time=*/500, Transaction::RecvWithAddress, + /*address=*/"addr", /*debit=*/0, /*credit=*/COIN_VALUE}; + paid.isPendingRequest = true; + paid.isUsedAddressRequest = true; + QCOMPARE(paid.dateTimeString(), QStringLiteral("Received")); +} + #ifdef BITCOINQML_NO_TEST_MAIN #include BITCOINQML_REGISTER_QT_TEST(TransactionTests) diff --git a/test/test_walletqmlmodel.cpp b/test/test_walletqmlmodel.cpp index 990ebb4556..6047eba17a 100644 --- a/test/test_walletqmlmodel.cpp +++ b/test/test_walletqmlmodel.cpp @@ -105,6 +105,28 @@ std::unique_ptr MakeWalletModel(NiceMock*& wallet_ou return std::make_unique(std::move(wallet), node); } +// A minimal external incoming payment to `dest`, shaped so that +// Transaction::fromWalletTx decodes it into a single RecvWithAddress row. +interfaces::WalletTx ReceiveWalletTxFor(const CTxDestination& dest, CAmount amount) +{ + CMutableTransaction mtx; + mtx.version = 2; + mtx.vout.emplace_back(amount, GetScriptForDestination(dest)); + + interfaces::WalletTx wtx; + wtx.tx = MakeTransactionRef(mtx); + wtx.txin_is_mine = {false}; // an external sender: an incoming payment + wtx.txout_is_mine = {true}; + wtx.txout_address = {dest}; + wtx.txout_address_is_mine = {true}; + wtx.txout_is_change = {false}; + wtx.credit = amount; + wtx.debit = 0; + wtx.time = 500; + wtx.is_coinbase = false; + return wtx; +} + void SetValidRecipient(WalletQmlModel& model, const QString& address = VALID_MAINNET_ADDRESS, const bool subtract_fee_from_amount = false) @@ -151,6 +173,8 @@ class FakePasswordWallet : public StubWallet std::vector fill_psbt_sign_args; bool get_address_result{false}; std::string get_address_label; + std::string last_set_address_book_label; + int set_address_book_calls{0}; int sign_message_calls{0}; std::string last_signed_message; bool can_bump_transaction{true}; @@ -253,7 +277,12 @@ class FakePasswordWallet : public StubWallet return sign_message_fn(message, pkhash, signature); } bool isSpendable(const CTxDestination&) override { return false; } - bool setAddressBook(const CTxDestination&, const std::string&, const std::optional&) override { return true; } + bool setAddressBook(const CTxDestination&, const std::string& name, const std::optional&) override + { + last_set_address_book_label = name; + ++set_address_book_calls; + return true; + } bool delAddressBook(const CTxDestination&) override { return true; } bool getAddress(const CTxDestination&, std::string* name, wallet::AddressPurpose*) override { @@ -457,6 +486,13 @@ private Q_SLOTS: void availableReceiveAddressTypesHideUnavailableTaproot(); void receiveAddressTypeDefaultPersistsPerWallet(); void removeReceiveRequestRemovesPendingActivityRow(); + void editedReceiveRequestLabelShownInActivityRow(); + void editedRequestSyncsAddressBookLabel(); + void requestSaveLeavesUneditedAddressBookLabelAlone(); + void editedAddressBookLabelSyncsRequestLabel(); + void editedAddressBookLabelUpdatesHeldRequestObjects(); + void usedAddressReceiveRequestRowIsFlaggedAndUntracked(); + void paidPendingRequestBecomesUsedAddressRequestLive(); void prepareTransactionOnLockedWalletRequiresPassword(); void prepareTransactionWithPrivateKeysDisabledDoesNotRequirePassword(); void sendRecipientRejectsDustAmount(); @@ -1560,6 +1596,207 @@ void WalletQmlModelTests::removeReceiveRequestRemovesPendingActivityRow() QCOMPARE(model->activityListModel()->rowCount(), 0); } +void WalletQmlModelTests::editedReceiveRequestLabelShownInActivityRow() +{ + FakePasswordWallet* wallet{nullptr}; + auto model = MakeWalletModel(wallet); + + // The address book reports a different label for the request address. A + // pending request row must show the request's own label, not the address + // book label, so an edited label is not overwritten when Activity renders. + wallet->get_address_result = true; + wallet->get_address_label = "address book label"; + + model->currentPaymentRequest()->setLabel(QStringLiteral("Old label")); + QVERIFY(model->commitPaymentRequest()); + + ActivityListModel* activity = model->activityListModel(); + QCOMPARE(activity->rowCount(), 1); + const QModelIndex row = activity->index(0); + QVERIFY(activity->data(row, ActivityListModel::IsPendingRequestRole).toBool()); + QCOMPARE(activity->data(row, ActivityListModel::LabelRole).toString(), QStringLiteral("Old label")); + + // Editing the label commits an update for the same request id. + model->currentPaymentRequest()->setLabel(QStringLiteral("New label")); + QVERIFY(model->commitPaymentRequest()); + + QCOMPARE(activity->rowCount(), 1); + QCOMPARE(activity->data(activity->index(0), ActivityListModel::LabelRole).toString(), + QStringLiteral("New label")); +} + +void WalletQmlModelTests::editedRequestSyncsAddressBookLabel() +{ + FakePasswordWallet* wallet{nullptr}; + auto model = MakeWalletModel(wallet); + // getAddress must succeed for the label sync to reach setAddressBook. + wallet->get_address_result = true; + + // Creating the request labels its address. + model->currentPaymentRequest()->setLabel(QStringLiteral("Old label")); + QVERIFY(model->commitPaymentRequest()); + QCOMPARE(wallet->last_set_address_book_label, std::string{"Old label"}); + + // Editing the label writes the new label back to the address book, so the + // Addresses page reflects it instead of keeping the stale label. + const int calls_before = wallet->set_address_book_calls; + model->currentPaymentRequest()->setLabel(QStringLiteral("New label")); + QVERIFY(model->commitPaymentRequest()); + QVERIFY(wallet->set_address_book_calls > calls_before); + QCOMPARE(wallet->last_set_address_book_label, std::string{"New label"}); +} + +void WalletQmlModelTests::requestSaveLeavesUneditedAddressBookLabelAlone() +{ + FakePasswordWallet* wallet{nullptr}; + auto model = MakeWalletModel(wallet); + wallet->get_address_result = true; + // The address book already carries a label, e.g. set on the Addresses page. + wallet->get_address_label = "Book label"; + + // Saving a request whose label is empty must not clear the book label. + QVERIFY(model->commitPaymentRequest()); + QCOMPARE(wallet->set_address_book_calls, 0); + + // Saving with an unchanged label (e.g. an amount-only edit) must not + // rewrite the book label either. + model->currentPaymentRequest()->setLabel(QStringLiteral("Book label")); + QVERIFY(model->commitPaymentRequest()); + QCOMPARE(wallet->set_address_book_calls, 0); +} + +void WalletQmlModelTests::editedAddressBookLabelSyncsRequestLabel() +{ + FakePasswordWallet* wallet{nullptr}; + auto model = MakeWalletModel(wallet); + wallet->get_address_result = true; + + // Create a request; its address carries the request label. + model->currentPaymentRequest()->setLabel(QStringLiteral("Old label")); + QVERIFY(model->commitPaymentRequest()); + const QString address = model->currentPaymentRequest()->address(); + QVERIFY(!address.isEmpty()); + + ActivityListModel* activity = model->activityListModel(); + QCOMPARE(activity->data(activity->index(0), ActivityListModel::LabelRole).toString(), + QStringLiteral("Old label")); + + // Editing the label on the Addresses page must propagate to the matching + // request, so the request record and its Activity row do not diverge from + // the address book (the reverse of editedRequestSyncsAddressBookLabel). + QVERIFY(model->setAddressLabel(address, QStringLiteral("New label"))); + + QCOMPARE(activity->data(activity->index(0), ActivityListModel::LabelRole).toString(), + QStringLiteral("New label")); + const QVariantList matches = model->receiveRequests()->matchingEntriesForAddress(address); + QCOMPARE(matches.size(), 1); + QCOMPARE(matches.at(0).toMap().value(QStringLiteral("label")).toString(), + QStringLiteral("New label")); +} + +void WalletQmlModelTests::editedAddressBookLabelUpdatesHeldRequestObjects() +{ + FakePasswordWallet* wallet{nullptr}; + auto model = MakeWalletModel(wallet); + wallet->get_address_result = true; + + model->currentPaymentRequest()->setLabel(QStringLiteral("Old label")); + QVERIFY(model->commitPaymentRequest()); + const QString address = model->currentPaymentRequest()->address(); + const QString request_id = model->currentPaymentRequest()->id(); + QVERIFY(!address.isEmpty()); + QVERIFY(model->loadPaymentRequestDetail(request_id)); + + // A held-open detail page or locked editor keeps reading these objects, + // so an Addresses page label edit must refresh them too, not only the + // stored request and its Activity row. + QVERIFY(model->setAddressLabel(address, QStringLiteral("New label"))); + QCOMPARE(model->detailPaymentRequest()->label(), QStringLiteral("New label")); + QCOMPARE(model->currentPaymentRequest()->label(), QStringLiteral("New label")); + + // While the editor is mid-edit its unsaved draft must survive the sync; + // the detail object still follows. + model->currentPaymentRequest()->edit(); + model->currentPaymentRequest()->setLabel(QStringLiteral("Unsaved draft")); + QVERIFY(model->setAddressLabel(address, QStringLiteral("Renamed again"))); + QCOMPARE(model->currentPaymentRequest()->label(), QStringLiteral("Unsaved draft")); + QCOMPARE(model->detailPaymentRequest()->label(), QStringLiteral("Renamed again")); +} + +void WalletQmlModelTests::usedAddressReceiveRequestRowIsFlaggedAndUntracked() +{ + FakePasswordWallet* wallet{nullptr}; + auto model = MakeWalletModel(wallet); + ActivityListModel* activity = model->activityListModel(); + QCOMPARE(activity->rowCount(), 0); + + // An unused-address request is a normal pending row. + activity->addReceiveRequest(QStringLiteral("bcrt1qunused"), QStringLiteral("Unused"), + 1000, 100, QStringLiteral("1"), /*used_address=*/false); + QCOMPARE(activity->rowCount(), 1); + QVERIFY(activity->data(activity->index(0), ActivityListModel::IsPendingRequestRole).toBool()); + QVERIFY(!activity->data(activity->index(0), ActivityListModel::IsUsedAddressRequestRole).toBool()); + + // A used-address request is still a payment-request row but is flagged so the + // proxy can keep it out of every view except the Payment request filter. + activity->addReceiveRequest(QStringLiteral("bcrt1qused"), QStringLiteral("Used"), + 2000, 200, QStringLiteral("2"), /*used_address=*/true); + QCOMPARE(activity->rowCount(), 2); + const QModelIndex used_row = activity->index(0); // rows are pushed to the front + QVERIFY(activity->data(used_row, ActivityListModel::IsPendingRequestRole).toBool()); + QVERIFY(activity->data(used_row, ActivityListModel::IsUsedAddressRequestRole).toBool()); + QCOMPARE(activity->data(used_row, ActivityListModel::LabelRole).toString(), QStringLiteral("Used")); +} + +void WalletQmlModelTests::paidPendingRequestBecomesUsedAddressRequestLive() +{ + auto wallet = std::make_unique>(); + auto* wallet_ptr = wallet.get(); + + const CTxDestination dest{WitnessV0KeyHash{uint160{std::vector(20, 7)}}}; + const QString address = QString::fromStdString(EncodeDestination(dest)); + const interfaces::WalletTx received = ReceiveWalletTxFor(dest, 50 * COIN); + + std::vector callbacks; + ON_CALL(*wallet_ptr, getWalletTxs()).WillByDefault(Return(std::set{})); + ON_CALL(*wallet_ptr, getBalance()).WillByDefault(Return(10 * COIN)); + ON_CALL(*wallet_ptr, handleTransactionChanged(testing::_)).WillByDefault(Invoke([&](interfaces::Wallet::TransactionChangedFn fn) { + callbacks.push_back(std::move(fn)); + return std::unique_ptr{}; + })); + ON_CALL(*wallet_ptr, getWalletTx(testing::_)).WillByDefault(Return(received)); + ON_CALL(*wallet_ptr, tryGetTxStatus(testing::_, testing::_, testing::_, testing::_)).WillByDefault(Return(true)); + + WalletQmlModel model{std::move(wallet)}; + ActivityListModel* activity = model.activityListModel(); + + // A pending request for the address that is about to be paid. + activity->addReceiveRequest(address, QStringLiteral("req"), 0, 100, QStringLiteral("1")); + QCOMPARE(activity->rowCount(), 1); + QVERIFY(activity->data(activity->index(0), ActivityListModel::IsPendingRequestRole).toBool()); + QVERIFY(!activity->data(activity->index(0), ActivityListModel::IsUsedAddressRequestRole).toBool()); + + // The payment arrives while the wallet is open (no reload). + QVERIFY(!callbacks.empty()); + for (const auto& cb : callbacks) { + cb(received.tx->GetHash(), CT_NEW); + } + + // The request is kept as a used-address request (Payment request filter only) + // and the received transaction is added as its own row, matching the reloaded + // state instead of consuming the request in place. + QCOMPARE(activity->rowCount(), 2); + QVERIFY(!activity->data(activity->index(0), ActivityListModel::IsPendingRequestRole).toBool()); + QVERIFY(!activity->data(activity->index(0), ActivityListModel::IsUsedAddressRequestRole).toBool()); + QCOMPARE(activity->data(activity->index(0), ActivityListModel::TypeRole).toInt(), + static_cast(Transaction::RecvWithAddress)); + + const QModelIndex request_row = activity->index(1); + QVERIFY(activity->data(request_row, ActivityListModel::IsPendingRequestRole).toBool()); + QVERIFY(activity->data(request_row, ActivityListModel::IsUsedAddressRequestRole).toBool()); + QCOMPARE(activity->data(request_row, ActivityListModel::LabelRole).toString(), QStringLiteral("req")); +} + void WalletQmlModelTests::prepareTransactionOnLockedWalletRequiresPassword() { FakePasswordWallet* wallet{nullptr};