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