Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Bitkit/ViewModels/CurrencyViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
185 changes: 174 additions & 11 deletions Bitkit/Views/HomeScreen.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

Expand All @@ -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()
Expand All @@ -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()

Expand All @@ -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()
Expand All @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spinner can stay up for about 3 minutes, and pulls are ignored the whole time.

If the rates host is reachable at the network level but never answers (captive portal, blackholed route, slow backend), CurrencyService.fetchLatestRates makes 3 attempts with the default 60s URLSession timeout plus 1s/2s backoff, about 183s in total. The spinner and the 60pt padding stay for that whole time. Every pull hits the beginRefreshing() guard and does nothing. Rate failures are never toasted, so the spinner then disappears without any feedback. Before this change, a pull with a running node ended as soon as the wallet/activity sync finished.

Keeping the spinner for rates-only pulls was the fix for the earlier feedback thread, so I'm not asking to undo it. Capping this await (for example, race currencyRefresh against a ~10s sleep and let the fetch keep running in the background) keeps that feedback without the long lock-out.

}

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)
Comment thread
pwltr marked this conversation as resolved.
}
}

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?()
}
}
1 change: 1 addition & 0 deletions changelog.d/next/532.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Improved Home pull-to-refresh feedback and added on-demand exchange-rate updates.
20 changes: 20 additions & 0 deletions journeys/home/pull-to-refresh-rates.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<journey name="pull to refresh rates">
<description>
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.
</description>
<actions>
<action>Resolve the app group logs for the simulator under test: UDID=&lt;the simulator this journey is running on&gt;; GROUP=$(xcrun simctl get_app_container "$UDID" to.bitkit groups | awk '{print $2}'); ls -t "$GROUP/logs"</action>
<action>Resolve the app group again, run `grep "Currency rates refreshed" "$GROUP"/logs/*.log`, and note the time of the last line</action>
<action>Verify the home screen (id "HomeScrollView") is visible</action>
<action>Run `xcodebuildmcp simulator snapshot-ui`, note the elementRef for "HomeScrollView", then run `xcodebuildmcp ui-automation swipe --within-element-ref &lt;ref&gt; --direction down --distance 0.7` and note the UTC time</action>
<action>Verify the newest log (list the logs again) gains a "Currency rates refreshed successfully" line within 10s of the pull</action>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The journey never checks the spinner, which is what #519 is about.

The description says to take a screenshot, but no action does. The indicator is .accessibilityHidden(true) and the UIActivityIndicatorView sets isAccessibilityElement = false, so snapshot-ui cannot see it. A build where the spinner never appears passes on the log line alone. Add an action after the swipe: take a screenshot within ~1s and verify a spinner shows under the header, then verify it is gone once the success line appears.

<action>Verify no "Currency rates refresh failed" line was added for the pull</action>
</actions>
</journey>
Loading