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
218 changes: 207 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,196 @@ struct HomeScreen: View {
}
}

private func refresh() async {
guard currentPage == 0 else { return }
guard pullRefreshState.beginRefreshing() else { return }
defer { pullRefreshState.endRefreshing() }

async let currencyRefreshFeedback: Void = HomePullRefreshFeedback.wait {
await currency.refresh()
}

if wallet.nodeLifecycleState == .running {
do {
try await wallet.sync()
try await activity.syncLdkNodePayments()
} catch {
app.toast(error)
}
}

await currencyRefreshFeedback
}

private func consumeRequestedHomePage() {
guard let requested = app.requestedHomePage else { return }
withAnimation { scrollPosition = requested }
app.requestedHomePage = nil
}
}

// 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<Void>.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 {
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?()
}
}
40 changes: 40 additions & 0 deletions BitkitTests/HomePullRefreshFeedbackTests.swift
Original file line number Diff line number Diff line change
@@ -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<Void>.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)
}
}
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.
23 changes: 23 additions & 0 deletions journeys/home/pull-to-refresh-rates.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<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>Within about 1s of the pull, take a screenshot and verify a spinner is visible below the header with the wallet content shifted down</action>
<action>Verify the newest log (list the logs again) gains a "Currency rates refreshed successfully" line within 10s of the pull</action>
Comment thread
pwltr marked this conversation as resolved.
<action>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</action>
<action>Take another screenshot and verify the spinner and extra space above the wallet content are gone</action>
Comment thread
pwltr marked this conversation as resolved.
<action>Verify no "Currency rates refresh failed" line was added for the pull</action>
</actions>
</journey>
Loading