diff --git a/Features/FiatConnect/Package.swift b/Features/FiatConnect/Package.swift index a0fc6b53c..99127209a 100644 --- a/Features/FiatConnect/Package.swift +++ b/Features/FiatConnect/Package.swift @@ -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( @@ -36,6 +38,8 @@ let package = Package( "Localization", "Store", "PrimitivesComponents", + .product(name: "FiatTransactionService", package: "FeatureServices"), + .product(name: "ExplorerService", package: "ChainServices"), ], path: "Sources" ), @@ -44,6 +48,7 @@ let package = Package( dependencies: [ "FiatConnect", .product(name: "PrimitivesTestKit", package: "Primitives"), + .product(name: "FiatTransactionServiceTestKit", package: "FeatureServices"), ], path: "Tests" ), diff --git a/Features/FiatConnect/Sources/Navigation/FiatConnectNavigationView.swift b/Features/FiatConnect/Sources/Navigation/FiatConnectNavigationView.swift index 471a9920e..1b7aa4c27 100644 --- a/Features/FiatConnect/Sources/Navigation/FiatConnectNavigationView.swift +++ b/Features/FiatConnect/Sources/Navigation/FiatConnectNavigationView.swift @@ -5,6 +5,7 @@ import Primitives import Store import Components import Localization +import Style public struct FiatConnectNavigationView: View { @State private var model: FiatSceneViewModel @@ -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, diff --git a/Features/FiatConnect/Sources/Scenes/FiatTransactionDetailScene.swift b/Features/FiatConnect/Sources/Scenes/FiatTransactionDetailScene.swift new file mode 100644 index 000000000..526ce497a --- /dev/null +++ b/Features/FiatConnect/Sources/Scenes/FiatTransactionDetailScene.swift @@ -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() + } + } +} diff --git a/Features/FiatConnect/Sources/Scenes/FiatTransactionsScene.swift b/Features/FiatConnect/Sources/Scenes/FiatTransactionsScene.swift new file mode 100644 index 000000000..550bc7bd6 --- /dev/null +++ b/Features/FiatConnect/Sources/Scenes/FiatTransactionsScene.swift @@ -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() } + } +} diff --git a/Features/FiatConnect/Sources/Types/FiatTransactionDetailSection.swift b/Features/FiatConnect/Sources/Types/FiatTransactionDetailSection.swift new file mode 100644 index 000000000..c69a9b66d --- /dev/null +++ b/Features/FiatConnect/Sources/Types/FiatTransactionDetailSection.swift @@ -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) + } +} diff --git a/Features/FiatConnect/Sources/ViewModels/FiatSceneViewModel.swift b/Features/FiatConnect/Sources/ViewModels/FiatSceneViewModel.swift index 6c7875dca..595a46553 100644 --- a/Features/FiatConnect/Sources/ViewModels/FiatSceneViewModel.swift +++ b/Features/FiatConnect/Sources/ViewModels/FiatSceneViewModel.swift @@ -12,6 +12,7 @@ import PrimitivesComponents import Formatters import Validators import BigInt +import FiatTransactionService @MainActor @Observable @@ -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 var assetData: AssetData { assetQuery.value } @@ -41,6 +43,7 @@ public final class FiatSceneViewModel { public init( fiatService: any GemAPIFiatService, + fiatTransactionService: FiatTransactionService, currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencyCode: Currency.usd.rawValue), assetAddress: AssetAddress, walletId: WalletId, @@ -48,6 +51,7 @@ public final class FiatSceneViewModel { amount: Int? = nil ) { self.fiatService = fiatService + self.fiatTransactionService = fiatTransactionService self.currencyFormatter = currencyFormatter self.assetAddress = assetAddress self.walletId = walletId diff --git a/Features/FiatConnect/Sources/ViewModels/FiatTransactionDetailViewModel.swift b/Features/FiatConnect/Sources/ViewModels/FiatTransactionDetailViewModel.swift new file mode 100644 index 000000000..77036608e --- /dev/null +++ b/Features/FiatConnect/Sources/ViewModels/FiatTransactionDetailViewModel.swift @@ -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 + 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] { + 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 { + 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)) + } +} diff --git a/Features/FiatConnect/Sources/ViewModels/FiatTransactionsViewModel.swift b/Features/FiatConnect/Sources/ViewModels/FiatTransactionsViewModel.swift new file mode 100644 index 000000000..95f404221 --- /dev/null +++ b/Features/FiatConnect/Sources/ViewModels/FiatTransactionsViewModel.swift @@ -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 + 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)") + } + } +} diff --git a/Features/FiatConnect/Tests/FiatSceneViewModelTests.swift b/Features/FiatConnect/Tests/FiatSceneViewModelTests.swift index 95c756d90..b9c5b442f 100644 --- a/Features/FiatConnect/Tests/FiatSceneViewModelTests.swift +++ b/Features/FiatConnect/Tests/FiatSceneViewModelTests.swift @@ -6,6 +6,8 @@ import GemAPI import Primitives import PrimitivesTestKit import Formatters +import FiatTransactionService +import FiatTransactionServiceTestKit import BigInt @testable import FiatConnect @@ -14,12 +16,14 @@ import BigInt final class FiatSceneViewModelTests { private static func mock( service: any GemAPIFiatService = GemAPIService(), + fiatTransactionService: FiatTransactionService = .mock(), currencyFormatter: CurrencyFormatter = .init(locale: Locale.US, currencyCode: Currency.usd.rawValue), assetAddress: AssetAddress = .mock(), walletId: WalletId = .mock() ) -> FiatSceneViewModel { FiatSceneViewModel( fiatService: service, + fiatTransactionService: fiatTransactionService, currencyFormatter: currencyFormatter, assetAddress: assetAddress, walletId: walletId diff --git a/Features/Transfer/Package.swift b/Features/Transfer/Package.swift index 3c1fefc2b..f674b0bb9 100644 --- a/Features/Transfer/Package.swift +++ b/Features/Transfer/Package.swift @@ -81,7 +81,8 @@ let package = Package( .product(name: "ExplorerService", package: "ChainServices"), .product(name: "NameService", package: "ChainServices"), .product(name: "AddressNameService", package: "FeatureServices"), - .product(name: "ActivityService", package: "FeatureServices") + .product(name: "ActivityService", package: "FeatureServices"), + .product(name: "FiatTransactionService", package: "FeatureServices") ], path: "Sources" ), @@ -120,6 +121,7 @@ let package = Package( .product(name: "NFTServiceTestKit", package: "FeatureServices"), .product(name: "SignerTestKit", package: "Signer"), .product(name: "EarnServiceTestKit", package: "FeatureServices"), + .product(name: "FiatTransactionServiceTestKit", package: "FeatureServices"), .product(name: "EventPresenterServiceTestKit", package: "EventPresenterService"), ], path: "Tests" diff --git a/Features/Transfer/Sources/Navigation/AmountNavigationView.swift b/Features/Transfer/Sources/Navigation/AmountNavigationView.swift index 868c083c1..a2d7e27c0 100644 --- a/Features/Transfer/Sources/Navigation/AmountNavigationView.swift +++ b/Features/Transfer/Sources/Navigation/AmountNavigationView.swift @@ -30,7 +30,7 @@ public struct AmountNavigationView: View { case let .fiatConnect(assetAddress, walletId): NavigationStack { FiatConnectNavigationView( - model: FiatSceneViewModel(fiatService: fiatService, assetAddress: assetAddress, walletId: walletId) + model: FiatSceneViewModel(fiatService: fiatService, fiatTransactionService: model.fiatTransactionService, assetAddress: assetAddress, walletId: walletId) ) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarDismissItem(type: .close, placement: .topBarLeading) } diff --git a/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift b/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift index fabf6d271..6e2f4e14d 100644 --- a/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift +++ b/Features/Transfer/Sources/Scenes/ConfirmTransferScene.swift @@ -64,6 +64,7 @@ public struct ConfirmTransferScene: View { FiatConnectNavigationView( model: FiatSceneViewModel( fiatService: fiatService, + fiatTransactionService: model.fiatTransactionService, assetAddress: assetAddress, walletId: walletId ) diff --git a/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift b/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift index 5de124f26..fb4df1bb9 100644 --- a/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/AmountSceneViewModel.swift @@ -2,6 +2,7 @@ import BigInt import Components +import FiatTransactionService import Formatters import Foundation import InfoSheet @@ -19,6 +20,7 @@ import Validators public final class AmountSceneViewModel { private let wallet: Wallet private let onTransferAction: TransferDataAction + let fiatTransactionService: FiatTransactionService private let formatter = ValueFormatter(style: .full) private let valueConverter = ValueConverter() @@ -40,11 +42,13 @@ public final class AmountSceneViewModel { input: AmountInput, wallet: Wallet, service: AmountService, + fiatTransactionService: FiatTransactionService, preferences: Preferences = .standard, onTransferAction: TransferDataAction ) { self.wallet = wallet self.onTransferAction = onTransferAction + self.fiatTransactionService = fiatTransactionService self.currencyFormatter = CurrencyFormatter(type: .currency, currencyCode: preferences.currency) self.provider = .make(from: input, wallet: wallet, service: service) self.assetQuery = ObservableQuery(AssetRequest(walletId: wallet.walletId, assetId: input.asset.id), initialValue: .with(asset: input.asset)) diff --git a/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift b/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift index adf84d0d3..2eff5a2f2 100644 --- a/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift +++ b/Features/Transfer/Sources/ViewModels/ConfirmTransferSceneViewModel.swift @@ -11,11 +11,13 @@ import WalletConnector import InfoSheet import Validators import SwiftUI +import FiatTransactionService import Swap @Observable @MainActor public final class ConfirmTransferSceneViewModel { + let fiatTransactionService: FiatTransactionService var feeModel: NetworkFeeSceneViewModel var state: StateViewType = .loading { didSet { @@ -56,6 +58,7 @@ public final class ConfirmTransferSceneViewModel { data: TransferData, confirmService: ConfirmService, simulationService: ConfirmSimulationService, + fiatTransactionService: FiatTransactionService, confirmTransferDelegate: TransferDataCallback.ConfirmTransferDelegate? = .none, simulation: SimulationResult? = nil, onComplete: VoidAction @@ -64,6 +67,7 @@ public final class ConfirmTransferSceneViewModel { self.transferData = data self.confirmService = confirmService self.simulationService = simulationService + self.fiatTransactionService = fiatTransactionService self.confirmTransferDelegate = confirmTransferDelegate self.simulation = simulation self.onComplete = onComplete diff --git a/Features/Transfer/Tests/ViewModels/AmountSceneViewModelTests.swift b/Features/Transfer/Tests/ViewModels/AmountSceneViewModelTests.swift index ccd39f480..ff130f6fd 100644 --- a/Features/Transfer/Tests/ViewModels/AmountSceneViewModelTests.swift +++ b/Features/Transfer/Tests/ViewModels/AmountSceneViewModelTests.swift @@ -4,6 +4,7 @@ import Testing import PrimitivesTestKit import Primitives import EarnServiceTestKit +import FiatTransactionServiceTestKit @testable import Transfer @testable import Store @@ -160,6 +161,7 @@ extension AmountSceneViewModel { input: AmountInput(type: type, asset: assetData.asset), wallet: .mock(), service: AmountService(earnDataProvider: MockEarnService()), + fiatTransactionService: .mock(), onTransferAction: { _ in } ) model.assetQuery.value = assetData diff --git a/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift b/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift index 47e458914..ea2a0df33 100644 --- a/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift +++ b/Features/Transfer/Tests/ViewModels/ConfirmTransferSceneViewModelTests.swift @@ -23,6 +23,7 @@ import AddressNameServiceTestKit import ActivityServiceTestKit import EventPresenterService import EventPresenterServiceTestKit +import FiatTransactionServiceTestKit import Store import BigInt import Components @@ -472,6 +473,7 @@ private extension ConfirmTransferSceneViewModel { addressNameService: addressNameService, assetsService: .mock() ), + fiatTransactionService: .mock(), simulation: simulation, onComplete: {} ) diff --git a/Gem.xcodeproj/project.pbxproj b/Gem.xcodeproj/project.pbxproj index 81b88fcd0..492a7a730 100644 --- a/Gem.xcodeproj/project.pbxproj +++ b/Gem.xcodeproj/project.pbxproj @@ -140,6 +140,7 @@ DF93C4EE2C969F4F00204ABD /* Gemstone in Frameworks */ = {isa = PBXBuildFile; productRef = DF93C4ED2C969F4F00204ABD /* Gemstone */; }; E1B4C7052E85A00000YIELD1 /* EarnService in Frameworks */ = {isa = PBXBuildFile; productRef = E1B4C7062E85A00000YIELD1 /* EarnService */; }; E1B4C7092E85B00000EARN02 /* Stake in Frameworks */ = {isa = PBXBuildFile; productRef = E1B4C70A2E85B00000EARN03 /* Stake */; }; + F1A7B80C2F0C000000FIAT01 /* FiatTransactionService in Frameworks */ = {isa = PBXBuildFile; productRef = F1A7B80D2F0C000000FIAT01 /* FiatTransactionService */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -336,6 +337,7 @@ D8F5B86F2CCD639A0007E615 /* StakeService in Frameworks */, E1B4C7052E85A00000YIELD1 /* EarnService in Frameworks */, E1B4C7092E85B00000EARN02 /* Stake in Frameworks */, + F1A7B80C2F0C000000FIAT01 /* FiatTransactionService in Frameworks */, D829BF512CBF2C4900DEB2E8 /* NotificationService in Frameworks */, 8349F1472D5E4FF4003A0A93 /* ManageWallets in Frameworks */, 8361BB612D3FE5F2008D89CF /* Transactions in Frameworks */, @@ -823,6 +825,7 @@ D8F5B86E2CCD639A0007E615 /* StakeService */, E1B4C7062E85A00000YIELD1 /* EarnService */, E1B4C70A2E85B00000EARN03 /* Stake */, + F1A7B80D2F0C000000FIAT01 /* FiatTransactionService */, D85AD29C2CD337BE0010DEF8 /* SwapService */, D85AD2A52CD7017E0010DEF8 /* NativeProviderService */, D8D83C832CECFA610083AA53 /* Swap */, @@ -2249,6 +2252,10 @@ isa = XCSwiftPackageProductDependency; productName = EarnService; }; + F1A7B80D2F0C000000FIAT01 /* FiatTransactionService */ = { + isa = XCSwiftPackageProductDependency; + productName = FiatTransactionService; + }; E1B4C70A2E85B00000EARN03 /* Stake */ = { isa = XCSwiftPackageProductDependency; productName = Stake; diff --git a/Gem/Services/AppResolver+Services.swift b/Gem/Services/AppResolver+Services.swift index 901475f43..04672e495 100644 --- a/Gem/Services/AppResolver+Services.swift +++ b/Gem/Services/AppResolver+Services.swift @@ -33,6 +33,7 @@ import EventPresenterService import NotificationService import GemAPI import ContactService +import FiatTransactionService extension AppResolver { struct Services: Sendable { @@ -86,6 +87,7 @@ extension AppResolver { let inAppNotificationService: InAppNotificationService let portfolioService: PortfolioService let fiatService: any GemAPIFiatService + let fiatTransactionService: FiatTransactionService let contactService: ContactService init( @@ -138,6 +140,7 @@ extension AppResolver { inAppNotificationService: InAppNotificationService, portfolioService: PortfolioService, fiatService: any GemAPIFiatService, + fiatTransactionService: FiatTransactionService, contactService: ContactService ) { self.assetsService = assetsService @@ -190,6 +193,7 @@ extension AppResolver { self.inAppNotificationService = inAppNotificationService self.portfolioService = portfolioService self.fiatService = fiatService + self.fiatTransactionService = fiatTransactionService self.contactService = contactService } } diff --git a/Gem/Services/AppResolver+ViewInjection.swift b/Gem/Services/AppResolver+ViewInjection.swift index 47c707f08..d256a9e76 100644 --- a/Gem/Services/AppResolver+ViewInjection.swift +++ b/Gem/Services/AppResolver+ViewInjection.swift @@ -2,6 +2,7 @@ import SwiftUI import Store +import FiatConnect extension View { func inject(resolver: AppResolver) -> some View { diff --git a/Gem/Services/ServicesFactory.swift b/Gem/Services/ServicesFactory.swift index 4d41da456..a5b993b13 100644 --- a/Gem/Services/ServicesFactory.swift +++ b/Gem/Services/ServicesFactory.swift @@ -45,6 +45,7 @@ import EarnService import Transfer import SwiftHTTPClient import ContactService +import FiatTransactionService import WebSocketClient @@ -289,6 +290,10 @@ struct ServicesFactory { ) let contactService = ContactService(store: storeManager.contactStore, addressStore: storeManager.addressStore) + let fiatTransactionService = FiatTransactionService( + apiService: apiService, + store: storeManager.fiatTransactionStore + ) let appLifecycleService = AppLifecycleService( preferences: preferences, @@ -318,6 +323,7 @@ struct ServicesFactory { activityService: activityService, eventPresenterService: eventPresenterService, fiatService: apiService, + fiatTransactionService: fiatTransactionService, assetsService: assetsService ) @@ -371,6 +377,7 @@ struct ServicesFactory { inAppNotificationService: inAppNotificationService, portfolioService: portfolioService, fiatService: apiService, + fiatTransactionService: fiatTransactionService, contactService: contactService ) } diff --git a/Gem/Services/ViewModelFactory.swift b/Gem/Services/ViewModelFactory.swift index 3365a4870..b7db65e7f 100644 --- a/Gem/Services/ViewModelFactory.swift +++ b/Gem/Services/ViewModelFactory.swift @@ -28,6 +28,7 @@ import Preferences import PrimitivesComponents import GemAPI import AssetsService +import FiatTransactionService public struct ViewModelFactory: Sendable { let keystore: any Keystore @@ -48,6 +49,7 @@ public struct ViewModelFactory: Sendable { let activityService: ActivityService let eventPresenterService: EventPresenterService let fiatService: any GemAPIFiatService + let fiatTransactionService: FiatTransactionService let assetsService: AssetsService public init( @@ -69,6 +71,7 @@ public struct ViewModelFactory: Sendable { activityService: ActivityService, eventPresenterService: EventPresenterService, fiatService: any GemAPIFiatService, + fiatTransactionService: FiatTransactionService, assetsService: AssetsService ) { self.keystore = keystore @@ -89,6 +92,7 @@ public struct ViewModelFactory: Sendable { self.activityService = activityService self.eventPresenterService = eventPresenterService self.fiatService = fiatService + self.fiatTransactionService = fiatTransactionService self.assetsService = assetsService } @@ -124,6 +128,7 @@ public struct ViewModelFactory: Sendable { data: data, confirmService: confirmService, simulationService: simulationService, + fiatTransactionService: fiatTransactionService, confirmTransferDelegate: confirmTransferDelegate, simulation: simulation, onComplete: onComplete @@ -159,6 +164,7 @@ public struct ViewModelFactory: Sendable { input: input, wallet: wallet, service: amountService, + fiatTransactionService: fiatTransactionService, onTransferAction: onTransferAction ) } @@ -172,6 +178,7 @@ public struct ViewModelFactory: Sendable { ) -> FiatSceneViewModel { FiatSceneViewModel( fiatService: fiatService, + fiatTransactionService: fiatTransactionService, assetAddress: assetAddress, walletId: walletId, type: type, diff --git a/Packages/FeatureServices/FiatTransactionService/FiatTransactionService.swift b/Packages/FeatureServices/FiatTransactionService/FiatTransactionService.swift new file mode 100644 index 000000000..c9393074d --- /dev/null +++ b/Packages/FeatureServices/FiatTransactionService/FiatTransactionService.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import GemAPI +import Store + +public struct FiatTransactionService: Sendable { + private let apiService: any GemAPIFiatService + public let store: FiatTransactionStore + + public init( + apiService: any GemAPIFiatService, + store: FiatTransactionStore + ) { + self.apiService = apiService + self.store = store + } + + public func update(walletId: WalletId) async throws { + let transactions = try await apiService.getFiatTransactions(walletId: walletId.id) + try store.addTransactions(walletId: walletId, transactions: transactions) + } +} diff --git a/Packages/FeatureServices/FiatTransactionService/TestKit/FiatTransactionService+TestKit.swift b/Packages/FeatureServices/FiatTransactionService/TestKit/FiatTransactionService+TestKit.swift new file mode 100644 index 000000000..babc6ac56 --- /dev/null +++ b/Packages/FeatureServices/FiatTransactionService/TestKit/FiatTransactionService+TestKit.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import FiatTransactionService +import GemAPITestKit +import StoreTestKit + +public extension FiatTransactionService { + static func mock() -> FiatTransactionService { + FiatTransactionService( + apiService: GemAPIFiatServiceMock(), + store: .mock() + ) + } +} diff --git a/Packages/FeatureServices/Package.swift b/Packages/FeatureServices/Package.swift index 30a861dc8..e292e141c 100644 --- a/Packages/FeatureServices/Package.swift +++ b/Packages/FeatureServices/Package.swift @@ -57,6 +57,8 @@ let package = Package( .library(name: "ContactService", targets: ["ContactService"]), .library(name: "EarnService", targets: ["EarnService"]), .library(name: "EarnServiceTestKit", targets: ["EarnServiceTestKit"]), + .library(name: "FiatTransactionService", targets: ["FiatTransactionService"]), + .library(name: "FiatTransactionServiceTestKit", targets: ["FiatTransactionServiceTestKit"]), ], dependencies: [ .package(name: "Primitives", path: "../Primitives"), @@ -632,6 +634,25 @@ let package = Package( ], path: "EarnService/TestKit" ), + .target( + name: "FiatTransactionService", + dependencies: [ + "Primitives", + "GemAPI", + "Store", + ], + path: "FiatTransactionService", + exclude: ["TestKit"] + ), + .target( + name: "FiatTransactionServiceTestKit", + dependencies: [ + "FiatTransactionService", + .product(name: "GemAPITestKit", package: "GemAPI"), + .product(name: "StoreTestKit", package: "Store"), + ], + path: "FiatTransactionService/TestKit" + ), .testTarget( name: "PriceAlertServiceTests", dependencies: [ diff --git a/Packages/GemAPI/Sources/GemAPIService.swift b/Packages/GemAPI/Sources/GemAPIService.swift index 57207042e..eafefa47d 100644 --- a/Packages/GemAPI/Sources/GemAPIService.swift +++ b/Packages/GemAPI/Sources/GemAPIService.swift @@ -11,6 +11,7 @@ public protocol GemAPIConfigService: Sendable { public protocol GemAPIFiatService: Sendable { func getQuotes(walletId: String, type: FiatQuoteType, assetId: AssetId, request: FiatQuoteRequest) async throws -> [FiatQuote] func getQuoteUrl(walletId: String, quoteId: String) async throws -> FiatQuoteUrl + func getFiatTransactions(walletId: String) async throws -> [FiatTransaction] } public protocol GemAPIPricesService: Sendable { @@ -145,6 +146,11 @@ extension GemAPIService: GemAPIFiatService { try await requestDevice(.getFiatQuoteUrl(walletId: walletId, quoteId: quoteId)) .mapResponse(as: FiatQuoteUrl.self) } + + public func getFiatTransactions(walletId: String) async throws -> [FiatTransaction] { + try await requestDevice(.getFiatTransactions(walletId: walletId)) + .mapResponse(as: [FiatTransaction].self) + } } extension GemAPIService: GemAPIConfigService { diff --git a/Packages/GemAPI/Sources/GemDeviceAPI.swift b/Packages/GemAPI/Sources/GemDeviceAPI.swift index 701853824..52b532a93 100644 --- a/Packages/GemAPI/Sources/GemDeviceAPI.swift +++ b/Packages/GemAPI/Sources/GemDeviceAPI.swift @@ -42,6 +42,7 @@ public enum GemDeviceAPI: TargetType { case getFiatAssets(FiatQuoteType) case getFiatQuotes(walletId: String, type: FiatQuoteType, assetId: AssetId, request: FiatQuoteRequest) case getFiatQuoteUrl(walletId: String, quoteId: String) + case getFiatTransactions(walletId: String) case getNameRecord(name: String, chain: String) case getAddressNames(requests: [ChainAddress]) @@ -70,6 +71,7 @@ public enum GemDeviceAPI: TargetType { .getFiatAssets, .getFiatQuotes, .getFiatQuoteUrl, + .getFiatTransactions, .getNameRecord: return .GET case .addDevice, @@ -151,6 +153,8 @@ public enum GemDeviceAPI: TargetType { return "/v2/devices/fiat/quotes/\(type.rawValue)/\(assetId.identifier)" case .getFiatQuoteUrl(_, let quoteId): return "/v2/devices/fiat/quotes/\(quoteId)/url" + case .getFiatTransactions: + return "/v2/devices/fiat/transactions" case .getNameRecord(let name, let chain): return "/v2/devices/name/resolve/\(name)?chain=\(chain)" case .getAddressNames: @@ -175,7 +179,8 @@ public enum GemDeviceAPI: TargetType { .useDeviceReferralCode(let walletId, _), .redeemDeviceRewards(let walletId, _), .getFiatQuotes(let walletId, _, _, _), - .getFiatQuoteUrl(let walletId, _): + .getFiatQuoteUrl(let walletId, _), + .getFiatTransactions(let walletId): return walletId default: return nil @@ -199,6 +204,7 @@ public enum GemDeviceAPI: TargetType { .isDeviceRegistered, .getFiatAssets, .getFiatQuoteUrl, + .getFiatTransactions, .getNameRecord: return .plain case .getPriceAlerts(let assetId): diff --git a/Packages/GemAPI/TestKit/GemAPIFiatService+TestKit.swift b/Packages/GemAPI/TestKit/GemAPIFiatService+TestKit.swift index 7e0fab253..61aadf03a 100644 --- a/Packages/GemAPI/TestKit/GemAPIFiatService+TestKit.swift +++ b/Packages/GemAPI/TestKit/GemAPIFiatService+TestKit.swift @@ -7,13 +7,16 @@ import Primitives public struct GemAPIFiatServiceMock: GemAPIFiatService { private let quotes: [FiatQuote] private let quoteUrl: FiatQuoteUrl + private let fiatTransactions: [FiatTransaction] public init( quotes: [FiatQuote] = [], - quoteUrl: FiatQuoteUrl = FiatQuoteUrl(redirectUrl: "") + quoteUrl: FiatQuoteUrl = FiatQuoteUrl(redirectUrl: ""), + fiatTransactions: [FiatTransaction] = [] ) { self.quotes = quotes self.quoteUrl = quoteUrl + self.fiatTransactions = fiatTransactions } public func getQuotes(walletId: String, type: FiatQuoteType, assetId: AssetId, request: FiatQuoteRequest) async throws -> [FiatQuote] { @@ -23,4 +26,8 @@ public struct GemAPIFiatServiceMock: GemAPIFiatService { public func getQuoteUrl(walletId: String, quoteId: String) async throws -> FiatQuoteUrl { quoteUrl } + + public func getFiatTransactions(walletId: String) async throws -> [FiatTransaction] { + fiatTransactions + } } diff --git a/Packages/Primitives/Sources/Extensions/FiatTransaction+Primitives.swift b/Packages/Primitives/Sources/Extensions/FiatTransaction+Primitives.swift new file mode 100644 index 000000000..0dab00cb2 --- /dev/null +++ b/Packages/Primitives/Sources/Extensions/FiatTransaction+Primitives.swift @@ -0,0 +1,14 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +extension FiatTransaction: Identifiable, Hashable { + public var id: String { + "\(providerId.rawValue)_\(providerTransactionId)" + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(providerId) + hasher.combine(providerTransactionId) + } +} diff --git a/Packages/Primitives/Sources/FiatTransaction.swift b/Packages/Primitives/Sources/FiatTransaction.swift index ffb30a86d..04c3f85e2 100644 --- a/Packages/Primitives/Sources/FiatTransaction.swift +++ b/Packages/Primitives/Sources/FiatTransaction.swift @@ -8,3 +8,27 @@ public enum FiatQuoteType: String, Codable, Equatable, Hashable, Sendable { case buy case sell } + +public struct FiatTransaction: Codable, Equatable, Sendable { + public let assetId: AssetId? + public let transactionType: FiatQuoteType + public let providerId: FiatProviderName + public let providerTransactionId: String + public let status: FiatTransactionStatus + public let fiatAmount: Double + public let fiatCurrency: String + public let transactionHash: String? + public let address: String? + + public init(assetId: AssetId?, transactionType: FiatQuoteType, providerId: FiatProviderName, providerTransactionId: String, status: FiatTransactionStatus, fiatAmount: Double, fiatCurrency: String, transactionHash: String?, address: String?) { + self.assetId = assetId + self.transactionType = transactionType + self.providerId = providerId + self.providerTransactionId = providerTransactionId + self.status = status + self.fiatAmount = fiatAmount + self.fiatCurrency = fiatCurrency + self.transactionHash = transactionHash + self.address = address + } +} diff --git a/Packages/Primitives/Sources/FiatTransactionData.swift b/Packages/Primitives/Sources/FiatTransactionData.swift new file mode 100644 index 000000000..4b560fb76 --- /dev/null +++ b/Packages/Primitives/Sources/FiatTransactionData.swift @@ -0,0 +1,45 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public enum FiatTransactionStatus: Codable, Equatable, Hashable, Sendable { + case complete + case pending + case failed + case unknown(String) + + private enum CodingKeys: String { + case complete + case pending + case failed + } + + public var rawValue: String { + switch self { + case .complete: CodingKeys.complete.rawValue + case .pending: CodingKeys.pending.rawValue + case .failed: CodingKeys.failed.rawValue + case .unknown(let value): value + } + } + + public init(rawValue: String) { + switch CodingKeys(rawValue: rawValue) { + case .complete: self = .complete + case .pending: self = .pending + case .failed: self = .failed + case .none: self = .unknown(rawValue) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + self.init(rawValue: value) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } +} diff --git a/Packages/Primitives/Sources/Scenes.swift b/Packages/Primitives/Sources/Scenes.swift index 0ff607be3..43b213560 100644 --- a/Packages/Primitives/Sources/Scenes.swift +++ b/Packages/Primitives/Sources/Scenes.swift @@ -222,4 +222,24 @@ public struct Scenes { self.address = address } } + + public struct FiatTransactions: Hashable, Codable { + public let asset: Primitives.Asset + + public init(asset: Primitives.Asset) { + self.asset = asset + } + } + + public struct FiatTransaction: Hashable, Codable { + public let walletId: WalletId + public let asset: Primitives.Asset + public let transaction: Primitives.FiatTransaction + + public init(walletId: WalletId, asset: Primitives.Asset, transaction: Primitives.FiatTransaction) { + self.walletId = walletId + self.asset = asset + self.transaction = transaction + } + } } diff --git a/Packages/Primitives/TestKit/FiatTransaction+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/FiatTransaction+PrimitivesTestKit.swift new file mode 100644 index 000000000..81b469ad4 --- /dev/null +++ b/Packages/Primitives/TestKit/FiatTransaction+PrimitivesTestKit.swift @@ -0,0 +1,30 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +extension FiatTransaction { + public static func mock( + assetId: AssetId? = .mock(), + transactionType: FiatQuoteType = .buy, + providerId: FiatProviderName = .moonPay, + providerTransactionId: String = "mock_tx_123", + status: FiatTransactionStatus = .complete, + fiatAmount: Double = 100.0, + fiatCurrency: String = "USD", + transactionHash: String? = nil, + address: String? = "0x1234567890abcdef" + ) -> FiatTransaction { + FiatTransaction( + assetId: assetId, + transactionType: transactionType, + providerId: providerId, + providerTransactionId: providerTransactionId, + status: status, + fiatAmount: fiatAmount, + fiatCurrency: fiatCurrency, + transactionHash: transactionHash, + address: address + ) + } +} \ No newline at end of file diff --git a/Packages/PrimitivesComponents/Sources/Extensions/FiatProviderName+PrimitivesComponents.swift b/Packages/PrimitivesComponents/Sources/Extensions/FiatProviderName+PrimitivesComponents.swift new file mode 100644 index 000000000..e066c5755 --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/Extensions/FiatProviderName+PrimitivesComponents.swift @@ -0,0 +1,29 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Primitives +import Style +import SwiftUI + +public extension FiatProviderName { + var image: Image { + switch self { + case .moonPay: Images.Fiat.moonpay + case .transak: Images.Fiat.transak + case .banxa: Images.Fiat.banxa + case .mercuryo: Images.Fiat.mercuryo + case .paybis: Images.Fiat.paybis + case .flashnet: Images.Fiat.cashapp + } + } + + var displayName: String { + switch self { + case .moonPay: "MoonPay" + case .transak: "Transak" + case .banxa: "Banxa" + case .mercuryo: "Mercuryo" + case .paybis: "Paybis" + case .flashnet: "Flashnet" + } + } +} diff --git a/Packages/PrimitivesComponents/Sources/Types/AmountDisplay.swift b/Packages/PrimitivesComponents/Sources/Types/AmountDisplay.swift index 7c79b7552..6457faba0 100644 --- a/Packages/PrimitivesComponents/Sources/Types/AmountDisplay.swift +++ b/Packages/PrimitivesComponents/Sources/Types/AmountDisplay.swift @@ -47,6 +47,7 @@ public struct AmountDisplayStyle: Sendable { public enum AmountDisplay: Sendable { case numeric(NumericViewModel) case symbol(SymbolViewModel) + case fiat(FiatAmountDisplay) } extension AmountDisplay: AmountDisplayable { @@ -54,6 +55,7 @@ extension AmountDisplay: AmountDisplayable { switch self { case .numeric(let viewModel): viewModel.amount case .symbol(let viewModel): viewModel.amount + case .fiat(let viewModel): viewModel.amount } } @@ -61,6 +63,7 @@ extension AmountDisplay: AmountDisplayable { switch self { case .numeric(let viewModel): viewModel.fiat case .symbol(let viewModel): viewModel.fiat + case .fiat(let viewModel): viewModel.fiat } } @@ -68,6 +71,7 @@ extension AmountDisplay: AmountDisplayable { switch self { case .numeric(let viewModel): viewModel.assetImage case .symbol(let viewModel): viewModel.assetImage + case .fiat(let viewModel): viewModel.assetImage } } @@ -89,7 +93,7 @@ extension AmountDisplay: AmountDisplayable { style: style ) ) - case .symbol: + case .symbol, .fiat: return self } } @@ -131,7 +135,7 @@ extension AmountDisplay { ) } - static func currency( + public static func currency( value: Double, currencyCode: String, textStyle: TextStyle? = nil, diff --git a/Packages/PrimitivesComponents/Sources/Types/FiatAmountDisplay.swift b/Packages/PrimitivesComponents/Sources/Types/FiatAmountDisplay.swift new file mode 100644 index 000000000..ff542d065 --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/Types/FiatAmountDisplay.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Style + +public struct FiatAmountDisplay: @unchecked Sendable, AmountDisplayable { + public let amount: TextValue + public let fiat: TextValue? = nil + public let assetImage: AssetImage? + + public init(amount: TextValue, assetImage: AssetImage?) { + self.amount = amount + self.assetImage = assetImage + } +} diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/FiatTransactionStatusViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/FiatTransactionStatusViewModel.swift new file mode 100644 index 000000000..d7e2e2d98 --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/ViewModels/FiatTransactionStatusViewModel.swift @@ -0,0 +1,42 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import Style +import SwiftUI +import Localization + +public struct FiatTransactionStatusViewModel: Sendable { + let status: FiatTransactionStatus + + public init(status: FiatTransactionStatus) { + self.status = status + } + + public var title: String { + switch status { + case .complete: Localized.Transaction.Status.confirmed + case .pending: Localized.Transaction.Status.pending + case .failed: Localized.Transaction.Status.failed + case .unknown(let value): value + } + } + + public var color: Color { + switch status { + case .complete: Colors.green + case .pending: Colors.orange + case .failed: Colors.red + case .unknown: Colors.gray + } + } + + public var background: Color { + switch status { + case .complete: Colors.green.opacity(.light) + case .pending: Colors.orange.opacity(.light) + case .failed: Colors.red.opacity(.light) + case .unknown: Colors.gray.opacity(.light) + } + } +} diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/FiatTransactionViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/FiatTransactionViewModel.swift new file mode 100644 index 000000000..4c187d3ce --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/ViewModels/FiatTransactionViewModel.swift @@ -0,0 +1,77 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Components +import Formatters +import Foundation +import Localization +import Primitives +import Style +import SwiftUI + +public struct FiatTransactionViewModel: Sendable { + public let transaction: FiatTransaction + + public init(transaction: FiatTransaction) { + self.transaction = transaction + } + + public var listItemModel: ListItemModel { + let statusModel = FiatTransactionStatusViewModel(status: transaction.status) + return ListItemModel( + title: title, + titleStyle: TextStyle(font: Font.system(.body, weight: .medium), color: .primary), + titleTag: titleTag(statusModel), + titleTagStyle: titleTagStyle(statusModel), + titleTagType: titleTagType, + titleExtra: transaction.providerId.displayName, + titleStyleExtra: .footnote, + subtitle: subtitle, + subtitleStyle: TextStyle(font: .body, color: subtitleColor, fontWeight: .medium), + imageStyle: .asset(assetImage: .image(transaction.providerId.image)) + ) + } +} + +// MARK: - Private + +extension FiatTransactionViewModel { + private var title: String { + switch transaction.transactionType { + case .buy: Localized.Wallet.buy + case .sell: Localized.Wallet.sell + } + } + + private var titleTagType: TitleTagType { + switch transaction.status { + case .pending: .progressView() + case .complete, .failed, .unknown: .none + } + } + + private func titleTag(_ model: FiatTransactionStatusViewModel) -> String? { + switch transaction.status { + case .complete: .none + case .pending, .failed, .unknown: model.title + } + } + + private func titleTagStyle(_ model: FiatTransactionStatusViewModel) -> TextStyle { + TextStyle( + font: Font.system(.footnote, weight: .medium), + color: model.color, + background: model.background + ) + } + + private var subtitle: String { + CurrencyFormatter(currencyCode: transaction.fiatCurrency).string(transaction.fiatAmount) + } + + private var subtitleColor: Color { + switch transaction.status { + case .failed: Colors.gray + case .pending, .complete, .unknown: Colors.black + } + } +} diff --git a/Packages/Store/Sources/Migrations.swift b/Packages/Store/Sources/Migrations.swift index e8866c25f..e58a9f28c 100644 --- a/Packages/Store/Sources/Migrations.swift +++ b/Packages/Store/Sources/Migrations.swift @@ -72,6 +72,7 @@ struct Migrations { try RecentActivityRecord.create(db: db) try SearchRecord.create(db: db) try NotificationRecord.create(db: db) + try FiatTransactionRecord.create(db: db) } try migrator.migrate(dbQueue) } @@ -443,6 +444,10 @@ struct Migrations { } } + migrator.registerMigration("Create \(FiatTransactionRecord.databaseTableName)") { db in + try? FiatTransactionRecord.create(db: db) + } + try migrator.migrate(dbQueue) } } diff --git a/Packages/Store/Sources/Models/FiatTransactionRecord.swift b/Packages/Store/Sources/Models/FiatTransactionRecord.swift new file mode 100644 index 000000000..8fa098dd9 --- /dev/null +++ b/Packages/Store/Sources/Models/FiatTransactionRecord.swift @@ -0,0 +1,98 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import GRDB +import Primitives + +struct FiatTransactionRecord: Codable, FetchableRecord, PersistableRecord, Sendable { + + static let databaseTableName: String = "fiat_transactions" + + enum Columns { + static let walletId = Column("walletId") + static let assetId = Column("assetId") + static let transactionType = Column("transactionType") + static let providerId = Column("providerId") + static let providerTransactionId = Column("providerTransactionId") + static let status = Column("status") + static let fiatAmount = Column("fiatAmount") + static let fiatCurrency = Column("fiatCurrency") + static let transactionHash = Column("transactionHash") + static let address = Column("address") + } + + var walletId: String + var assetId: AssetId? + var transactionType: FiatQuoteType + var providerId: FiatProviderName + var providerTransactionId: String + var status: FiatTransactionStatus + var fiatAmount: Double + var fiatCurrency: String + var transactionHash: String? + var address: String? +} + +extension FiatTransactionRecord: CreateTable { + static func create(db: Database) throws { + try db.create(table: Self.databaseTableName, ifNotExists: true) { + $0.column(Columns.walletId.name, .text) + .notNull() + .indexed() + .references(WalletRecord.databaseTableName, onDelete: .cascade, onUpdate: .cascade) + $0.column(Columns.assetId.name, .text) + $0.column(Columns.transactionType.name, .text) + .notNull() + $0.column(Columns.providerId.name, .text) + .notNull() + $0.column(Columns.providerTransactionId.name, .text) + .notNull() + $0.column(Columns.status.name, .text) + .notNull() + $0.column(Columns.fiatAmount.name, .double) + .notNull() + $0.column(Columns.fiatCurrency.name, .text) + .notNull() + $0.column(Columns.transactionHash.name, .text) + $0.column(Columns.address.name, .text) + $0.primaryKey([ + Columns.walletId.name, + Columns.providerId.name, + Columns.providerTransactionId.name, + ]) + } + } +} + +extension FiatTransactionRecord { + var fiatTransaction: FiatTransaction { + FiatTransaction( + assetId: assetId, + transactionType: transactionType, + providerId: providerId, + providerTransactionId: providerTransactionId, + status: status, + fiatAmount: fiatAmount, + fiatCurrency: fiatCurrency, + transactionHash: transactionHash, + address: address + ) + } +} + +extension FiatTransaction { + func record(walletId: String) -> FiatTransactionRecord { + FiatTransactionRecord( + walletId: walletId, + assetId: assetId, + transactionType: transactionType, + providerId: providerId, + providerTransactionId: providerTransactionId, + status: status, + fiatAmount: fiatAmount, + fiatCurrency: fiatCurrency, + transactionHash: transactionHash, + address: address + ) + } +} diff --git a/Packages/Store/Sources/Requests/FiatTransactionRequest.swift b/Packages/Store/Sources/Requests/FiatTransactionRequest.swift new file mode 100644 index 000000000..0ccbe4638 --- /dev/null +++ b/Packages/Store/Sources/Requests/FiatTransactionRequest.swift @@ -0,0 +1,29 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import GRDB +import Primitives + +public struct FiatTransactionRequest: DatabaseQueryable { + + public let walletId: WalletId + public let providerId: FiatProviderName + public let providerTransactionId: String + private let initialValue: FiatTransaction + + public init(walletId: WalletId, transaction: FiatTransaction) { + self.walletId = walletId + self.providerId = transaction.providerId + self.providerTransactionId = transaction.providerTransactionId + self.initialValue = transaction + } + + public func fetch(_ db: Database) throws -> FiatTransaction { + try FiatTransactionRecord + .filter(FiatTransactionRecord.Columns.walletId == walletId.id) + .filter(FiatTransactionRecord.Columns.providerId == providerId.rawValue) + .filter(FiatTransactionRecord.Columns.providerTransactionId == providerTransactionId) + .fetchOne(db)? + .fiatTransaction ?? initialValue + } +} diff --git a/Packages/Store/Sources/Requests/FiatTransactionsRequest.swift b/Packages/Store/Sources/Requests/FiatTransactionsRequest.swift new file mode 100644 index 000000000..f1cb25608 --- /dev/null +++ b/Packages/Store/Sources/Requests/FiatTransactionsRequest.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import GRDB +import Primitives + +public struct FiatTransactionsRequest: DatabaseQueryable { + + public let walletId: WalletId + public let assetId: AssetId + + public init(walletId: WalletId, assetId: AssetId) { + self.walletId = walletId + self.assetId = assetId + } + + public func fetch(_ db: Database) throws -> [FiatTransaction] { + try FiatTransactionRecord + .filter(FiatTransactionRecord.Columns.walletId == walletId.id) + .filter(FiatTransactionRecord.Columns.assetId == assetId.identifier) + .fetchAll(db) + .map { $0.fiatTransaction } + } +} diff --git a/Packages/Store/Sources/Stores/FiatTransactionStore.swift b/Packages/Store/Sources/Stores/FiatTransactionStore.swift new file mode 100644 index 000000000..3f06c74f4 --- /dev/null +++ b/Packages/Store/Sources/Stores/FiatTransactionStore.swift @@ -0,0 +1,29 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import GRDB +import Primitives + +public struct FiatTransactionStore: Sendable { + + let db: DatabaseQueue + + public init(db: DB) { + self.db = db.dbQueue + } + + public func addTransactions(walletId: WalletId, transactions: [FiatTransaction]) throws { + guard transactions.isNotEmpty else { return } + try db.write { db in + for transaction in transactions { + try transaction.record(walletId: walletId.id).upsert(db) + } + } + } + + public func clear() throws -> Int { + try db.write { db in + try FiatTransactionRecord.deleteAll(db) + } + } +} diff --git a/Packages/Store/Sources/Stores/StoreManager.swift b/Packages/Store/Sources/Stores/StoreManager.swift index 78f042b69..b52cccc06 100644 --- a/Packages/Store/Sources/Stores/StoreManager.swift +++ b/Packages/Store/Sources/Stores/StoreManager.swift @@ -21,6 +21,7 @@ public struct StoreManager: Sendable { public let searchStore: SearchStore public let inAppNotificationStore: InAppNotificationStore public let contactStore: ContactStore + public let fiatTransactionStore: FiatTransactionStore public init(db: DB) { self.assetStore = AssetStore(db: db) @@ -41,5 +42,6 @@ public struct StoreManager: Sendable { self.searchStore = SearchStore(db: db) self.inAppNotificationStore = InAppNotificationStore(db: db) self.contactStore = ContactStore(db: db) + self.fiatTransactionStore = FiatTransactionStore(db: db) } } diff --git a/Packages/Store/TestKit/FiatTransactionStore+TestKit.swift b/Packages/Store/TestKit/FiatTransactionStore+TestKit.swift new file mode 100644 index 000000000..36278768c --- /dev/null +++ b/Packages/Store/TestKit/FiatTransactionStore+TestKit.swift @@ -0,0 +1,10 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Store + +public extension FiatTransactionStore { + static func mock(db: DB = .mock()) -> Self { + FiatTransactionStore(db: db) + } +} diff --git a/core b/core index 8384c67b0..ce2ad3228 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 8384c67b090b715d9ffb027a4ed993a674ecd500 +Subproject commit ce2ad322809ce06a8fd5459067a72d9967e090f2