Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.

Commit aee5c4c

Browse files
committed
Refactor swap quote streaming & loading UI
Introduce streaming-aware state and loading UI for swap quotes. Add isStreaming to SwapSceneViewModel and include it in isLoading; replace incremental accumulator with applyQuotes/selectQuote logic and add minInputAmountError handling to pick the smallest min amount. Wire loading indicators into UI by adding isLoading to SwapChangeView and SwapTokenView (renaming showLoading -> isLoading and showing LoadingView when appropriate). Add SwapQuotesProviderMock for tests and update testkit mocks (SwapperProviderData, SwapperProviderType, SwapperQuote) to support provider-specific mocks. Update SwapSceneViewModel tests to assert provider selection preservation and min-amount error behavior. Remove unused SwapQuoteStreamEvent type. Also allow injecting a custom quotesProvider into the SwapSceneViewModel mock helper.
1 parent 188cb66 commit aee5c4c

10 files changed

Lines changed: 127 additions & 64 deletions

File tree

Features/Swap/Sources/Scenes/SwapScene.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ extension SwapScene {
9494
} footer: {
9595
SwapChangeView(
9696
fromId: $model.pairSelectorModel.fromAssetId,
97-
toId: $model.pairSelectorModel.toAssetId
97+
toId: $model.pairSelectorModel.toAssetId,
98+
isLoading: model.isLoading
9899
)
99100
.padding(.top, .small)
100101
.frame(maxWidth: .infinity)
@@ -110,7 +111,7 @@ extension SwapScene {
110111
SwapTokenView(
111112
model: model.swapTokenModel(type: .receive(chains: [], assetIds: [])),
112113
text: $model.toValue,
113-
showLoading: model.isLoading,
114+
isLoading: model.isLoading,
114115
disabledTextField: true,
115116
onBalanceAction: {},
116117
onSelectAssetAction: model.onSelectAssetReceive

Features/Swap/Sources/ViewModels/SwapSceneViewModel.swift

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ public final class SwapSceneViewModel {
3030
public var swapState: SwapState = .init()
3131
public var isPresentingInfoSheet: SwapSheetType?
3232

33+
private(set) var isStreaming: Bool = false
34+
3335
public let fromAssetQuery: ObservableQuery<AssetRequestOptional>
3436
public let toAssetQuery: ObservableQuery<AssetRequestOptional>
3537

@@ -113,7 +115,7 @@ public final class SwapSceneViewModel {
113115
}
114116

115117
var isLoading: Bool {
116-
swapState.quotes.isLoading
118+
swapState.quotes.isLoading || isStreaming
117119
}
118120

119121
var assetIds: [AssetId] {
@@ -341,30 +343,33 @@ extension SwapSceneViewModel {
341343
do {
342344
swapState.swapTransferData = .noData
343345
swapState.quotes = .loading
346+
isStreaming = true
344347
resetToValue()
345348

346-
var accumulated: [SwapperQuote] = []
349+
var quotes: [SwapperQuote] = []
347350
var errors: [Error] = []
348351

349352
for await result in swapQuotesProvider.fetchQuotes(wallet: wallet, input: input) {
350353
try Task.checkCancellation()
351354

352355
switch result {
353356
case .success(let quote):
354-
accumulated.append(quote)
355-
applyAccumulatedQuotes(&accumulated)
357+
quotes.append(quote)
358+
applyQuotes(&quotes)
356359
case .failure(let error):
357360
errors.append(error)
358361
}
359362
}
360363

361364
try Task.checkCancellation()
362365

363-
if accumulated.isEmpty {
364-
let swapError = prioritizedSwapError(errors) ?? SwapperError.NoQuoteAvailable
366+
if quotes.isEmpty {
367+
let swapError = minInputAmountError(errors) ?? SwapperError.NoQuoteAvailable
365368
swapState.quotes = .error(swapError)
366369
selectedSwapQuote = nil
367370
amountInputModel.update(error: nil)
371+
} else {
372+
selectQuote(from: quotes)
368373
}
369374
} catch {
370375
if !error.isCancelled && !Task.isCancelled {
@@ -374,28 +379,28 @@ extension SwapSceneViewModel {
374379
debugLog("SwapScene get quotes error: \(error)")
375380
}
376381
}
382+
isStreaming = false
377383
}
378384

379-
private func applyAccumulatedQuotes(_ accumulated: inout [SwapperQuote]) {
380-
accumulated.sort { BigInt.fromString($0.toValue) > BigInt.fromString($1.toValue) }
381-
swapState.quotes = .data(accumulated)
382-
selectedSwapQuote = accumulated.first
383-
if let selectedSwapQuote, let asset = toAsset?.asset {
384-
applyQuote(selectedSwapQuote, asset: asset)
385+
private func applyQuotes(_ quotes: inout [SwapperQuote]) {
386+
quotes.sort { BigInt.fromString($0.toValue) > BigInt.fromString($1.toValue) }
387+
swapState.quotes = .data(quotes)
388+
if let bestQuote = quotes.first, let asset = toAsset?.asset {
389+
applyQuote(bestQuote, asset: asset)
385390
}
386391
}
387392

388-
private func prioritizedSwapError(_ errors: [Error]) -> SwapperError? {
389-
let inputErrors: [(BigInt?, String?)] = errors.compactMap { error in
390-
guard let swapperError = error as? SwapperError, case .InputAmountError(let minAmount) = swapperError else { return nil }
391-
return (minAmount.flatMap { BigInt($0) }, minAmount)
392-
}
393-
guard !inputErrors.isEmpty else { return nil }
394-
if let best = inputErrors.filter({ $0.0 != nil }).min(by: { ($0.0 ?? 0) < ($1.0 ?? 0) }) {
395-
let adjusted = best.0.map { String($0.increase(byPercent: 10)) }
396-
return .InputAmountError(minAmount: adjusted)
393+
private func selectQuote(from quotes: [SwapperQuote]) {
394+
selectedSwapQuote = quotes.first(where: { $0.data.provider == selectedSwapQuote?.data.provider }) ?? quotes.first
395+
}
396+
397+
private func minInputAmountError(_ errors: [Error]) -> SwapperError? {
398+
let minAmounts: [BigInt] = errors.compactMap { error in
399+
guard case .InputAmountError(let minAmount) = error as? SwapperError else { return nil }
400+
return minAmount.flatMap { BigInt($0) }
397401
}
398-
return .InputAmountError(minAmount: nil)
402+
guard let min = minAmounts.min() else { return nil }
403+
return .InputAmountError(minAmount: String(min.increase(byPercent: 10)))
399404
}
400405

401406
private func performUpdate(for assetIds: [AssetId]) async {

Features/Swap/Sources/Views/SwapChangeView.swift

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,40 @@
22

33
import Foundation
44
import SwiftUI
5+
import Components
56
import Style
67
import Primitives
78

89
struct SwapChangeView: View {
910
@Binding private var fromId: AssetId?
1011
@Binding private var toId: AssetId?
12+
var isLoading: Bool
1113

1214
init(
1315
fromId: Binding<AssetId?> = .constant(.none),
14-
toId: Binding<AssetId?> = .constant(.none)
16+
toId: Binding<AssetId?> = .constant(.none),
17+
isLoading: Bool = false
1518
) {
1619
_fromId = fromId
1720
_toId = toId
21+
self.isLoading = isLoading
1822
}
1923

2024
var body: some View {
21-
Button {
22-
swap(&fromId, &toId)
23-
} label: {
24-
Images.System.arrowSwap
25-
.resizable()
26-
.renderingMode(.template)
27-
.scaledToFit()
25+
if isLoading {
26+
LoadingView(tint: Colors.gray)
2827
.frame(size: .large)
29-
.foregroundStyle(Colors.gray)
28+
} else {
29+
Button {
30+
swap(&fromId, &toId)
31+
} label: {
32+
Images.System.arrowSwap
33+
.resizable()
34+
.renderingMode(.template)
35+
.scaledToFit()
36+
.frame(size: .large)
37+
.foregroundStyle(Colors.gray)
38+
}
3039
}
3140
}
3241
}

Features/Swap/Sources/Views/SwapTokenView.swift

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import Primitives
88
struct SwapTokenView: View {
99
let model: SwapTokenViewModel
1010
@Binding var text: String
11-
var showLoading: Bool = false
11+
var isLoading: Bool = false
1212
var disabledTextField: Bool = false
1313
var onBalanceAction: (() -> Void)
1414
var onSelectAssetAction: (() -> Void)
@@ -26,19 +26,14 @@ struct SwapTokenView: View {
2626
}
2727
}
2828
}
29-
29+
3030
private var inputView: some View {
31-
HStack {
32-
if showLoading {
33-
LoadingView()
34-
}
35-
TextField(showLoading ? "" : String.zero, text: $text)
36-
.keyboardType(.decimalPad)
37-
.foregroundStyle(Colors.black)
38-
.font(.app.title1)
39-
.disabled(disabledTextField)
40-
.multilineTextAlignment(.leading)
41-
}
31+
TextField(String.zero, text: $text)
32+
.keyboardType(.decimalPad)
33+
.foregroundStyle(isLoading ? Colors.gray : Colors.black)
34+
.font(.app.title1)
35+
.disabled(disabledTextField)
36+
.multilineTextAlignment(.leading)
4237
}
4338

4439
private var fiatBalanceView: some View {

Features/Swap/Tests/SwapTests/SwapSceneViewModelTests.swift

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import SwapServiceTestKit
1111
import BigInt
1212
import protocol Gemstone.GemSwapperProtocol
1313
import enum Gemstone.SwapperError
14+
import struct Gemstone.SwapperQuote
15+
import struct Gemstone.SwapperProviderType
1416
import Keystore
1517
import KeystoreTestKit
1618
import Primitives
@@ -132,6 +134,34 @@ struct SwapSceneViewModelTests {
132134
#expect(model.fetchTrigger?.isImmediate == true)
133135
}
134136

137+
@Test
138+
func preservesProviderSelectionOnRefresh() async {
139+
let quoteA = SwapperQuote.mock(toValue: "100", data: .mock(provider: .mock(id: .uniswapV3)))
140+
let quoteB = SwapperQuote.mock(toValue: "200", data: .mock(provider: .mock(id: .thorchain)))
141+
let model = SwapSceneViewModel.mock(quotesProvider: SwapQuotesProviderMock(results: [.success(quoteA), .success(quoteB)]))
142+
143+
await model.fetch()
144+
model.onFinishSwapProviderSelection(quoteA)
145+
146+
#expect(model.selectedSwapQuote?.data.provider.id == .uniswapV3)
147+
148+
await model.fetch()
149+
150+
#expect(model.selectedSwapQuote?.data.provider.id == .uniswapV3)
151+
}
152+
153+
@Test
154+
func minAmountErrorPicksSmallest() async {
155+
let model = SwapSceneViewModel.mock(quotesProvider: SwapQuotesProviderMock(results: [
156+
.failure(SwapperError.InputAmountError(minAmount: "2000")),
157+
.failure(SwapperError.InputAmountError(minAmount: "1000")),
158+
]))
159+
160+
await model.fetch()
161+
162+
#expect(model.buttonViewModel.buttonAction == .useMinAmount(amount: "1100", asset: .mockEthereum()))
163+
}
164+
135165
// MARK: - Private methods
136166

137167
private func model(
@@ -147,7 +177,7 @@ struct SwapSceneViewModelTests {
147177
}
148178

149179
extension SwapSceneViewModel {
150-
static func mock(swapper: GemSwapperProtocol = GemSwapperMock()) -> SwapSceneViewModel {
180+
static func mock(swapper: GemSwapperProtocol = GemSwapperMock(), quotesProvider: SwapQuotesProvidable? = nil) -> SwapSceneViewModel {
151181
let model = SwapSceneViewModel(
152182
preferences: .mock(),
153183
input: .init(
@@ -156,7 +186,7 @@ extension SwapSceneViewModel {
156186
),
157187
balanceUpdater: .mock(),
158188
priceUpdater: .mock(),
159-
swapQuotesProvider: SwapQuotesProvider(swapService: .mock(swapper: swapper)),
189+
swapQuotesProvider: quotesProvider ?? SwapQuotesProvider(swapService: .mock(swapper: swapper)),
160190
swapQuoteDataProvider: SwapQuoteDataProvider(keystore: LocalKeystore.mock(), swapService: .mock(swapper: swapper))
161191
)
162192
model.fromAssetQuery.value = .mock(asset: .mockEthereum(), balance: .mock())
@@ -170,3 +200,4 @@ extension SwapSceneViewModel {
170200
private struct TestError: Error, RetryableError {
171201
var isRetryAvailable: Bool = true
172202
}
203+

Packages/FeatureServices/SwapService/SwapQuoteStreamEvent.swift

Lines changed: 0 additions & 8 deletions
This file was deleted.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Copyright (c). Gem Wallet. All rights reserved.
2+
3+
import SwapService
4+
import Primitives
5+
import struct Gemstone.SwapperQuote
6+
7+
public struct SwapQuotesProviderMock: SwapQuotesProvidable {
8+
private let results: [Result<SwapperQuote, Error>]
9+
10+
public init(results: [Result<SwapperQuote, Error>]) {
11+
self.results = results
12+
}
13+
14+
public func supportedAssets(for assetId: AssetId) -> ([Primitives.Chain], [Primitives.AssetId]) {
15+
([], [])
16+
}
17+
18+
public func fetchQuotes(wallet: Wallet, input: SwapQuoteInput) -> AsyncStream<Result<SwapperQuote, Error>> {
19+
let results = self.results
20+
return AsyncStream { continuation in
21+
for result in results {
22+
continuation.yield(result)
23+
}
24+
continuation.finish()
25+
}
26+
}
27+
}

Packages/FeatureServices/SwapService/TestKit/Types+Mock/SwapperProviderData+TestKit.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import Foundation
44
import struct Gemstone.SwapperProviderData
5+
import struct Gemstone.SwapperProviderType
56
import struct Gemstone.SwapperRoute
67

7-
extension SwapperProviderData {
8-
static func mock() -> SwapperProviderData {
8+
public extension SwapperProviderData {
9+
static func mock(provider: SwapperProviderType = .mock()) -> SwapperProviderData {
910
SwapperProviderData(
10-
provider: .mock(),
11+
provider: provider,
1112
slippageBps: 50,
1213
routes: [.mock()]
1314
)

Packages/FeatureServices/SwapService/TestKit/Types+Mock/SwapperProviderType+TestKit.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import enum Gemstone.SwapperProvider
55
import struct Gemstone.SwapperProviderType
66

77
public extension SwapperProviderType {
8-
static func mock() -> SwapperProviderType {
8+
static func mock(id: SwapperProvider = .pancakeswapV3) -> SwapperProviderType {
99
SwapperProviderType(
10-
id: .pancakeswapV3,
11-
name: "PancakeSwap",
10+
id: id,
11+
name: "\(id)",
1212
protocol: "v3",
13-
protocolId: "pancakeswap_v3",
13+
protocolId: "\(id)_v3",
1414
mode: .onChain
1515
)
1616
}

Packages/FeatureServices/SwapService/TestKit/Types+Mock/SwapperQuote+TestKit.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
// Copyright (c). Gem Wallet. All rights reserved.
22

33
import Foundation
4+
import struct Gemstone.SwapperProviderData
45
import struct Gemstone.SwapperQuote
56

67
public extension SwapperQuote {
78
static func mock(
89
fromValue: String = "1000000000000000000",
910
toValue: String = "250000000000",
11+
data: SwapperProviderData = .mock(),
1012
etaInSeconds: UInt32? = nil
1113
) -> SwapperQuote {
1214
SwapperQuote(
1315
fromValue: fromValue,
1416
toValue: toValue,
15-
data: .mock(),
17+
data: data,
1618
request: .mock(),
1719
etaInSeconds: etaInSeconds
1820
)

0 commit comments

Comments
 (0)