Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Features/FiatConnect/Sources/Scenes/FiatScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public struct FiatScene: View {
.safeAreaView {
StateButton(
text: model.actionButtonTitle,
type: .primary(model.state, showProgress: false),
type: .primary(model.actionButtonState, showProgress: false),
action: model.onSelectContinue
)
.frame(maxWidth: .scene.button.maxWidth)
Expand Down
72 changes: 38 additions & 34 deletions Features/FiatConnect/Sources/ViewModels/FiatSceneViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ public final class FiatSceneViewModel {
var focusField: FiatScene.Field?
var isPresentingFiatProvider: Bool = false

private var fetchTasks: [FiatQuoteType: Task<Void, Never>] = [:]

public init(
fiatService: any GemAPIFiatService = GemAPIService(),
currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencyCode: Currency.usd.rawValue),
Expand Down Expand Up @@ -88,6 +90,9 @@ public final class FiatSceneViewModel {
}

var actionButtonTitle: String { Localized.Common.continue }
var actionButtonState: StateViewType<[FiatQuote]> {
inputValidationModel.isValid ? state : .noData
}
var providerTitle: String { Localized.Common.provider }
var rateTitle: String { Localized.Buy.rate }
var errorTitle: String { Localized.Errors.errorOccured }
Expand Down Expand Up @@ -194,14 +199,15 @@ extension FiatSceneViewModel {
isPresentingFiatProvider = false
}

func onChangeType(_: FiatQuoteType, type: FiatQuoteType) {
func onChangeType(_ oldValue: FiatQuoteType, type: FiatQuoteType) {
fetchTasks[oldValue]?.cancel()
inputValidationModel.text = amountFormatter.format(amount: input.amount, for: type)
inputValidationModel.update(validators: inputValidation)
focusField = type == .buy ? .amountBuy : .amountSell
}

func onChangeAmountValue(_ amount: Double) async {
await fetch()
func onChangeAmountValue(_ amount: Double) {
fetch()
}

func onChangeAmountText(_: String, text: String) {
Expand All @@ -212,17 +218,14 @@ extension FiatSceneViewModel {
// MARK: - Private

extension FiatSceneViewModel {
private func fetch() async {
input.quote = nil
private func fetch() {
fetchTasks[input.type]?.cancel()

guard shouldProceedFetch else {
state = .noData
return
}
state = .loading
fetchTasks[input.type] = Task {
input.quote = nil
state = .loading

do {
let quotes: [FiatQuote] = try await {
do {
let request = FiatQuoteRequest(
assetId: asset.id.identifier,
type: input.type,
Expand All @@ -231,19 +234,24 @@ extension FiatSceneViewModel {
cryptoValue: amountFormatter.formatCryptoValue(fiatAmount: input.amount, type: input.type),
walletAddress: assetAddress.address
)
return try await fiatService.getQuotes(asset: asset, request: request)
}()

if !quotes.isEmpty {
input.quote = quotes.first
state = .data(quotes)
} else {
state = .noData
}
} catch {
if !error.isCancelled {
state = .error(error)
NSLog("FiatSceneViewModel get quotes error: \(error)")

let quotes = try await fiatService.getQuotes(asset: asset, request: request)

try Task.checkCancellation()
Copy link
Contributor

Choose a reason for hiding this comment

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

try Task.checkCancellation() can we move this to fiatService.getQuotes( ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The service layer (GemAPIService) focuses on network operations, while the ViewModel manages the lifecycle of its async tasks. This keeps each layer focused on its own responsibilities.


if !quotes.isEmpty {
input.quote = quotes.first
state = .data(quotes)
} else {
state = .noData
}
} catch {
guard !Task.isCancelled else { return }

if !error.isCancelled {
state = .error(error)
NSLog("FiatSceneViewModel get quotes error: \(error)")
}
}
}
}
Expand All @@ -267,14 +275,6 @@ extension FiatSceneViewModel {
}
}

private var shouldProceedFetch: Bool {
guard !input.amount.isZero else { return false }
switch input.type {
case .buy: return true
case .sell: return input.amount <= maxAmount
}
}

private var fiatProvidersViewModelState: StateViewType<SelectableListType<FiatQuoteViewModel>> {
switch state {
case .error(let error): .error(error)
Expand All @@ -297,7 +297,11 @@ extension FiatSceneViewModel {
)]
)
]
case .sell: []
case .sell: [
.assetAmount(decimals: asset.decimals.asInt, validators: [
BalanceValueValidator(available: assetData.balance.available, asset: asset)
])
]
}
}

Expand Down
54 changes: 50 additions & 4 deletions Features/FiatConnect/Tests/FiatSceneViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,17 +135,63 @@ final class FiatSceneViewModelTests {
@Test
func testFiatValidation() {
let model = FiatSceneViewModelTests.mock()

model.inputValidationModel.text = "4"
#expect(model.inputValidationModel.update() == false)

model.inputValidationModel.text = "5"
#expect(model.inputValidationModel.update() == true)

model.inputValidationModel.text = "10000"
#expect(model.inputValidationModel.update() == true)

model.inputValidationModel.text = "10001"
#expect(model.inputValidationModel.update() == false)
}

@Test
func actionButtonStateInvalidInput() {
let model = FiatSceneViewModelTests.mock()
model.state = .data([])

model.inputValidationModel.text = "4"
model.inputValidationModel.update()

#expect(model.actionButtonState.value == nil)
}

@Test
func actionButtonStateLoading() {
let model = FiatSceneViewModelTests.mock()
model.state = .loading

model.inputValidationModel.text = "100"
model.inputValidationModel.update()

#expect(model.actionButtonState.value == nil)
}

@Test
func actionButtonStateValidWithQuote() {
let model = FiatSceneViewModelTests.mock()
let quote = FiatQuote.mock(fiatAmount: 100, cryptoAmount: 1, type: .buy)

model.state = .data([quote])
model.input.quote = quote
model.inputValidationModel.text = "100"
model.inputValidationModel.update()

#expect(model.actionButtonState.value != nil)
}

@Test
func actionButtonStateValidNoQuote() {
let model = FiatSceneViewModelTests.mock()
model.state = .noData

model.inputValidationModel.text = "100"
model.inputValidationModel.update()

#expect(model.actionButtonState.value == nil)
}
}
Loading