From ae88063fcb24d3ae3af37833b838df024ab7847a Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Fri, 25 Sep 2026 15:42:20 +0200 Subject: [PATCH 1/3] fix: improve pull-to-refresh feedback --- Bitkit/ViewModels/CurrencyViewModel.swift | 1 + Bitkit/Views/HomeScreen.swift | 185 ++++++++++++++++++++-- changelog.d/next/532.fixed.md | 1 + journeys/home/pull-to-refresh-rates.xml | 20 +++ 4 files changed, 196 insertions(+), 11 deletions(-) create mode 100644 changelog.d/next/532.fixed.md create mode 100644 journeys/home/pull-to-refresh-rates.xml diff --git a/Bitkit/ViewModels/CurrencyViewModel.swift b/Bitkit/ViewModels/CurrencyViewModel.swift index a085a6122..7b36f88b5 100644 --- a/Bitkit/ViewModels/CurrencyViewModel.swift +++ b/Bitkit/ViewModels/CurrencyViewModel.swift @@ -75,6 +75,7 @@ class CurrencyViewModel: ObservableObject { error = nil hasStaleData = false syncDisplayCurrencyToAppGroup() + Logger.debug("Currency rates refreshed successfully") } catch { self.error = error Logger.error(error, context: "Currency rates refresh failed") diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index c8f88e24a..670a3be5d 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -1,14 +1,17 @@ import SwiftUI +import UIKit struct HomeScreen: View { @Environment(CalculatorInputManager.self) private var calculatorInput @EnvironmentObject var activity: ActivityListViewModel @EnvironmentObject var app: AppViewModel + @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel @EnvironmentObject var wallet: WalletViewModel @State private var scrollPosition: Int? = 0 @State private var isEditingWidgets = false + @State private var pullRefreshState = HomePullRefreshState() private var hasActivity: Bool { return activity.latestActivities?.isEmpty == false @@ -25,7 +28,7 @@ struct HomeScreen: View { GeometryReader { geometry in ScrollView(showsIndicators: false) { LazyVStack { - HomeWalletView() + HomePullRefreshWallet(state: pullRefreshState) .frame(height: geometry.size.height, alignment: .top) .id(0) @@ -36,9 +39,17 @@ struct HomeScreen: View { } } .scrollTargetLayout() + .overlay(alignment: .top) { + HomePullRefreshObserver { + Task { await refresh() } + } + .frame(width: 0, height: 0) + } } .scrollTargetBehavior(.paging) .scrollPosition(id: $scrollPosition) + .accessibilityIdentifier("HomeScrollView") + .accessibilityElement(children: .contain) .onChange(of: scrollPosition) { _, newValue in if newValue != 1 { calculatorInput.dismiss() @@ -49,16 +60,6 @@ struct HomeScreen: View { app.hasDismissedWidgetsOnboardingHint = true } } - .refreshable { - guard currentPage == 0 else { return } - guard wallet.nodeLifecycleState == .running else { return } - do { - try await wallet.sync() - try await activity.syncLdkNodePayments() - } catch { - app.toast(error) - } - } } .ignoresSafeArea() @@ -85,6 +86,14 @@ struct HomeScreen: View { .allowsHitTesting(false) .animation(.easeOut(duration: 0.14), value: calculatorInput.isPresented) } + .overlay(alignment: .top) { + HomePullRefreshOverlay(state: pullRefreshState) + .frame(width: 20, height: 20) + .padding(.top, ScreenLayout.headerHeight + 16) + .frame(maxWidth: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) + } .navigationBarHidden(true) .onAppear { TimedSheetManager.shared.onPrimaryScreenEntered() @@ -98,9 +107,163 @@ struct HomeScreen: View { } } + private func refresh() async { + guard currentPage == 0 else { return } + guard pullRefreshState.beginRefreshing() else { return } + defer { pullRefreshState.endRefreshing() } + + async let currencyRefresh: Void = currency.refresh() + + if wallet.nodeLifecycleState == .running { + do { + try await wallet.sync() + try await activity.syncLdkNodePayments() + } catch { + app.toast(error) + } + } + + await currencyRefresh + } + private func consumeRequestedHomePage() { guard let requested = app.requestedHomePage else { return } withAnimation { scrollPosition = requested } app.requestedHomePage = nil } } + +// MARK: - Pull-to-refresh + +@MainActor +@Observable +private final class HomePullRefreshState { + private(set) var isRefreshing = false + + @ObservationIgnored + private weak var spinner: UIActivityIndicatorView? + + func attach(_ spinner: UIActivityIndicatorView) { + self.spinner = spinner + spinner.alpha = isRefreshing ? 1 : 0 + if isRefreshing { + spinner.startAnimating() + } else { + spinner.stopAnimating() + } + } + + func beginRefreshing() -> Bool { + guard !isRefreshing else { return false } + withAnimation(.easeOut(duration: 0.2)) { + isRefreshing = true + } + spinner?.startAnimating() + UIView.animate(withDuration: 0.2) { [weak spinner] in + spinner?.alpha = 1 + } + return true + } + + func endRefreshing() { + withAnimation(.easeOut(duration: 0.2)) { + isRefreshing = false + } + UIView.animate(withDuration: 0.2) { [weak spinner] in + spinner?.alpha = 0 + } completion: { [weak self, weak spinner] _ in + guard self?.isRefreshing == false else { return } + spinner?.stopAnimating() + } + } +} + +private struct HomePullRefreshWallet: View { + private static let refreshSpacing: CGFloat = 60 + + var state: HomePullRefreshState + + var body: some View { + HomeWalletView() + .padding(.top, state.isRefreshing ? Self.refreshSpacing : 0) + } +} + +private struct HomePullRefreshOverlay: UIViewRepresentable { + var state: HomePullRefreshState + + func makeUIView(context _: Context) -> UIActivityIndicatorView { + let spinner = UIActivityIndicatorView(style: .medium) + spinner.color = UIColor(Color.textPrimary) + spinner.isAccessibilityElement = false + state.attach(spinner) + return spinner + } + + func updateUIView(_ uiView: UIActivityIndicatorView, context _: Context) { + state.attach(uiView) + } +} + +private struct HomePullRefreshObserver: UIViewRepresentable { + var onRefresh: () -> Void + + func makeUIView(context _: Context) -> HomePullRefreshObserverView { + let view = HomePullRefreshObserverView() + view.onRefresh = onRefresh + return view + } + + func updateUIView(_ uiView: HomePullRefreshObserverView, context _: Context) { + uiView.onRefresh = onRefresh + uiView.attachToScrollViewIfNeeded() + } +} + +private final class HomePullRefreshObserverView: UIView { + /** Pull distance required to start refreshing the home wallet. */ + private static let threshold: CGFloat = 80 + + var onRefresh: (() -> Void)? + private weak var observedScrollView: UIScrollView? + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil { + detachFromScrollView() + } else { + attachToScrollViewIfNeeded() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + attachToScrollViewIfNeeded() + } + + func attachToScrollViewIfNeeded() { + var ancestor = superview + while let view = ancestor { + if let scrollView = view as? UIScrollView { + guard scrollView !== observedScrollView else { return } + detachFromScrollView() + observedScrollView = scrollView + scrollView.panGestureRecognizer.addTarget(self, action: #selector(handlePanGesture)) + return + } + ancestor = view.superview + } + } + + private func detachFromScrollView() { + observedScrollView?.panGestureRecognizer.removeTarget(self, action: #selector(handlePanGesture)) + observedScrollView = nil + } + + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + guard gesture.state == .ended, let scrollView = observedScrollView else { return } + let pullDistance = -(scrollView.contentOffset.y + scrollView.adjustedContentInset.top) + guard pullDistance >= Self.threshold else { return } + onRefresh?() + } +} diff --git a/changelog.d/next/532.fixed.md b/changelog.d/next/532.fixed.md new file mode 100644 index 000000000..9dbb8fca5 --- /dev/null +++ b/changelog.d/next/532.fixed.md @@ -0,0 +1 @@ +Improved Home pull-to-refresh feedback and added on-demand exchange-rate updates. diff --git a/journeys/home/pull-to-refresh-rates.xml b/journeys/home/pull-to-refresh-rates.xml new file mode 100644 index 000000000..edb74b428 --- /dev/null +++ b/journeys/home/pull-to-refresh-rates.xml @@ -0,0 +1,20 @@ + + + Pulling to refresh on Home also refreshes exchange rates, independently of the wallet and + activity refresh. Precondition: onboarded dev wallet on Home (id "HomeScrollView" visible) and + network access to the rates backend. Rates also refresh on app start and every two minutes of + polling (`Env.fxRateRefreshInterval`), so pull at least 20s after the last "Currency rates + refreshed successfully" line and well before the next polling tick, or the log check passes for + the wrong reason. The success line and any error toast are not in `snapshot-ui`; read the app log + and take a screenshot. Unlike Android, this journey does not assert a separate widget refresh; + issue #344 only adds an exchange-rate refresh to the iOS Home pull. + + + Resolve the app group logs for the simulator under test: UDID=<the simulator this journey is running on>; GROUP=$(xcrun simctl get_app_container "$UDID" to.bitkit groups | awk '{print $2}'); ls -t "$GROUP/logs" + Resolve the app group again, run `grep "Currency rates refreshed" "$GROUP"/logs/*.log`, and note the time of the last line + Verify the home screen (id "HomeScrollView") is visible + Run `xcodebuildmcp simulator snapshot-ui`, note the elementRef for "HomeScrollView", then run `xcodebuildmcp ui-automation swipe --within-element-ref <ref> --direction down --distance 0.7` and note the UTC time + Verify the newest log (list the logs again) gains a "Currency rates refreshed successfully" line within 10s of the pull + Verify no "Currency rates refresh failed" line was added for the pull + + From b1a133ae28d67ec438580356badcb13894609abd Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 28 Sep 2026 15:47:42 +0200 Subject: [PATCH 2/3] fix: cap pull-to-refresh feedback --- Bitkit/Views/HomeScreen.swift | 37 ++++++++++++++++- .../HomePullRefreshFeedbackTests.swift | 40 +++++++++++++++++++ journeys/home/pull-to-refresh-rates.xml | 2 + 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 BitkitTests/HomePullRefreshFeedbackTests.swift diff --git a/Bitkit/Views/HomeScreen.swift b/Bitkit/Views/HomeScreen.swift index 670a3be5d..57837961e 100644 --- a/Bitkit/Views/HomeScreen.swift +++ b/Bitkit/Views/HomeScreen.swift @@ -112,7 +112,9 @@ struct HomeScreen: View { guard pullRefreshState.beginRefreshing() else { return } defer { pullRefreshState.endRefreshing() } - async let currencyRefresh: Void = currency.refresh() + async let currencyRefreshFeedback: Void = HomePullRefreshFeedback.wait { + await currency.refresh() + } if wallet.nodeLifecycleState == .running { do { @@ -123,7 +125,7 @@ struct HomeScreen: View { } } - await currencyRefresh + await currencyRefreshFeedback } private func consumeRequestedHomePage() { @@ -135,6 +137,37 @@ struct HomeScreen: View { // MARK: - Pull-to-refresh +enum HomePullRefreshFeedback { + static let timeout: Duration = .seconds(10) + + @MainActor + static func wait( + timeout: Duration = HomePullRefreshFeedback.timeout, + refresh: @escaping () async -> Void, + sleep: @escaping (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + ) async { + let (stream, continuation) = AsyncStream.makeStream() + Task { + await refresh() + continuation.finish() + } + let timeoutTask = Task { + do { + try await sleep(timeout) + continuation.finish() + } catch { + continuation.finish() + } + } + + defer { + timeoutTask.cancel() + continuation.finish() + } + for await _ in stream {} + } +} + @MainActor @Observable private final class HomePullRefreshState { diff --git a/BitkitTests/HomePullRefreshFeedbackTests.swift b/BitkitTests/HomePullRefreshFeedbackTests.swift new file mode 100644 index 000000000..1486dbe3b --- /dev/null +++ b/BitkitTests/HomePullRefreshFeedbackTests.swift @@ -0,0 +1,40 @@ +@testable import Bitkit +import XCTest + +@MainActor +final class HomePullRefreshFeedbackTests: XCTestCase { + func testStopsWaitingWhenRefreshCompletes() async { + var didRefresh = false + + await HomePullRefreshFeedback.wait( + refresh: { + didRefresh = true + }, + sleep: { _ in + try await Task.sleep(for: .seconds(60)) + } + ) + + XCTAssertTrue(didRefresh) + } + + func testTimeoutStopsWaitingWhileRefreshContinues() async { + let refreshGate = AsyncStream.makeStream() + let refreshFinished = expectation(description: "Refresh continued after feedback timeout") + var didFinishRefresh = false + + await HomePullRefreshFeedback.wait( + refresh: { + for await _ in refreshGate.stream {} + didFinishRefresh = true + refreshFinished.fulfill() + }, + sleep: { _ in } + ) + + XCTAssertFalse(didFinishRefresh) + refreshGate.continuation.finish() + await fulfillment(of: [refreshFinished], timeout: 1) + XCTAssertTrue(didFinishRefresh) + } +} diff --git a/journeys/home/pull-to-refresh-rates.xml b/journeys/home/pull-to-refresh-rates.xml index edb74b428..191a3f35e 100644 --- a/journeys/home/pull-to-refresh-rates.xml +++ b/journeys/home/pull-to-refresh-rates.xml @@ -14,7 +14,9 @@ Resolve the app group again, run `grep "Currency rates refreshed" "$GROUP"/logs/*.log`, and note the time of the last line Verify the home screen (id "HomeScrollView") is visible Run `xcodebuildmcp simulator snapshot-ui`, note the elementRef for "HomeScrollView", then run `xcodebuildmcp ui-automation swipe --within-element-ref <ref> --direction down --distance 0.7` and note the UTC time + Within about 1s of the pull, take a screenshot and verify a spinner is visible below the header with the wallet content shifted down Verify the newest log (list the logs again) gains a "Currency rates refreshed successfully" line within 10s of the pull + Take another screenshot and verify the spinner and extra space above the wallet content are gone Verify no "Currency rates refresh failed" line was added for the pull From 83f02c7232efd9add781bca5cf2977590b608066 Mon Sep 17 00:00:00 2001 From: Philipp Walter Date: Mon, 28 Sep 2026 17:14:12 +0200 Subject: [PATCH 3/3] docs: clarify refresh journey completion --- journeys/home/pull-to-refresh-rates.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/journeys/home/pull-to-refresh-rates.xml b/journeys/home/pull-to-refresh-rates.xml index 191a3f35e..6aecf0b05 100644 --- a/journeys/home/pull-to-refresh-rates.xml +++ b/journeys/home/pull-to-refresh-rates.xml @@ -16,6 +16,7 @@ Run `xcodebuildmcp simulator snapshot-ui`, note the elementRef for "HomeScrollView", then run `xcodebuildmcp ui-automation swipe --within-element-ref <ref> --direction down --distance 0.7` and note the UTC time Within about 1s of the pull, take a screenshot and verify a spinner is visible below the header with the wallet content shifted down Verify the newest log (list the logs again) gains a "Currency rates refreshed successfully" line within 10s of the pull + Do not treat the rates success line as overall refresh completion; wait for wallet and activity synchronization to finish and for the spinner fade-out to complete Take another screenshot and verify the spinner and extra space above the wallet content are gone Verify no "Currency rates refresh failed" line was added for the pull