From 95ddf821a26d0257a6ac1ae0d7e1753179f4dddd Mon Sep 17 00:00:00 2001 From: epicleafies Date: Mon, 29 Jun 2026 12:27:14 -0400 Subject: [PATCH 1/7] qml: keep edited payment request labels in Activity Editing a saved payment request updated its label everywhere except the pending request row in Activity, which kept showing the old label. The Activity list refreshes a row's label on every read by looking the row's address up in the wallet address book. For a pending receive request that overwrote the label the request itself carries (set when the request is saved and updated when it is edited) with the older stored address book label, so an edited label was lost on the next render. Skip the address book lookup for pending requests so they keep their own label, mirroring how the status refresh already skips pending requests. --- qml/models/activitylistmodel.cpp | 6 +++++- test/test_walletqmlmodel.cpp | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/qml/models/activitylistmodel.cpp b/qml/models/activitylistmodel.cpp index 59461624d8..acb8fc82e6 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; } diff --git a/test/test_walletqmlmodel.cpp b/test/test_walletqmlmodel.cpp index 990ebb4556..7fbd0ecbd6 100644 --- a/test/test_walletqmlmodel.cpp +++ b/test/test_walletqmlmodel.cpp @@ -457,6 +457,7 @@ private Q_SLOTS: void availableReceiveAddressTypesHideUnavailableTaproot(); void receiveAddressTypeDefaultPersistsPerWallet(); void removeReceiveRequestRemovesPendingActivityRow(); + void editedReceiveRequestLabelShownInActivityRow(); void prepareTransactionOnLockedWalletRequiresPassword(); void prepareTransactionWithPrivateKeysDisabledDoesNotRequirePassword(); void sendRecipientRejectsDustAmount(); @@ -1560,6 +1561,35 @@ 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::prepareTransactionOnLockedWalletRequiresPassword() { FakePasswordWallet* wallet{nullptr}; From 0f1c3ac2a15f08824a37a597f84392c886ff0c07 Mon Sep 17 00:00:00 2001 From: epicleafies Date: Mon, 29 Jun 2026 12:27:22 -0400 Subject: [PATCH 2/7] qml: add Other type and Last month filters to Activity Qt Widgets distinguishes a dedicated Other transaction type and a Last month date preset in its transaction filters. The QML Activity filters folded Transaction::Other into Sent and had no Last month preset, so those rows could not be isolated. Classify Transaction::Other (and any unrecognized type) as its own Other filter rather than Sent, matching the label already used for CSV export, and add a Last month date bucket spanning the previous calendar month. Both are surfaced in the Activity type and date filter menus. The minimum amount and custom date range filters Qt Widgets also offers are left for a follow-up, as they need filter input controls that the Activity design does not yet define. --- qml/models/activityfilterproxymodel.cpp | 7 ++++++- qml/models/activityfilterproxymodel.h | 2 ++ qml/pages/wallet/Activity.qml | 6 ++++++ test/qml/qml_tests_main.cpp | 2 ++ test/test_activityfilterproxymodel.cpp | 20 +++++++++++++++----- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/qml/models/activityfilterproxymodel.cpp b/qml/models/activityfilterproxymodel.cpp index 2b25805770..9fd7402f65 100644 --- a/qml/models/activityfilterproxymodel.cpp +++ b/qml/models/activityfilterproxymodel.cpp @@ -214,9 +214,10 @@ ActivityFilterProxyModel::TypeFilter ActivityFilterProxyModel::filterTypeForInde return Mined; case Transaction::SendToAddress: case Transaction::SendToOther: + return Sent; case Transaction::Other: default: - return Sent; + return Other; } } @@ -264,6 +265,10 @@ 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); diff --git a/qml/models/activityfilterproxymodel.h b/qml/models/activityfilterproxymodel.h index e3009ef86f..ebf4e3087d 100644 --- a/qml/models/activityfilterproxymodel.h +++ b/qml/models/activityfilterproxymodel.h @@ -26,6 +26,7 @@ class ActivityFilterProxyModel : public QSortFilterProxyModel Today, ThisWeek, ThisMonth, + LastMonth, ThisYear }; Q_ENUM(DateFilter) @@ -36,6 +37,7 @@ class ActivityFilterProxyModel : public QSortFilterProxyModel Sent, SentToSelf, Mined, + Other, PaymentRequest }; Q_ENUM(TypeFilter) diff --git a/qml/pages/wallet/Activity.qml b/qml/pages/wallet/Activity.qml index 172dc4a481..210a5353e2 100644 --- a/qml/pages/wallet/Activity.qml +++ b/qml/pages/wallet/Activity.qml @@ -108,6 +108,8 @@ PageStack { return qsTr("This week") case ActivityFilterProxyModel.ThisMonth: return qsTr("This month") + case ActivityFilterProxyModel.LastMonth: + return qsTr("Last month") case ActivityFilterProxyModel.ThisYear: return qsTr("This year") default: @@ -125,6 +127,8 @@ PageStack { return qsTr("Sent to yourself") case ActivityFilterProxyModel.Mined: return qsTr("Mined") + case ActivityFilterProxyModel.Other: + return qsTr("Other") case ActivityFilterProxyModel.PaymentRequest: return qsTr("Payment request") default: @@ -641,6 +645,7 @@ PageStack { { 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("Last month"), value: ActivityFilterProxyModel.LastMonth, objectName: "activityDateLastMonth" }, { text: qsTr("This year"), value: ActivityFilterProxyModel.ThisYear, objectName: "activityDateThisYear" } ] onActivated: function(value) { @@ -668,6 +673,7 @@ 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" }, + { text: qsTr("Other"), value: ActivityFilterProxyModel.Other, objectName: "activityTypeOther" }, { text: qsTr("Payment request"), value: ActivityFilterProxyModel.PaymentRequest, objectName: "activityTypePaymentRequest" } ] onActivated: function(value) { diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index 0fef97273c..1177b70e1b 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -2891,6 +2891,7 @@ class MockActivityFilterProxyModel : public QSortFilterProxyModel Today, ThisWeek, ThisMonth, + LastMonth, ThisYear }; Q_ENUM(DateFilter) @@ -2901,6 +2902,7 @@ class MockActivityFilterProxyModel : public QSortFilterProxyModel Sent, SentToSelf, Mined, + Other, PaymentRequest }; Q_ENUM(TypeFilter) diff --git a/test/test_activityfilterproxymodel.cpp b/test/test_activityfilterproxymodel.cpp index 677af6a48f..10b7bcd957 100644 --- a/test/test_activityfilterproxymodel.cpp +++ b/test/test_activityfilterproxymodel.cpp @@ -175,6 +175,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 +190,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 +215,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 +251,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); From 7daae41acce2093cbb22037acd894e6fdd48f290 Mon Sep 17 00:00:00 2001 From: epicleafies Date: Mon, 29 Jun 2026 14:56:25 -0400 Subject: [PATCH 3/7] qml: add minimum amount and custom date range Activity filters Qt Widgets offers a minimum amount filter and a custom date range in its transaction filters; the QML Activity filters had neither. Add a minimum amount filter: a new Amount button opens a popup with an amount field in the active display unit plus Apply and Reset. The button and the field show the unit the same way as the rest of the app (the BitcoinAmount unit label: a bitcoin sign or sat/sats). The proxy filters on absolute satoshi value, so a send and a receive of the same size both pass a threshold, matching the Qt Widgets behaviour. Add a custom date range: the date menu gains a Custom range entry that switches the popup to a calendar. Qt has no stock date picker, so the day grid is built from ItemDelegate cells in a Grid positioner (never a Repeater inside a Quick Layout, which would hit a Qt 6.4 use-after-free). The first click sets the start and the next sets the end (reordered if earlier); the range then locks until Reset clears it. Endpoints, the days between, and today are marked, with today carrying weight as well as a ring so the state is not conveyed by color alone. Month and year steppers use caret icons from the Bitcoin Icons set (the set has no double-caret, so the year jump composes two carets); the week starts on the locale's first day. The day cells, steppers, and the presets link carry Accessible names and roles. The proxy gains a CustomRange bucket whose bounds include both endpoints. Moving dates across the QML/C++ boundary needs care in both directions because a JavaScript Date and a QDate disagree on the day across time zones: the range is applied through an ISO-string invokable rather than by assigning the QDate properties from QML, and when the popup reopens on an applied range the QDate read back (a UTC-midnight JavaScript Date) is rebuilt at local midnight before it reseeds the calendar, so a reopen does not shift the applied range west of UTC. With a fourth control added, the search field moves to its own row and the date, type, and amount buttons sit in a row beneath it, so they no longer compete with the search field for one row's width. Both apply to the visible list and the CSV export and clear with the existing reset path. The new minAmount, rangeStart and rangeEnd proxy properties are covered by C++ unit tests, the apply paths by QML tests driving the amount field and the calendar (including a reopen round-trip that guards the timezone reseed), and the whole flow by the Activity filter functional test; the filtered-empty hint now mentions the amount filter. --- qml/bitcoin_qml.qrc | 1 + qml/controls/ActivityCalendar.qml | 532 ++++++++++++++++++ qml/models/activityfilterproxymodel.cpp | 94 ++++ qml/models/activityfilterproxymodel.h | 28 +- qml/pages/wallet/Activity.qml | 392 ++++++++++++- .../qml_test_activity_filter_export.py | 77 ++- test/qml/qml_tests_main.cpp | 69 ++- test/qml/tst_activity.qml | 190 ++++++- test/test_activityfilterproxymodel.cpp | 55 ++ 9 files changed, 1404 insertions(+), 34 deletions(-) create mode 100644 qml/controls/ActivityCalendar.qml 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 9fd7402f65..ffbb1b922e 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(); @@ -176,6 +250,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(); @@ -273,6 +356,17 @@ bool ActivityFilterProxyModel::dateMatches(qint64 timestamp) const 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 ebf4e3087d..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: @@ -27,7 +33,8 @@ class ActivityFilterProxyModel : public QSortFilterProxyModel ThisWeek, ThisMonth, LastMonth, - ThisYear + ThisYear, + CustomRange }; Q_ENUM(DateFilter) @@ -59,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; @@ -68,6 +89,8 @@ class ActivityFilterProxyModel : public QSortFilterProxyModel void dateFilterChanged(); void typeFilterChanged(); void displayUnitChanged(); + void minAmountChanged(); + void rangeChanged(); void countChanged(); protected: @@ -84,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/pages/wallet/Activity.qml b/qml/pages/wallet/Activity.qml index 210a5353e2..c890e5d626 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() { @@ -109,14 +113,91 @@ PageStack { 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: @@ -128,6 +209,7 @@ PageStack { 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") @@ -167,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 "" } @@ -191,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 @@ -267,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 @@ -281,7 +373,6 @@ PageStack { id: searchField objectName: "activitySearchField" Layout.fillWidth: true - Layout.minimumWidth: 160 implicitHeight: 37 leftPadding: 15 rightPadding: clearSearchButton.visible ? 36 : 10 @@ -315,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 } } } } @@ -632,34 +740,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("Last month"), value: ActivityFilterProxyModel.LastMonth, objectName: "activityDateLastMonth" }, - { 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 @@ -673,6 +906,7 @@ 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" } ] @@ -682,6 +916,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/test/functional/qml_test_activity_filter_export.py b/test/functional/qml_test_activity_filter_export.py index 922f72c3c2..4d5a21e5f4 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,71 @@ 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) + txid, expected_amount = _activity_transaction_row(gui) gui.invoke("activityStack", "navigateToTransaction", [txid]) gui.wait_for_page("activityDetailsPage", timeout_ms=10000) diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index 1177b70e1b..697fdab503 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -2883,6 +2883,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: @@ -2892,7 +2895,8 @@ class MockActivityFilterProxyModel : public QSortFilterProxyModel ThisWeek, ThisMonth, LastMonth, - ThisYear + ThisYear, + CustomRange }; Q_ENUM(DateFilter) @@ -2993,6 +2997,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 @@ -3016,6 +3078,8 @@ class MockActivityFilterProxyModel : public QSortFilterProxyModel void dateFilterChanged(); void typeFilterChanged(); void displayUnitChanged(); + void minAmountChanged(); + void rangeChanged(); void countChanged(); private: @@ -3023,6 +3087,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..c234262249 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 { @@ -31,6 +33,12 @@ TestCase { } } + Component { + id: activityCalendarComponent + + ActivityCalendar {} + } + Component { id: activityDetailsComponent @@ -104,7 +112,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 +191,184 @@ 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_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/test_activityfilterproxymodel.cpp b/test/test_activityfilterproxymodel.cpp index 10b7bcd957..2456d8dbf3 100644 --- a/test/test_activityfilterproxymodel.cpp +++ b/test/test_activityfilterproxymodel.cpp @@ -136,7 +136,9 @@ class ActivityFilterProxyModelTests : public QObject private Q_SLOTS: void searchMatchesLabelAddressAndTxid(); void filtersByDateBuckets(); + void filtersByCustomDateRange(); void filtersByTypeBucketsAndKeepsPendingRequestsExclusive(); + void filtersByMinimumAmount(); void sortsByTimestampDescending(); void exportsCurrentFilteredRowsToCsv(); void exportsCsvUsingDisplayUnit(); @@ -381,6 +383,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) From ac55de251811f189451821262e6e066aeb74e524 Mon Sep 17 00:00:00 2001 From: epicleafies Date: Sun, 5 Jul 2026 09:27:56 -0400 Subject: [PATCH 4/7] qml: surface used-address requests in the Payment request filter A saved receive request whose address already had a real transaction was skipped by ActivityListModel::addPendingReceiveRequests(), so it never became an Activity row. The Payment request filter could therefore not surface it, even though the transaction details for that address already list the matching requests. Materialize those used-address requests as rows as well, flagged with a new isUsedAddressRequest marker. The proxy shows a used-address request only when the Payment request filter is selected, and hides it in every other view so it does not duplicate the address's real transaction row. Apply the same rule live: when a payment arrives for a pending request, keep the request as a used-address request and add the received transaction as its own row, rather than consuming the request in place. Without this, a fulfilled request dropped out of the Payment request filter until the wallet was reloaded; now the filter is consistent whether the payment just arrived or the wallet was restarted. Used-address requests are not tracked for pending-request fulfillment, since they have no unfulfilled payment left to wait for. The visibility rule is covered by a proxy unit test (hidden by default and under other type filters, shown under the Payment request filter), the used-address flag by a WalletQmlModel unit test, and the live fulfillment path by a WalletQmlModel test that drives a received transaction and asserts the request stays a used-address request while the transaction appears as its own row. --- qml/models/activityfilterproxymodel.cpp | 8 ++ qml/models/activitylistmodel.cpp | 49 ++++++---- qml/models/activitylistmodel.h | 9 +- qml/models/transaction.cpp | 1 + qml/models/transaction.h | 4 + qml/pages/wallet/Activity.qml | 2 + .../wallet/ActivityTransactionVisuals.qml | 16 ++- .../qml_test_activity_filter_export.py | 48 +++++++++ test/mocks/mockwallet.h | 2 + test/qml/qml_tests_main.cpp | 26 ++++- test/qml/tst_activity.qml | 43 ++++++++ test/test_activityfilterproxymodel.cpp | 43 ++++++++ test/test_transaction.cpp | 19 ++++ test/test_walletqmlmodel.cpp | 98 +++++++++++++++++++ 14 files changed, 339 insertions(+), 29 deletions(-) diff --git a/qml/models/activityfilterproxymodel.cpp b/qml/models/activityfilterproxymodel.cpp index ffbb1b922e..2ea7efc52a 100644 --- a/qml/models/activityfilterproxymodel.cpp +++ b/qml/models/activityfilterproxymodel.cpp @@ -241,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; } diff --git a/qml/models/activitylistmodel.cpp b/qml/models/activitylistmodel.cpp index acb8fc82e6..001b78007f 100644 --- a/qml/models/activitylistmodel.cpp +++ b/qml/models/activitylistmodel.cpp @@ -101,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(); } @@ -124,6 +126,7 @@ QHash ActivityListModel::roleNames() const roles[IsPendingRequestRole] = "isPendingRequest"; roles[RequestIdRole] = "requestId"; roles[NetAmountSatRole] = "netAmountSat"; + roles[IsUsedAddressRequestRole] = "isUsedAddressRequest"; return roles; } @@ -209,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(); @@ -217,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, @@ -230,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(); } @@ -333,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/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/pages/wallet/Activity.qml b/qml/pages/wallet/Activity.qml index c890e5d626..89a5be2299 100644 --- a/qml/pages/wallet/Activity.qml +++ b/qml/pages/wallet/Activity.qml @@ -552,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 { @@ -584,6 +585,7 @@ PageStack { transactionType: delegate.type transactionStatus: delegate.status isPendingRequest: delegate.isPendingRequest + isUsedAddressRequest: delegate.isUsedAddressRequest } contentItem: RowLayout { 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/test/functional/qml_test_activity_filter_export.py b/test/functional/qml_test_activity_filter_export.py index 4d5a21e5f4..373b744afc 100644 --- a/test/functional/qml_test_activity_filter_export.py +++ b/test/functional/qml_test_activity_filter_export.py @@ -345,6 +345,54 @@ def run_test(save_screenshots=False, screenshot_root=None): 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/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 697fdab503..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 diff --git a/test/qml/tst_activity.qml b/test/qml/tst_activity.qml index c234262249..1f26d73000 100644 --- a/test/qml/tst_activity.qml +++ b/test/qml/tst_activity.qml @@ -19,6 +19,7 @@ TestCase { testWalletModel.lastLoadedPaymentRequestId = "" testWalletModel.lastLoadedPaymentRequestDetailId = "" testActivityListModel.setCountForTest(2) + testActivityListModel.setUsedAddressRequestRowForTest(-1) nodeModel.setBlockSyncActiveForTest(false) nodeModel.verificationProgress = 1.0 walletController.openReceiveRequests = 0 @@ -39,6 +40,12 @@ TestCase { ActivityCalendar {} } + Component { + id: transactionVisualsComponent + + ActivityTransactionVisuals {} + } + Component { id: activityDetailsComponent @@ -346,6 +353,42 @@ TestCase { 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) diff --git a/test/test_activityfilterproxymodel.cpp b/test/test_activityfilterproxymodel.cpp index 2456d8dbf3..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"}, }; } @@ -138,6 +142,7 @@ private Q_SLOTS: void filtersByDateBuckets(); void filtersByCustomDateRange(); void filtersByTypeBucketsAndKeepsPendingRequestsExclusive(); + void usedAddressRequestsOnlyVisibleUnderPaymentRequestFilter(); void filtersByMinimumAmount(); void sortsByTimestampDescending(); void exportsCurrentFilteredRowsToCsv(); @@ -273,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; 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 7fbd0ecbd6..6d2d1f15f8 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) @@ -458,6 +480,8 @@ private Q_SLOTS: void receiveAddressTypeDefaultPersistsPerWallet(); void removeReceiveRequestRemovesPendingActivityRow(); void editedReceiveRequestLabelShownInActivityRow(); + void usedAddressReceiveRequestRowIsFlaggedAndUntracked(); + void paidPendingRequestBecomesUsedAddressRequestLive(); void prepareTransactionOnLockedWalletRequiresPassword(); void prepareTransactionWithPrivateKeysDisabledDoesNotRequirePassword(); void sendRecipientRejectsDustAmount(); @@ -1590,6 +1614,80 @@ void WalletQmlModelTests::editedReceiveRequestLabelShownInActivityRow() QStringLiteral("New label")); } +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}; From 8edf3a8f78a70a7897875266ecbfbe2fee71d502 Mon Sep 17 00:00:00 2001 From: epicleafies Date: Sun, 5 Jul 2026 10:41:03 -0400 Subject: [PATCH 5/7] qml: sync the address book label with an edited payment request Editing a payment request updated the request record and the Activity row, but not the address's address book label, so the Addresses page kept showing the label the address was created with. Creating a request already labels its address (getNewDestination writes the address book), so the two only diverged on an edit. Write the request label back to the address book whenever a request is saved, so the Addresses page and any other address-book reader reflect an edited request rather than a stale label. --- qml/models/walletqmlmodel.cpp | 11 ++++++++ test/test_walletqmlmodel.cpp | 51 ++++++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/qml/models/walletqmlmodel.cpp b/qml/models/walletqmlmodel.cpp index 82b8c0ae90..28a332b505 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()) { diff --git a/test/test_walletqmlmodel.cpp b/test/test_walletqmlmodel.cpp index 6d2d1f15f8..eb0843fcb9 100644 --- a/test/test_walletqmlmodel.cpp +++ b/test/test_walletqmlmodel.cpp @@ -173,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}; @@ -275,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 { @@ -480,6 +487,8 @@ private Q_SLOTS: void receiveAddressTypeDefaultPersistsPerWallet(); void removeReceiveRequestRemovesPendingActivityRow(); void editedReceiveRequestLabelShownInActivityRow(); + void editedRequestSyncsAddressBookLabel(); + void requestSaveLeavesUneditedAddressBookLabelAlone(); void usedAddressReceiveRequestRowIsFlaggedAndUntracked(); void paidPendingRequestBecomesUsedAddressRequestLive(); void prepareTransactionOnLockedWalletRequiresPassword(); @@ -1614,6 +1623,46 @@ void WalletQmlModelTests::editedReceiveRequestLabelShownInActivityRow() 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::usedAddressReceiveRequestRowIsFlaggedAndUntracked() { FakePasswordWallet* wallet{nullptr}; From f9611f16813eb5ef2cc13c2951631e3ea98330fb Mon Sep 17 00:00:00 2001 From: epicleafies Date: Fri, 31 Jul 2026 12:32:50 -0400 Subject: [PATCH 6/7] qml: sync a payment request label with an edited address book label The previous commit made request label edits flow to the address book, but the reverse still diverged: editing a label on the Addresses page wrote only the address book, leaving the payment request and its Activity row showing the old label. Propagate the edit the other way too. When setAddressLabel writes an address book label, update any payment request saved for that address to match: rewrite the stored request, its receive-request history entry, and its Activity row. setAddressLabel is the shared path both the Addresses page and the request-save sync go through, so both edits now converge on one label. The guard skips a request already carrying the new label, so the request-save sync (which calls setAddressLabel) does not redundantly rewrite what it just stored. Covered by a WalletQmlModel unit test that edits an address label and asserts the request record and Activity row follow, and a functional test that renames a request's address on the Addresses page and checks both the address row and the Activity row show the new label. --- .github/workflows/gui-functional-tests.yml | 1 + qml/models/receiverequesthistorymodel.cpp | 12 +++ qml/models/receiverequesthistorymodel.h | 4 + qml/models/walletqmlmodel.cpp | 40 ++++++- qml/models/walletqmlmodel.h | 3 + .../functional/qml_test_address_label_sync.py | 102 ++++++++++++++++++ test/test_walletqmlmodel.cpp | 30 ++++++ 7 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 test/functional/qml_test_address_label_sync.py 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/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/walletqmlmodel.cpp b/qml/models/walletqmlmodel.cpp index 28a332b505..5fb9f5a0d3 100644 --- a/qml/models/walletqmlmodel.cpp +++ b/qml/models/walletqmlmodel.cpp @@ -1214,7 +1214,45 @@ 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); + } + } } 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/test/functional/qml_test_address_label_sync.py b/test/functional/qml_test_address_label_sync.py new file mode 100644 index 0000000000..73a590ffa7 --- /dev/null +++ b/test/functional/qml_test_address_label_sync.py @@ -0,0 +1,102 @@ +#!/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}" + ) + + 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/test_walletqmlmodel.cpp b/test/test_walletqmlmodel.cpp index eb0843fcb9..b3c30ef9ba 100644 --- a/test/test_walletqmlmodel.cpp +++ b/test/test_walletqmlmodel.cpp @@ -489,6 +489,7 @@ private Q_SLOTS: void editedReceiveRequestLabelShownInActivityRow(); void editedRequestSyncsAddressBookLabel(); void requestSaveLeavesUneditedAddressBookLabelAlone(); + void editedAddressBookLabelSyncsRequestLabel(); void usedAddressReceiveRequestRowIsFlaggedAndUntracked(); void paidPendingRequestBecomesUsedAddressRequestLive(); void prepareTransactionOnLockedWalletRequiresPassword(); @@ -1663,6 +1664,35 @@ void WalletQmlModelTests::requestSaveLeavesUneditedAddressBookLabelAlone() 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::usedAddressReceiveRequestRowIsFlaggedAndUntracked() { FakePasswordWallet* wallet{nullptr}; From cb1ada278bcd444f0b4de72b131a3b3e9621018e Mon Sep 17 00:00:00 2001 From: epicleafies Date: Fri, 31 Jul 2026 15:35:45 -0400 Subject: [PATCH 7/7] qml: keep held-open request views in step with label sync Editing an address label on the Addresses page rewrites the stored payment request, its history entry, and its Activity row, but a request already open in the Activity detail page or in the locked Receive editor kept showing the old label until it was closed and reopened. The wallet tabs live in a StackLayout that keeps every page alive, so those views hold the shared detailPaymentRequest and currentPaymentRequest objects that were filled when the view loaded, and the reverse sync never touched them. Refresh the held objects from syncPaymentRequestLabelToAddress. The editor object is skipped while a request is being edited so the sync cannot clobber an unsaved draft; the label the user saves wins in that case. The detail page follows automatically through its label bindings. The editor name field does not: any assignment to a TextField text property (typing, or the isEditing re-sync handler) replaces its declarative binding, so the field re-syncs from the labelChanged signal instead, again only while the form is locked. Covered by a unit test asserting both held objects follow an address label edit and that a draft survives one, a QML test asserting the locked editor field follows an external label change, and a functional assertion that the held-open editor shows the edited label after the address is renamed on the Addresses page. --- qml/models/walletqmlmodel.cpp | 10 +++++++ qml/pages/wallet/RequestPayment.qml | 9 ++++++ .../functional/qml_test_address_label_sync.py | 10 +++++++ test/qml/tst_requestpayment.qml | 27 +++++++++++++++++ test/test_walletqmlmodel.cpp | 30 +++++++++++++++++++ 5 files changed, 86 insertions(+) diff --git a/qml/models/walletqmlmodel.cpp b/qml/models/walletqmlmodel.cpp index 5fb9f5a0d3..469e0d01e7 100644 --- a/qml/models/walletqmlmodel.cpp +++ b/qml/models/walletqmlmodel.cpp @@ -1252,6 +1252,16 @@ void WalletQmlModel::syncPaymentRequestLabelToAddress(const QString& address, co 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); + } } } 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_address_label_sync.py b/test/functional/qml_test_address_label_sync.py index 73a590ffa7..c8423b8f3b 100644 --- a/test/functional/qml_test_address_label_sync.py +++ b/test/functional/qml_test_address_label_sync.py @@ -83,6 +83,16 @@ def run_test(): 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 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_walletqmlmodel.cpp b/test/test_walletqmlmodel.cpp index b3c30ef9ba..6047eba17a 100644 --- a/test/test_walletqmlmodel.cpp +++ b/test/test_walletqmlmodel.cpp @@ -490,6 +490,7 @@ private Q_SLOTS: void editedRequestSyncsAddressBookLabel(); void requestSaveLeavesUneditedAddressBookLabelAlone(); void editedAddressBookLabelSyncsRequestLabel(); + void editedAddressBookLabelUpdatesHeldRequestObjects(); void usedAddressReceiveRequestRowIsFlaggedAndUntracked(); void paidPendingRequestBecomesUsedAddressRequestLive(); void prepareTransactionOnLockedWalletRequiresPassword(); @@ -1693,6 +1694,35 @@ void WalletQmlModelTests::editedAddressBookLabelSyncsRequestLabel() 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};