From d7ef1008ac8c38fe36e063a53204226b1e5d0548 Mon Sep 17 00:00:00 2001 From: Roberto Viola Date: Fri, 26 Jun 2026 12:45:10 +0200 Subject: [PATCH 1/5] Fix tile drag: auto-scroll GridView when dragging to screen edges - Home.qml: Added autoScrollTimer (50ms interval) that scrolls the GridView when dragging a tile near the top or bottom viewport edge, with speed proportional to proximity to the edge. - Home.qml: Added indexAtMouse() helper that accounts for contentY offset when computing which tile is under the cursor during drag, fixing incorrect target index when the grid has scrolled. - Home.qml: Fixed active tile visual position to include gridView.contentY so the dragged tile stays visually anchored to the cursor while scrolling. - Home.qml: Track startIndex and tileName at long-press time to correctly identify source tile and compare against drop position at release. - homeform.cpp: Fixed moveTile() edge case where inserting a tile at the last position never wrote its order setting because i only reached newIndex after the loop ended. Co-Authored-By: Claude Sonnet 4.6 --- src/Home.qml | 86 +++++++++++++++++++++++++++++++++++++++++------- src/homeform.cpp | 7 ++-- 2 files changed, 79 insertions(+), 14 deletions(-) diff --git a/src/Home.qml b/src/Home.qml index 18d64627f3..6906ec0abb 100644 --- a/src/Home.qml +++ b/src/Home.qml @@ -225,7 +225,7 @@ HomeForm { states: State { name: "active"; when: loc.currentId === gridId && window.lockTiles - PropertyChanges { target: id1; x: loc.mouseX - gridView.x - width/2; y: loc.mouseY - gridView.y - height/2; scale: 0.5; z: 10 } + PropertyChanges { target: id1; x: loc.mouseX - gridView.x - width/2; y: loc.mouseY - gridView.y - height/2 + gridView.contentY; scale: 0.5; z: 10 } } transitions: Transition { NumberAnimation { property: "scale"; duration: 200} } @@ -513,26 +513,88 @@ HomeForm { } } + Timer { + id: autoScrollTimer + interval: 50 + repeat: true + running: false + property real scrollSpeed: 15 + onTriggered: { + if (loc.currentId === -1) { running = false; return; } + var edgeZone = 80 + if (loc.mouseY > gridView.height - edgeZone) { + var factor = (loc.mouseY - (gridView.height - edgeZone)) / edgeZone + gridView.contentY = Math.min( + gridView.contentHeight - gridView.height, + gridView.contentY + scrollSpeed * (1 + factor * 2) + ) + } else if (loc.mouseY < edgeZone) { + var factor2 = (edgeZone - loc.mouseY) / edgeZone + gridView.contentY = Math.max(0, gridView.contentY - scrollSpeed * (1 + factor2 * 2)) + } else { + running = false + } + } + } + MouseArea { - property int currentId: -1 // Original position in model - property int newIndex // Current Position in model - property int index: (Math.floor(gridView.width / gridView.cellWidth) * Math.floor(mouseY / gridView.cellHeight)) + Math.floor(mouseX / gridView.cellWidth) // gridView.indexAt(mouseX - gridView.x, mouseY - gridView.y) // Item underneath cursor + property int currentId: -1 + property int newIndex + property int startIndex: -1 + property string tileName: "" + + function indexAtMouse(mx, my) { + var cols = Math.max(1, Math.floor(gridView.width / gridView.cellWidth)) + var adjustedY = my + gridView.contentY + var col = Math.floor(mx / gridView.cellWidth) + var row = Math.floor(adjustedY / gridView.cellHeight) + var idx = row * cols + col + if (idx < 0 || idx >= appModel.count) return -1 + return idx + } id: loc enabled: window.lockTiles anchors.fill: parent - onPressAndHold: { console.log("onPressAndHold " + index); if(index !== -1) currentId = appModel[newIndex = index].gridId; else currentId = -1; } + + onPressAndHold: { + var idx = indexAtMouse(mouseX, mouseY) + console.log("onPressAndHold " + idx) + if (idx !== -1) { + startIndex = idx + newIndex = idx + currentId = appModel[idx].gridId + tileName = appModel[idx].name + } else { + currentId = -1 + tileName = "" + } + } + onReleased: { - console.log("onReleased " + currentId + " " + index ); - if (currentId !== -1 && index !== -1 && index !== newIndex) { - rootItem.moveTile(appModel[currentId].name, index, newIndex); - } currentId = -1 + autoScrollTimer.running = false + var idx = indexAtMouse(mouseX, mouseY) + console.log("onReleased tileName=" + tileName + " idx=" + idx + " startIndex=" + startIndex) + if (currentId !== -1 && idx !== -1 && idx !== startIndex) { + rootItem.moveTile(tileName, idx, startIndex) + } + currentId = -1 + startIndex = -1 + tileName = "" } onPositionChanged: { - console.log("onPositionChanged " + currentId + " " + index + " " + newIndex + " " + mouseX + " " + mouseY) - if (currentId !== -1 && index !== -1 && index !== newIndex) { - //appModel.move(newIndex, newIndex = index) + if (currentId === -1) return + var edgeZone = 80 + if (mouseY > gridView.height - edgeZone || mouseY < edgeZone) + autoScrollTimer.running = true + else + autoScrollTimer.running = false + + var idx = indexAtMouse(mouseX, mouseY) + console.log("onPositionChanged " + currentId + " " + idx + " " + newIndex + " " + mouseX + " " + mouseY) + if (idx !== -1 && idx !== newIndex) { + newIndex = idx } } } diff --git a/src/homeform.cpp b/src/homeform.cpp index dcdb32f208..0989e4f0d0 100644 --- a/src/homeform.cpp +++ b/src/homeform.cpp @@ -507,7 +507,7 @@ homeform::homeform(QQmlApplicationEngine *engine, bluetooth *bl) { true, QStringLiteral("peloton_remaining"), valueElapsedFontSize, labelFontSize); strokesCount = new DataObject(tr("Strokes Count"), QStringLiteral("icons/icons/cadence.png"), QStringLiteral("0"), false, QStringLiteral("strokes_count"), 48, labelFontSize); - strokesLength = new DataObject(tr("Stroke Length"), QStringLiteral("icons/icons/cadence.png"), + strokesLength = new DataObject(tr("Strokes Length"), QStringLiteral("icons/icons/cadence.png"), QStringLiteral("0"), false, QStringLiteral("strokes_length"), 48, labelFontSize); gears = new DataObject(tr("Gears"), QStringLiteral("icons/icons/elevationgain.png"), QStringLiteral("0"), true, QStringLiteral("gears"), 48, labelFontSize); @@ -4368,6 +4368,9 @@ void homeform::moveTile(QString name, int newIndex, int oldIndex) { i++; } } + if (i <= newIndex) { + settings.setValue("tile_" + current->m_id.toLower() + "_order", newIndex); + } foreach (QString s, settings.allKeys()) { if (s.contains(QStringLiteral("tile_")) && s.contains(QStringLiteral("_order"))) { @@ -10222,7 +10225,7 @@ void homeform::sendMail() { ((rower *)bluetoothManager->device())->maxPace().toString(QStringLiteral("m:ss")) + QStringLiteral("\n"); textMessage += - QStringLiteral("Average Stroke Length: ") + + QStringLiteral("Average Strokes Length: ") + QString::number(((rower *)bluetoothManager->device())->currentStrokesLength().average(), 'f', 1) + "\n"; } else if (bluetoothManager->device()->deviceType() == TREADMILL || bluetoothManager->device()->deviceType() == ELLIPTICAL) { textMessage += QStringLiteral("Average Pace: ") + From b75e418f59871d485f162b3bad64f7652ea327b1 Mon Sep 17 00:00:00 2001 From: Roberto Viola Date: Fri, 26 Jun 2026 13:56:33 +0200 Subject: [PATCH 2/5] Fix tile drag: use ghost tile instead of repositioning delegate Moving the dragged tile's y position within GridView's contentItem coordinate system confused the GridView's virtualization, causing other tiles to disappear during drag. Fix by: - Keeping the source tile in place (just dimming it to opacity 0.3) - Showing a separate ghost Item outside GridView that follows the mouse Co-Authored-By: Claude Sonnet 4.6 --- src/Home.qml | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Home.qml b/src/Home.qml index 6906ec0abb..bed272b581 100644 --- a/src/Home.qml +++ b/src/Home.qml @@ -225,10 +225,10 @@ HomeForm { states: State { name: "active"; when: loc.currentId === gridId && window.lockTiles - PropertyChanges { target: id1; x: loc.mouseX - gridView.x - width/2; y: loc.mouseY - gridView.y - height/2 + gridView.contentY; scale: 0.5; z: 10 } + PropertyChanges { target: id1; opacity: 0.3 } } - transitions: Transition { NumberAnimation { property: "scale"; duration: 200} } + transitions: Transition { NumberAnimation { property: "opacity"; duration: 200} } Rectangle { width: 168 * settings.ui_zoom / 100 @@ -513,6 +513,36 @@ HomeForm { } } + Item { + id: ghostTile + visible: loc.currentId !== -1 && window.lockTiles + x: loc.mouseX - width / 2 + y: loc.mouseY - height / 2 + width: 85 * settings.ui_zoom / 100 + height: 63 * settings.ui_zoom / 100 + z: 200 + + Rectangle { + anchors.fill: parent + radius: 3 + color: settings.theme_tile_background_color + opacity: 0.9 + border.width: 2 + border.color: settings.theme_tile_shadow_color + + Text { + anchors.centerIn: parent + color: "white" + text: loc.tileName + font.pointSize: 10 * settings.ui_zoom / 100 + font.bold: true + horizontalAlignment: Text.AlignHCenter + width: parent.width - 8 + wrapMode: Text.WordWrap + } + } + } + Timer { id: autoScrollTimer interval: 50 From 1cacdd47745f214fae29ba89bfe759052f6300e3 Mon Sep 17 00:00:00 2001 From: Roberto Viola Date: Fri, 26 Jun 2026 16:43:19 +0200 Subject: [PATCH 3/5] Remove Behavior on x/y from tile delegate to fix disappearing tiles during drag The OutBack easing on delegates caused newly-virtualized tiles to animate from y=0 (above the viewport when contentY > 0) to their target position, making them invisible for 400ms during auto-scroll. Since moveTile() rebuilds the entire model via sortTiles() on release (not appModel.move()), these animations served no useful purpose. Co-Authored-By: Claude Sonnet 4.6 --- src/Home.qml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/Home.qml b/src/Home.qml index bed272b581..08eedcfbf7 100644 --- a/src/Home.qml +++ b/src/Home.qml @@ -205,15 +205,6 @@ HomeForm { Accessible.description: largeButton ? largeButtonLabel : (secondLine !== "" ? secondLine : (writable ? qsTr("Adjustable. Current value: ") + value : qsTr("Current value: ") + value)) Accessible.focusable: true - Behavior on x { - enabled: id1.state != "active" - NumberAnimation { duration: 400; easing.type: Easing.OutBack } - } - - Behavior on y { - enabled: id1.state != "active" - NumberAnimation { duration: 400; easing.type: Easing.OutBack } - } SequentialAnimation on rotation { NumberAnimation { to: 2; duration: 60 } From 4f92551121c0adb03fa775c5f8cc61fed902813b Mon Sep 17 00:00:00 2001 From: Roberto Viola Date: Fri, 26 Jun 2026 21:52:27 +0200 Subject: [PATCH 4/5] Fix moveTile: use correct QSettings key for tiles with camelCase m_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moveTile() constructed settings keys as "tile_" + m_id.toLower() + "_order", but several DataObjects use camelCase m_ids (e.g. "avgWattLap") that don't match the actual QZSettings key ("tile_avg_watt_lap_order"). As a result, the moved tile's order was written to the wrong settings key and sortTiles() ignored it, leaving the tile in its original position after drag-and-drop. Fixed with a static lookup table in moveTile() mapping the mismatched m_ids to their correct QZSettings order key. Affected tiles: - avgWattLap → tile_avg_watt_lap_order - joul → tile_jouls_order - steeringangle → tile_steering_angle_order - stride_length → tile_instantaneous_stride_length_order - external_inclination → tile_ext_incline_order - target_inclination → tile_target_incline_order Co-Authored-By: Claude Sonnet 4.6 --- src/homeform.cpp | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/homeform.cpp b/src/homeform.cpp index 0989e4f0d0..40dda04259 100644 --- a/src/homeform.cpp +++ b/src/homeform.cpp @@ -4349,38 +4349,37 @@ void homeform::moveTile(QString name, int newIndex, int oldIndex) { if (current) { qDebug() << "moveTile" << name << newIndex << oldIndex; - foreach (QString s, settings.allKeys()) { - if (s.contains(QStringLiteral("tile_")) && s.contains(QStringLiteral("_order"))) { - - qDebug() << s << settings.value(s); - } - } + // Some DataObject m_ids don't match their QZSettings _order key (camelCase vs snake_case). + // This lambda returns the correct settings key for a given DataObject. + auto orderKey = [](const DataObject *d) -> QString { + static const QHash overrides = { + {QStringLiteral("avgWattLap"), QStringLiteral("tile_avg_watt_lap_order")}, + {QStringLiteral("joul"), QStringLiteral("tile_jouls_order")}, + {QStringLiteral("steeringangle"), QStringLiteral("tile_steering_angle_order")}, + {QStringLiteral("stride_length"), QStringLiteral("tile_instantaneous_stride_length_order")}, + {QStringLiteral("external_inclination"),QStringLiteral("tile_ext_incline_order")}, + {QStringLiteral("target_inclination"), QStringLiteral("tile_target_incline_order")}, + }; + auto it = overrides.constFind(d->m_id); + if (it != overrides.constEnd()) return it.value(); + return QStringLiteral("tile_") + d->m_id.toLower() + QStringLiteral("_order"); + }; int i = 0; foreach (QObject *d, dataList) { if (i == newIndex) { - settings.setValue("tile_" + current->m_id.toLower() + "_order", i); + settings.setValue(orderKey(current), i); i++; } - QString n = ((DataObject *)d)->m_id; if (((DataObject *)d)->name().compare(name)) { - settings.setValue("tile_" + n.toLower() + "_order", i); + settings.setValue(orderKey((DataObject *)d), i); i++; } } if (i <= newIndex) { - settings.setValue("tile_" + current->m_id.toLower() + "_order", newIndex); - } - - foreach (QString s, settings.allKeys()) { - if (s.contains(QStringLiteral("tile_")) && s.contains(QStringLiteral("_order"))) { - - qDebug() << s << settings.value(s); - } + settings.setValue(orderKey(current), newIndex); } - // sortTiles(); - // dataList.move(oldIndex, newIndex); // very dirty, but i needed a way to synchronize QML with C++ QTimer::singleShot(100, this, &homeform::sortTilesTimeout); } From deea89bec0ff3bfd37fc69afac82eb81b33446bb Mon Sep 17 00:00:00 2001 From: Roberto Viola Date: Thu, 2 Jul 2026 10:45:59 +0200 Subject: [PATCH 5/5] Enable scroll in lock mode and fix Android storage path on Waydroid In lock mode (lockTiles=true) the GridView's built-in interactive scrolling is disabled to prevent accidental tile drags. This meant any tiles beyond the visible viewport could never be reached. Fix: set `interactive: !window.lockTiles` so built-in scrolling is active only in normal mode, and add a manual scroll handler in the MouseArea's onPositionChanged that moves gridView.contentY when no drag is in progress (currentId === -1). An `isSwiping` flag suppresses long-press-to-drag when the gesture starts as a swipe. Also fix getAndroidDataAppDir() to handle Waydroid/emulator environments where isExternalStorageRemovable() throws IllegalArgumentException: clear the pending JNI exception and fall back to internal storage if external storage resolution fails entirely. Co-Authored-By: Claude Sonnet 4.6 --- src/Home.qml | 35 +++++++++++++++++++++++++---------- src/homeform.cpp | 17 +++++++++++++++++ 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/Home.qml b/src/Home.qml index 08eedcfbf7..828fc9bc25 100644 --- a/src/Home.qml +++ b/src/Home.qml @@ -178,6 +178,7 @@ HomeForm { model: appModel leftMargin: { if(OS_VERSION === "Android") (Screen.width % cellWidth) / 2; else (parent.width % cellWidth) / 2; } anchors.topMargin: (!window.lockTiles ? rootItem.topBarHeight + 30 : 0) + interactive: !window.lockTiles id: gridView objectName: "gridview" onMovementEnded: { headerToolbar.visible = (contentY == 0) || window.lockTiles; } @@ -563,6 +564,8 @@ HomeForm { property int newIndex property int startIndex: -1 property string tileName: "" + property real lastScrollY: 0 + property bool isSwiping: false function indexAtMouse(mx, my) { var cols = Math.max(1, Math.floor(gridView.width / gridView.cellWidth)) @@ -578,9 +581,14 @@ HomeForm { enabled: window.lockTiles anchors.fill: parent + onPressed: { + lastScrollY = mouseY + isSwiping = false + } + onPressAndHold: { + if (isSwiping) return var idx = indexAtMouse(mouseX, mouseY) - console.log("onPressAndHold " + idx) if (idx !== -1) { startIndex = idx newIndex = idx @@ -594,18 +602,28 @@ HomeForm { onReleased: { autoScrollTimer.running = false - var idx = indexAtMouse(mouseX, mouseY) - console.log("onReleased tileName=" + tileName + " idx=" + idx + " startIndex=" + startIndex) - if (currentId !== -1 && idx !== -1 && idx !== startIndex) { - rootItem.moveTile(tileName, idx, startIndex) + if (currentId !== -1) { + var idx = indexAtMouse(mouseX, mouseY) + if (idx !== -1 && idx !== startIndex) + rootItem.moveTile(tileName, idx, startIndex) } currentId = -1 startIndex = -1 tileName = "" + isSwiping = false } onPositionChanged: { - if (currentId === -1) return + if (currentId === -1) { + // No drag in progress: scroll the grid like a normal flick + var dy = mouseY - lastScrollY + if (Math.abs(dy) > 5) isSwiping = true + gridView.contentY = Math.max(0, + Math.min(gridView.contentHeight - gridView.height, + gridView.contentY - dy)) + lastScrollY = mouseY + return + } var edgeZone = 80 if (mouseY > gridView.height - edgeZone || mouseY < edgeZone) autoScrollTimer.running = true @@ -613,10 +631,7 @@ HomeForm { autoScrollTimer.running = false var idx = indexAtMouse(mouseX, mouseY) - console.log("onPositionChanged " + currentId + " " + idx + " " + newIndex + " " + mouseX + " " + mouseY) - if (idx !== -1 && idx !== newIndex) { - newIndex = idx - } + if (idx !== -1 && idx !== newIndex) newIndex = idx } } } diff --git a/src/homeform.cpp b/src/homeform.cpp index 40dda04259..4a175c3259 100644 --- a/src/homeform.cpp +++ b/src/homeform.cpp @@ -10414,8 +10414,16 @@ QString homeform::getAndroidDataAppDir() { QAndroidJniObject file; for (int i = 0; i < dataSize; i++) { file = env->GetObjectArrayElement(dataArray, i); + if (!file.isValid()) + continue; + // isExternalStorageRemovable throws IllegalArgumentException on Waydroid/emulators + // where vold can't resolve the storage volume — clear any pending exception. jboolean val = QAndroidJniObject::callStaticMethod( "android/os/Environment", "isExternalStorageRemovable", "(Ljava/io/File;)Z", file.object()); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + val = JNI_FALSE; + } mediaPath = file.callObjectMethod("getAbsolutePath", "()Ljava/lang/String;"); out = mediaPath.toString(); if (!val) @@ -10423,6 +10431,15 @@ QString homeform::getAndroidDataAppDir() { } } } + // Fallback to internal storage when external storage is unavailable (e.g. Waydroid) + if (out.isEmpty()) { + QAndroidJniObject internalDir = QtAndroid::androidActivity().callObjectMethod( + "getFilesDir", "()Ljava/io/File;"); + if (internalDir.isValid()) { + QAndroidJniObject internalPath = internalDir.callObjectMethod("getAbsolutePath", "()Ljava/lang/String;"); + out = internalPath.toString(); + } + } path = out; return out; }