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 + +