Skip to content
Draft
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
5 changes: 5 additions & 0 deletions Features/FiatConnect/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ let package = Package(
.package(name: "Localization", path: "../../Packages/Localization"),
.package(name: "Store", path: "../../Packages/Store"),
.package(name: "PrimitivesComponents", path: "../../Packages/PrimitivesComponents"),
.package(name: "FeatureServices", path: "../../Packages/FeatureServices"),
.package(name: "ChainServices", path: "../../Packages/ChainServices"),
],
targets: [
.target(
Expand All @@ -36,6 +38,8 @@ let package = Package(
"Localization",
"Store",
"PrimitivesComponents",
.product(name: "FiatTransactionService", package: "FeatureServices"),
.product(name: "ExplorerService", package: "ChainServices"),
],
path: "Sources"
),
Expand All @@ -44,6 +48,7 @@ let package = Package(
dependencies: [
"FiatConnect",
.product(name: "PrimitivesTestKit", package: "Primitives"),
.product(name: "FiatTransactionServiceTestKit", package: "FeatureServices"),
],
path: "Tests"
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Primitives
import Store
import Components
import Localization
import Style

public struct FiatConnectNavigationView: View {
@State private var model: FiatSceneViewModel
Expand All @@ -29,6 +30,31 @@ public struct FiatConnectNavigationView: View {
$0.navigationTitle(model.title)
}
)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: Scenes.FiatTransactions(asset: model.asset)) {
Images.Tabs.activity
}
}
}
.navigationDestination(for: Scenes.FiatTransactions.self) {
FiatTransactionsScene(
model: FiatTransactionsViewModel(
walletId: model.walletId,
asset: $0.asset,
service: model.fiatTransactionService
)
)
}
.navigationDestination(for: Scenes.FiatTransaction.self) {
FiatTransactionDetailScene(
model: FiatTransactionDetailViewModel(
transaction: $0.transaction,
walletId: $0.walletId,
asset: $0.asset
)
)
}
.sheet(isPresented: $model.isPresentingFiatProvider) {
SelectableListNavigationStack(
model: model.fiatProviderViewModel,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Copyright (c). Gem Wallet. All rights reserved.

import SwiftUI
import Components
import Style
import PrimitivesComponents

public struct FiatTransactionDetailScene: View {
@State private var model: FiatTransactionDetailViewModel

public init(model: FiatTransactionDetailViewModel) {
_model = State(initialValue: model)
}

public var body: some View {
List {
TransactionHeaderListItemView(model: model.headerModel)

ForEach(model.sections) { section in
Section {
ForEach(section.values) { item in
content(for: model.item(for: item))
}
}
}
}
.contentMargins([.top], .small, for: .scrollContent)
.listSectionSpacing(.compact)
.background(Colors.grayBackground)
.bindQuery(model.query)
.navigationTitle(model.title)
}

@ViewBuilder
private func content(for itemModel: FiatTransactionDetailItemModel) -> some View {
switch itemModel {
case let .listItem(model):
ListItemView(model: model)
case let .listImageItem(title, subtitle, image):
ListItemImageView(title: title, subtitle: subtitle, assetImage: image)
case let .explorer(url, text):
SafariNavigationLink(url: url) {
Text(text)
.tint(Colors.black)
}
case .empty:
EmptyView()
}
}
}
44 changes: 44 additions & 0 deletions Features/FiatConnect/Sources/Scenes/FiatTransactionsScene.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c). Gem Wallet. All rights reserved.

import SwiftUI
import Components
import Primitives
import Store
import Style
import PrimitivesComponents

public struct FiatTransactionsScene: View {
@State private var model: FiatTransactionsViewModel

public init(model: FiatTransactionsViewModel) {
_model = State(initialValue: model)
}

public var body: some View {
List {
Section { } header: {
AssetPreviewView(model: model.assetModel)
.frame(maxWidth: .infinity)
.padding(.bottom, .small)
}
.cleanListRow()
ForEach(model.transactions) { transaction in
NavigationLink(value: Scenes.FiatTransaction(walletId: model.walletId, asset: model.asset, transaction: transaction)) {
ListItemView(model: FiatTransactionViewModel(transaction: transaction).listItemModel)
}
}
.listRowInsets(.assetListRowInsets)
}
.overlay {
if model.transactions.isEmpty {
EmptyContentView(model: model.emptyContentModel)
.padding(.horizontal, .medium)
}
}
.contentMargins(.top, .scene.top, for: .scrollContent)
.listSectionSpacing(.compact)
.bindQuery(model.query)
.navigationTitle(model.title)
.task { await model.fetch() }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c). Gem Wallet. All rights reserved.

import Foundation
import Components
import PrimitivesComponents

public enum FiatTransactionDetailSectionType: String, Identifiable, Equatable {
case details
case explorer

public var id: String { rawValue }
}

public enum FiatTransactionDetailItem: Identifiable, Equatable, Sendable {
case status
case provider
case explorerLink

public var id: Self { self }
}

public enum FiatTransactionDetailItemModel {
case listItem(ListItemModel)
case listImageItem(title: String, subtitle: String, image: AssetImage)
case explorer(url: URL, text: String)
case empty
}

extension FiatTransactionDetailItemModel: ItemModelProvidable {
public var itemModel: FiatTransactionDetailItemModel { self }
}

public extension ListSection where T == FiatTransactionDetailItem {
init(type: FiatTransactionDetailSectionType, _ items: [FiatTransactionDetailItem]) {
self.init(type: type, values: items)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import PrimitivesComponents
import Formatters
import Validators
import BigInt
import FiatTransactionService

@MainActor
@Observable
Expand All @@ -22,11 +23,12 @@ public final class FiatSceneViewModel {
static let suggestedAmounts: [Int] = [100, 250]
}

let walletId: WalletId
private let fiatService: any GemAPIFiatService
private let assetAddress: AssetAddress
private let currencyFormatter: CurrencyFormatter
private let valueFormatter = ValueFormatter(locale: .US, style: .medium)
private let walletId: WalletId
let fiatTransactionService: FiatTransactionService

public let assetQuery: ObservableQuery<AssetRequest>
var assetData: AssetData { assetQuery.value }
Expand All @@ -41,13 +43,15 @@ public final class FiatSceneViewModel {

public init(
fiatService: any GemAPIFiatService,
fiatTransactionService: FiatTransactionService,
currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencyCode: Currency.usd.rawValue),
assetAddress: AssetAddress,
walletId: WalletId,
type: FiatQuoteType = .buy,
amount: Int? = nil
) {
self.fiatService = fiatService
self.fiatTransactionService = fiatTransactionService
self.currencyFormatter = currencyFormatter
self.assetAddress = assetAddress
self.walletId = walletId
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c). Gem Wallet. All rights reserved.

import Components
import ExplorerService
import Formatters
import Foundation
import Localization
import Primitives
import PrimitivesComponents
import Store
import Style
import SwiftUI

@Observable
@MainActor
public final class FiatTransactionDetailViewModel {
let asset: Asset
private let explorerService: ExplorerService

public let query: ObservableQuery<FiatTransactionRequest>
var transaction: FiatTransaction { query.value }

public init(
transaction: FiatTransaction,
walletId: WalletId,
asset: Asset,
explorerService: ExplorerService = .standard
) {
self.asset = asset
self.explorerService = explorerService
self.query = ObservableQuery(FiatTransactionRequest(walletId: walletId, transaction: transaction), initialValue: transaction)
}

var title: String {
switch transaction.transactionType {
case .buy: Localized.Wallet.buy
case .sell: Localized.Wallet.sell
}
}

var headerModel: TransactionHeaderItemModel {
let color: Color = switch transaction.status {
case .failed: Colors.gray
case .pending, .complete, .unknown: Colors.white
}
let amountText = AmountDisplay.currency(
value: transaction.fiatAmount,
currencyCode: transaction.fiatCurrency,
textStyle: TextStyle(font: .body, color: color, fontWeight: .medium),
showSign: false
)
let display = FiatAmountDisplay(
amount: amountText,
assetImage: AssetIdViewModel(assetId: asset.id).assetImage
)
return TransactionHeaderItemModel(
headerType: .amount(.fiat(display)),
showClearHeader: true
)
}
}

// MARK: - ListSectionProvideable

extension FiatTransactionDetailViewModel: ListSectionProvideable {
public var sections: [ListSection<FiatTransactionDetailItem>] {
var sections = [ListSection(type: .details, [.status, .provider])]
if let _ = explorerLink {
sections.append(ListSection(type: .explorer, [.explorerLink]))
}
return sections
}

public func itemModel(for item: FiatTransactionDetailItem) -> any ItemModelProvidable<FiatTransactionDetailItemModel> {
switch item {
case .status: statusModel
case .provider: providerModel
case .explorerLink: explorerModel
}
}
}

// MARK: - Private

extension FiatTransactionDetailViewModel {
private var statusModel: FiatTransactionDetailItemModel {
let model = FiatTransactionStatusViewModel(status: transaction.status)
return .listItem(ListItemModel(
title: Localized.Transaction.status,
titleTagType: transaction.status == .pending ? .progressView() : .none,
subtitle: model.title,
subtitleStyle: TextStyle(font: .callout, color: model.color)
))
}

private var providerModel: FiatTransactionDetailItemModel {
.listImageItem(
title: Localized.Common.provider,
subtitle: transaction.providerId.displayName,
image: .image(transaction.providerId.image)
)
}

private var explorerLink: BlockExplorerLink? {
guard let hash = transaction.transactionHash else { return nil }
return explorerService.transactionUrl(chain: asset.chain, hash: hash)
}

private var explorerModel: FiatTransactionDetailItemModel {
guard let link = explorerLink else { return .empty }
return .explorer(url: link.url, text: Localized.Transaction.viewOn(link.name))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright (c). Gem Wallet. All rights reserved.

import Foundation
import FiatTransactionService
import Localization
import Primitives
import PrimitivesComponents
import Store

@Observable
@MainActor
public final class FiatTransactionsViewModel {
private let service: FiatTransactionService
let walletId: WalletId
let asset: Asset

public let query: ObservableQuery<FiatTransactionsRequest>
var transactions: [FiatTransaction] { query.value }
var assetModel: AssetViewModel { AssetViewModel(asset: asset) }

public init(walletId: WalletId, asset: Asset, service: FiatTransactionService) {
self.walletId = walletId
self.asset = asset
self.service = service
self.query = ObservableQuery(FiatTransactionsRequest(walletId: walletId, assetId: asset.id), initialValue: [])
}

var title: String { Localized.Activity.title }

var emptyContentModel: EmptyContentTypeViewModel {
EmptyContentTypeViewModel(type: .activity(isViewOnly: false))
}

func fetch() async {
do {
try await service.update(walletId: walletId)
} catch {
debugLog("FiatTransactionsViewModel fetch error: \(error)")
}
Comment on lines +37 to +39
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Silently catching and logging the error here means the user receives no feedback if fetching transactions fails. They will see an empty or stale list, which can be confusing. It's better to expose this error state to the view so it can display an appropriate error message. Consider adding a state property to the view model to track fetching errors.

}
}
Loading
Loading