diff --git a/Packages/KeyAppKit/Package.swift b/Packages/KeyAppKit/Package.swift index c8d5234503..495df8c840 100644 --- a/Packages/KeyAppKit/Package.swift +++ b/Packages/KeyAppKit/Package.swift @@ -106,6 +106,11 @@ let package = Package( targets: ["TokenService"] ), + .library( + name: "PnLService", + targets: ["PnLService"] + ), + .library( name: "SendService", targets: ["SendService"] @@ -325,6 +330,19 @@ let package = Package( ] ), + .target( + name: "PnLService", + dependencies: [ + "KeyAppNetworking", + ] + ), + + .testTarget( + name: "PnLServiceTests", + dependencies: ["PnLService"], + path: "Tests/UnitTests/PnLServiceTests" + ), + .target( name: "KeyAppBusiness", dependencies: [ diff --git a/Packages/KeyAppKit/Sources/KeyAppKitCore/Repository.swift b/Packages/KeyAppKit/Sources/KeyAppKitCore/Repository.swift deleted file mode 100644 index da0473e6d3..0000000000 --- a/Packages/KeyAppKit/Sources/KeyAppKitCore/Repository.swift +++ /dev/null @@ -1,7 +0,0 @@ -import Foundation - -public protocol Repository { - associatedtype Element: Identifiable - - func get(id: Element.ID) async throws -} diff --git a/Packages/KeyAppKit/Sources/PnLService/PnLModel.swift b/Packages/KeyAppKit/Sources/PnLService/PnLModel.swift new file mode 100644 index 0000000000..aec8ad9062 --- /dev/null +++ b/Packages/KeyAppKit/Sources/PnLService/PnLModel.swift @@ -0,0 +1,30 @@ +import Foundation + +public protocol PnLModel { + var total: RPCPnLResponseDetail? { get } + var pnlByMint: [String: RPCPnLResponseDetail] { get } +} + +struct PnLRPCRequest: Codable { + let userWallet: String + let mints: [String] + + enum CodingKeys: String, CodingKey { + case userWallet = "user_wallet" + case mints + } +} + +public struct RPCPnLResponseDetail: Codable { + public let usdAmount, percent: String + + public init(usdAmount: String, percent: String) { + self.usdAmount = usdAmount + self.percent = percent + } + + enum CodingKeys: String, CodingKey { + case usdAmount = "usd_amount" + case percent + } +} diff --git a/Packages/KeyAppKit/Sources/PnLService/PnLService.swift b/Packages/KeyAppKit/Sources/PnLService/PnLService.swift new file mode 100644 index 0000000000..cbddcab1ac --- /dev/null +++ b/Packages/KeyAppKit/Sources/PnLService/PnLService.swift @@ -0,0 +1,41 @@ +import Foundation +import KeyAppNetworking + +public protocol PnLService { + associatedtype PnL: PnLModel + func getPNL(userWallet: String, mints: [String]) async throws -> PnL +} + +extension Dictionary: PnLModel where Key == String, Value == RPCPnLResponseDetail { + public var total: RPCPnLResponseDetail? { + self["total"] + } + + public var pnlByMint: [String: RPCPnLResponseDetail] { + var dict = self + dict.removeValue(forKey: "total") + return dict + } +} + +public class PnLServiceImpl: PnLService { + private let urlSession: HTTPURLSession + + public init(urlSession: HTTPURLSession = URLSession.shared) { + self.urlSession = urlSession + } + + public func getPNL(userWallet: String, mints: [String]) async throws -> [String: RPCPnLResponseDetail] { + try await JSONRPCHTTPClient(urlSession: urlSession) + .request( + baseURL: "https://pnl.key.app", + body: .init( + method: "get_pnl", + params: PnLRPCRequest( + userWallet: userWallet, + mints: mints + ) + ) + ) + } +} diff --git a/Packages/KeyAppKit/Sources/Repository/Repository/RepositoryView/RepositoryView.swift b/Packages/KeyAppKit/Sources/Repository/Repository/RepositoryView/RepositoryView.swift index 33371ba08e..192bd217b4 100644 --- a/Packages/KeyAppKit/Sources/Repository/Repository/RepositoryView/RepositoryView.swift +++ b/Packages/KeyAppKit/Sources/Repository/Repository/RepositoryView/RepositoryView.swift @@ -15,10 +15,10 @@ public struct RepositoryView< @ObservedObject var repository: R /// View to handle state when repository is loading, for example ProgressView or Skeleton - let loadingView: () -> LoadingView + let loadingView: (P.ItemType?) -> LoadingView /// View to handle state when an error occurred - let errorView: (Error) -> ErrorView + let errorView: (Error, P.ItemType?) -> ErrorView /// View to handle state when repository is loaded let content: (P.ItemType?) -> LoadedView @@ -33,10 +33,8 @@ public struct RepositoryView< /// - loadedView: View to handle state when repository is loaded public init( repository: R, - @ViewBuilder loadingView: @escaping () -> LoadingView, - @ViewBuilder errorView: @escaping (Error) -> ErrorView = { error in - Text(String(reflecting: error)).foregroundStyle(Color.red) - }, + @ViewBuilder loadingView: @escaping (P.ItemType?) -> LoadingView, + @ViewBuilder errorView: @escaping (Error, P.ItemType?) -> ErrorView, @ViewBuilder content: @escaping (P.ItemType?) -> LoadedView ) { self.repository = repository @@ -49,13 +47,15 @@ public struct RepositoryView< public var body: some View { switch repository.state { - case .initialized, .loading: - loadingView() + case .initialized: + loadingView(nil) + case .loading: + loadingView(repository.data) case .loaded: content(repository.data) case .error: if let error = repository.error { - errorView(error) + errorView(error, repository.data) } } } diff --git a/Packages/KeyAppKit/Tests/UnitTests/PnLServiceTests/PnLServiceTests.swift b/Packages/KeyAppKit/Tests/UnitTests/PnLServiceTests/PnLServiceTests.swift new file mode 100644 index 0000000000..8151b7d204 --- /dev/null +++ b/Packages/KeyAppKit/Tests/UnitTests/PnLServiceTests/PnLServiceTests.swift @@ -0,0 +1,38 @@ +import KeyAppNetworking +import XCTest +@testable import PnLService + +final class PnLServiceTests: XCTestCase { + func testEncodingRequest() async throws { + let request = PnLRPCRequest( + userWallet: "EXRV9Hu3VswiEDYVcx9tLfF7EX3z3zEH19g363KQe3Kd", + mints: [ + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "CKfatsPMUf8SkiURsDXs7eK6GWb4Jsd6UDbs7twMCWxo", + ] + ) + + let encoded = try String(data: JSONEncoder().encode(request), encoding: .utf8)! + XCTAssertEqual( + encoded, + #"{"user_wallet":"EXRV9Hu3VswiEDYVcx9tLfF7EX3z3zEH19g363KQe3Kd","mints":["EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v","CKfatsPMUf8SkiURsDXs7eK6GWb4Jsd6UDbs7twMCWxo"]}"# + ) + } + + func testRequest_SuccessfulResponse_ReturnsData() async throws { + let mockURLSession = + MockURLSession( + responseString: #"{"jsonrpc":"2.0","id":"12","result":{"total":{"usd_amount":"+1.23","percent":"-3.45"},"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v":{"usd_amount":"+1.23","percent":"-3.45"},"CKfatsPMUf8SkiURsDXs7eK6GWb4Jsd6UDbs7twMCWxo":{"usd_amount":"+1.23","percent":"-3.45"}}}"# + ) + + let service = PnLServiceImpl(urlSession: mockURLSession) + let result = try await service.getPNL(userWallet: "", mints: []) + + XCTAssertEqual(result.total?.usdAmount, "+1.23") + XCTAssertEqual(result.total?.percent, "-3.45") + XCTAssertEqual(result.pnlByMint["EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"]?.usdAmount, "+1.23") + XCTAssertEqual(result.pnlByMint["EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"]?.percent, "-3.45") + XCTAssertEqual(result.pnlByMint["CKfatsPMUf8SkiURsDXs7eK6GWb4Jsd6UDbs7twMCWxo"]?.usdAmount, "+1.23") + XCTAssertEqual(result.pnlByMint["CKfatsPMUf8SkiURsDXs7eK6GWb4Jsd6UDbs7twMCWxo"]?.percent, "-3.45") + } +} diff --git a/p2p_wallet/Common/Coordinator/BottomSheetCoordinator/BottomSheetCoordinator.swift b/p2p_wallet/Common/Coordinator/BottomSheetCoordinator/BottomSheetCoordinator.swift new file mode 100644 index 0000000000..f94d099fde --- /dev/null +++ b/p2p_wallet/Common/Coordinator/BottomSheetCoordinator/BottomSheetCoordinator.swift @@ -0,0 +1,26 @@ +import Combine +import Foundation +import SwiftUI + +@MainActor +class BottomSheetInfoCoordinator: Coordinator { + let vc: UIBottomSheetHostingController + let parentVC: UIViewController + + init( + parentVC: UIViewController, + rootView: Content, + shouldIgnoresKeyboard: Bool = true + ) { + self.parentVC = parentVC + vc = .init(rootView: rootView, shouldIgnoresKeyboard: shouldIgnoresKeyboard) + vc.view.layer.cornerRadius = 20 + } + + // MARK: - Methods + + override func start() -> AnyPublisher { + parentVC.present(vc, interactiveDismissalType: .standard) + return vc.deallocatedPublisher() + } +} diff --git a/p2p_wallet/Common/PnL/AccountPnLRepository.swift b/p2p_wallet/Common/PnL/AccountPnLRepository.swift new file mode 100644 index 0000000000..d8d75e845c --- /dev/null +++ b/p2p_wallet/Common/PnL/AccountPnLRepository.swift @@ -0,0 +1,51 @@ +import Foundation +import KeyAppBusiness +import PnLService +import Repository + +enum PnLError: String, Error { + case invalidUserWallet +} + +class PnLProvider: Provider { + // MARK: - Dependencies + + let service: any PnLService + let userWalletsManager: UserWalletManager + let solanaAccountsService: SolanaAccountsService + + // MARK: - Initializer + + init( + service: any PnLService, + userWalletsManager: UserWalletManager, + solanaAccountsService: SolanaAccountsService + ) { + self.service = service + self.userWalletsManager = userWalletsManager + self.solanaAccountsService = solanaAccountsService + } + + func fetch() async throws -> PnLModel? { + guard let userWallet = userWalletsManager.wallet?.account.publicKey.base58EncodedString else { + throw PnLError.invalidUserWallet + } + return try await service.getPNL( + userWallet: userWallet, + mints: solanaAccountsService.state.value.map(\.mintAddress) + ) + } +} + +class PnLRepository: Repository { + weak var timer: Timer? + + override init(initialData: ItemType?, provider: PnLProvider) { + super.init(initialData: initialData, provider: provider) + timer = Timer.scheduledTimer(withTimeInterval: 5 * 60, repeats: true) { [weak self] _ in + guard let self else { return } + Task { await self.refresh() } + } + timer?.fire() + } +} diff --git a/p2p_wallet/Common/PnL/MockPnLService.swift b/p2p_wallet/Common/PnL/MockPnLService.swift new file mode 100644 index 0000000000..6593dd7f2c --- /dev/null +++ b/p2p_wallet/Common/PnL/MockPnLService.swift @@ -0,0 +1,29 @@ +import Foundation +import PnLService +import SolanaSwift + +struct MockPnLModel: PnLModel { + let total: RPCPnLResponseDetail? + let pnlByMint: [String: RPCPnLResponseDetail] +} + +class MockPnLService: PnLService { + func getPNL(userWallet _: String, mints _: [String]) async throws -> MockPnLModel { + try await Task.sleep(nanoseconds: 1_000_000_000) + return .init( + total: .init(usdAmount: "+1.23", percent: "-1.2"), + pnlByMint: [ + "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263": .init(usdAmount: "-5", percent: "-5"), + "EjChnoTPcQ9DxKZkBM7g1M4DBF4J2Mx75CYTPoDZyYXB": .init(usdAmount: "-2.2", percent: "-2.2"), + "7SdFACfxmg2eetZEhEYZhsNMVAu84USVtfJ64jFDCg9Y": .init(usdAmount: "-1.1", percent: "-1.1"), + "98eKvPL8rJeFPVLft3JMfsCn1Yi9UN7MmG2htMQ4t4FS": .init(usdAmount: "-3.3", percent: "-3.3"), + "EchesyfXePKdLtoiZSL8pBe8Myagyy8ZRqsACNCFGnvp": .init(usdAmount: "+4.4", percent: "+4.4"), + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB": .init(usdAmount: "-6.6", percent: "-6.6"), + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": .init(usdAmount: "+7.7", percent: "+7.7"), + "JET6zMJWkCN9tpRT2v2jfAmm5VnQFDpUBCyaKojmGtz": .init(usdAmount: "-8.8", percent: "-8.8"), + "3NZ9JMVBmGAqocybic2c7LQCJScmgsAZ6vQqTDzcqmJh": .init(usdAmount: "-9.9", percent: "-9.9"), + "2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk": .init(usdAmount: "-10.1", percent: "-10.1"), + ] + ) + } +} diff --git a/p2p_wallet/Injection/Resolver+registerAllServices.swift b/p2p_wallet/Injection/Resolver+registerAllServices.swift index c3e78d2dd6..30954a250d 100644 --- a/p2p_wallet/Injection/Resolver+registerAllServices.swift +++ b/p2p_wallet/Injection/Resolver+registerAllServices.swift @@ -10,6 +10,7 @@ import Moonpay import NameService import Onboarding import OrcaSwapSwift +import PnLService import Reachability import Resolver import Sell @@ -569,6 +570,24 @@ extension Resolver: ResolverRegistering { } .implements(JupiterTokensProvider.self) .scope(.session) + + register { + PnLServiceImpl() + } + .implements((any PnLService).self) + .scope(.session) + + register { + PnLRepository( + initialData: nil, + provider: .init( + service: resolve(), + userWalletsManager: resolve(), + solanaAccountsService: resolve() + ) + ) + } + .scope(.session) } /// Shared scope: share between screens diff --git a/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Contents.json b/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Contents.json new file mode 100644 index 0000000000..aee65752b8 --- /dev/null +++ b/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "Group 1523461.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "Group 1523461@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "Group 1523461@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Group 1523461.png b/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Group 1523461.png new file mode 100644 index 0000000000..c9447f39dd Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Group 1523461.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Group 1523461@2x.png b/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Group 1523461@2x.png new file mode 100644 index 0000000000..3358c274e9 Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Group 1523461@2x.png differ diff --git a/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Group 1523461@3x.png b/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Group 1523461@3x.png new file mode 100644 index 0000000000..69e086b60f Binary files /dev/null and b/p2p_wallet/Resources/Assets.xcassets/all-time-pnl.imageset/Group 1523461@3x.png differ diff --git a/p2p_wallet/Resources/Base.lproj/Localizable.strings b/p2p_wallet/Resources/Base.lproj/Localizable.strings index 34ea753e12..873d340fee 100644 --- a/p2p_wallet/Resources/Base.lproj/Localizable.strings +++ b/p2p_wallet/Resources/Base.lproj/Localizable.strings @@ -597,3 +597,8 @@ "Token 2022 transfer fee" = "Token 2022 transfer fee"; "Calculated by subtracting the token 2022 transfer fee from your balance" = "Calculated by subtracting the token 2022 transfer fee from your balance"; "Calculated by subtracting the token 2022 transfer fee and account creation fee from your balance" = "Calculated by subtracting the token 2022 transfer fee and account creation fee from your balance"; +"%@ all the time" = "%@ all the time"; +"Here’s how do we count your profits for total balance and every single token" = "Here’s how do we count your profits for total balance and every single token"; +"Based on absolute and relative profitability of each trade. It shows the relative potential %% profits or losses of your trading strategy." = "Based on absolute and relative profitability of each trade. It shows the relative potential %% profits or losses of your trading strategy."; +"%@%% last 24h" = "%@%% last 24h"; +"Result of an investment, trading strategy per 24 hours" = "Result of an investment, trading strategy per 24 hours"; diff --git a/p2p_wallet/Resources/en.lproj/Localizable.strings b/p2p_wallet/Resources/en.lproj/Localizable.strings index 1b6c0248aa..643697605c 100644 --- a/p2p_wallet/Resources/en.lproj/Localizable.strings +++ b/p2p_wallet/Resources/en.lproj/Localizable.strings @@ -585,3 +585,8 @@ "Token 2022 transfer fee" = "Token 2022 transfer fee"; "Calculated by subtracting the token 2022 transfer fee from your balance" = "Calculated by subtracting the token 2022 transfer fee from your balance"; "Calculated by subtracting the token 2022 transfer fee and account creation fee from your balance" = "Calculated by subtracting the token 2022 transfer fee and account creation fee from your balance"; +"%@ all the time" = "%@ all the time"; +"Here’s how do we count your profits for total balance and every single token" = "Here’s how do we count your profits for total balance and every single token"; +"Based on absolute and relative profitability of each trade. It shows the relative potential %% profits or losses of your trading strategy." = "Based on absolute and relative profitability of each trade. It shows the relative potential %% profits or losses of your trading strategy."; +"%@%% last 24h" = "%@%% last 24h"; +"Result of an investment, trading strategy per 24 hours" = "Result of an investment, trading strategy per 24 hours"; diff --git a/p2p_wallet/Scenes/Main/Crypto/Components/AllTimePnLInfoBottomSheet.swift b/p2p_wallet/Scenes/Main/Crypto/Components/AllTimePnLInfoBottomSheet.swift new file mode 100644 index 0000000000..35947cd0ce --- /dev/null +++ b/p2p_wallet/Scenes/Main/Crypto/Components/AllTimePnLInfoBottomSheet.swift @@ -0,0 +1,97 @@ +import SkeletonUI +import SwiftUI + +struct AllTimePnLInfoBottomSheet: View { + @ObservedObject var repository: PnLRepository + @SwiftUI.Environment(\.dismiss) private var dismiss + let mint: String? // mint == nil for total pnl + + var body: some View { + VStack(alignment: .center) { + RoundedRectangle(cornerRadius: 2) + .fill(Color(.lightGray)) + .frame(width: 31, height: 4) + .padding(.vertical, 6) + + Image(.allTimePnl) + .padding(.top, 20.88) + + explanationView + .padding(.top, 16) + + button + .padding(.top, 28) + .padding(.bottom, 32) + } + } + + // MARK: - ViewBuilders + + @ViewBuilder + private var explanationView: some View { + HStack(alignment: .center, spacing: 16) { + Image(.transactionFee) + .frame(width: 48, height: 48) + + let pnl = mint == nil ? repository.data?.total?.percent : repository.data?.pnlByMint[mint!]?.percent + VStack(alignment: .leading, spacing: 2) { + Text(L10n.last24h("\(pnl ?? "")")) + .foregroundColor(Color(.night)) + .font(uiFont: .font(of: .text1, weight: .semibold)) + .skeleton(with: repository.data == nil && repository.isLoading) + .fixedSize(horizontal: false, vertical: true) + + Text( + L10n.resultOfAnInvestmentTradingStrategyPer24Hours + ) + .font(uiFont: .font(of: .label1)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(Color(.cloud)) + .cornerRadius(12) + .padding(.horizontal, 16) + } + + @ViewBuilder + private var button: some View { + NewTextButton( + title: L10n.gotIt, + size: .large, + style: .primaryWhite, + expandable: true, + action: { + dismiss() + } + ) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + } +} + +// #Preview { +// PnLInfoBottomSheet( +// token: .init( +// token: .unsupported( +// tags: ["unknown"], +// mint: "GWART6ijjvijdihuhvjhhdhjBn78Ee", +// decimals: 6, +// symbol: "GWART", +// supply: 1_000_000_000 +// ), +// userWallet: nil +// ) +// ) {} +// } + +// MARK: - Helpers + +private extension String { + var shortAddress: String { + "\(prefix(6))...\(suffix(6))" + } +} diff --git a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountCellView.swift b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountCellView.swift index dd17f94bb5..cb986b2a0f 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountCellView.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountCellView.swift @@ -1,84 +1,142 @@ +import Resolver +import SkeletonUI import SwiftUI struct CryptoAccountCellView: View, Equatable { - static func == (lhs: CryptoAccountCellView, rhs: CryptoAccountCellView) -> Bool { - lhs.rendable.id == rhs.rendable.id && - lhs.rendable.detail == rhs.rendable.detail && - lhs.rendable.title == rhs.rendable.title && - lhs.rendable.subtitle == rhs.rendable.subtitle && - rhs.rendable.tags == rhs.rendable.tags - } + // MARK: - Properties let iconSize: CGFloat = 50 let rendable: any RenderableAccount + let showPnL: Bool let onTap: (() -> Void)? let onButtonTap: (() -> Void)? + // MARK: - Body + var body: some View { HStack(alignment: .center, spacing: 12) { - switch rendable.icon { - case let .url(url): - CoinLogoImageViewRepresentable( - size: iconSize, - args: .manual(preferredImage: nil, url: url, key: "", wrapped: rendable.wrapped) - ) - .frame(width: iconSize, height: iconSize) - case let .image(image): - CoinLogoImageViewRepresentable( - size: iconSize, - args: .manual(preferredImage: image, url: nil, key: "", wrapped: rendable.wrapped) - ) - .frame(width: iconSize, height: iconSize) - case let .random(seed): - CoinLogoImageViewRepresentable( - size: iconSize, - args: .manual(preferredImage: nil, url: nil, key: seed, wrapped: rendable.wrapped) - ) - .frame(width: iconSize, height: iconSize) + iconView + mainInfoView + detailView + } + .contentShape(Rectangle()) + .if(rendable.onTapEnable) { view in + view.onTapGesture { + onTap?() } + } + } - VStack(alignment: .leading, spacing: 4) { - Text(rendable.title) - .font(uiFont: .font(of: .text2)) - .foregroundColor(Color(.night)) - Text(rendable.subtitle) - .font(uiFont: .font(of: .label1)) - .foregroundColor(Color(.mountain)) - } - Spacer() + // MARK: - ViewBuilders - switch rendable.detail { - case let .text(text): + @ViewBuilder private var iconView: some View { + switch rendable.icon { + case let .url(url): + CoinLogoImageViewRepresentable( + size: iconSize, + args: .manual(preferredImage: nil, url: url, key: "", wrapped: rendable.wrapped) + ) + .frame(width: iconSize, height: iconSize) + case let .image(image): + CoinLogoImageViewRepresentable( + size: iconSize, + args: .manual(preferredImage: image, url: nil, key: "", wrapped: rendable.wrapped) + ) + .frame(width: iconSize, height: iconSize) + case let .random(seed): + CoinLogoImageViewRepresentable( + size: iconSize, + args: .manual(preferredImage: nil, url: nil, key: seed, wrapped: rendable.wrapped) + ) + .frame(width: iconSize, height: iconSize) + } + } + + @ViewBuilder private var mainInfoView: some View { + VStack(alignment: .leading, spacing: 4) { + Text(rendable.title) + .font(uiFont: .font(of: .text2)) + .foregroundColor(Color(.night)) + Text(rendable.subtitle) + .font(uiFont: .font(of: .label1)) + .foregroundColor(Color(.mountain)) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder private var detailView: some View { + switch rendable.detail { + case let .text(text): + if showPnL, + let solanaAccount = rendable as? RenderableSolanaAccount + { + VStack(alignment: .trailing, spacing: 4) { + Text(text) + .font(uiFont: .font(of: .text3, weight: .semibold)) + .foregroundColor(Color(.night)) + + pnlView( + mint: solanaAccount.account.mintAddress + ) + } + } else { Text(text) .font(uiFont: .font(of: .text3, weight: .semibold)) .foregroundColor(Color(.night)) - case let .button(text, enabled): - Button( - action: { onButtonTap?() }, - label: { - Text(text) - .padding(.horizontal, 12) - .font(uiFont: TextButton.Style.second.font(size: .small)) - .foregroundColor(Color( - enabled ? TextButton.Style.primaryWhite.foreground - : TextButton.Style.primaryWhite.disabledForegroundColor! - )) - .frame(height: TextButton.Size.small.height) - .background(Color( - enabled ? TextButton.Style.primaryWhite.backgroundColor - : TextButton.Style.primaryWhite.disabledBackgroundColor! - )) - .cornerRadius(12) - } - ) } + case let .button(text, enabled): + Button( + action: { onButtonTap?() }, + label: { + Text(text) + .padding(.horizontal, 12) + .font(uiFont: TextButton.Style.second.font(size: .small)) + .foregroundColor(Color( + enabled ? TextButton.Style.primaryWhite.foreground + : TextButton.Style.primaryWhite.disabledForegroundColor! + )) + .frame(height: TextButton.Size.small.height) + .background(Color( + enabled ? TextButton.Style.primaryWhite.backgroundColor + : TextButton.Style.primaryWhite.disabledBackgroundColor! + )) + .cornerRadius(12) + } + ) } - .contentShape(Rectangle()) - .if(rendable.onTapEnable) { view in - view.onTapGesture { - onTap?() + } + + // MARK: - ViewBuilders + + @ViewBuilder private func pnlView(mint: String) -> some View { + PnLView( + pnlRepository: Resolver.resolve( + PnLRepository.self + ), + skeletonSize: .init(width: 30, height: 16) + ) { pnl in + if let pnl = pnl?.pnlByMint[mint]?.percent { + Text("\(pnl)%") + .font(uiFont: .font(of: .label1)) + .foregroundColor(Color(.mountain)) + } else { + // Hack: invisible text to keep lines height + Text("0%") + .font(uiFont: .font(of: .label1)) + .foregroundColor(.clear) } } + .frame(height: 16) + } + + // MARK: - Equatable + + static func == (lhs: CryptoAccountCellView, rhs: CryptoAccountCellView) -> Bool { + lhs.rendable.id == rhs.rendable.id && + lhs.rendable.detail == rhs.rendable.detail && + lhs.rendable.title == rhs.rendable.title && + lhs.rendable.subtitle == rhs.rendable.subtitle && + rhs.rendable.tags == rhs.rendable.tags } } diff --git a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsView.swift b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsView.swift index 5df18c16e3..83b86d661e 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsView.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsView.swift @@ -63,7 +63,7 @@ struct CryptoAccountsView: View { if !viewModel.transferAccounts.isEmpty { wrappedList(items: viewModel.transferAccounts) { data in ForEach(data, id: \.id) { - tokenCell(rendableAccount: $0) + tokenCell(rendableAccount: $0, showPnL: false) } } } @@ -71,16 +71,22 @@ struct CryptoAccountsView: View { wrappedList(items: viewModel.accounts) { data in ForEach(data, id: \.id) { rendableAccount in if rendableAccount.extraAction == .showHide { - tokenCell(rendableAccount: rendableAccount) - .swipeActions( - isVisible: true, - currentUserInteractionCellID: $currentUserInteractionCellID, - action: { - viewModel.invoke(for: rendableAccount, event: .visibleToggle) - } - ) + tokenCell( + rendableAccount: rendableAccount, + showPnL: true + ) + .swipeActions( + isVisible: true, + currentUserInteractionCellID: $currentUserInteractionCellID, + action: { + viewModel.invoke(for: rendableAccount, event: .visibleToggle) + } + ) } else { - tokenCell(rendableAccount: rendableAccount) + tokenCell( + rendableAccount: rendableAccount, + showPnL: true + ) } } } @@ -111,14 +117,17 @@ struct CryptoAccountsView: View { if !isHiddenSectionDisabled { wrappedList(items: viewModel.hiddenAccounts) { data in ForEach(data, id: \.id) { rendableAccount in - tokenCell(rendableAccount: rendableAccount) - .swipeActions( - isVisible: false, - currentUserInteractionCellID: $currentUserInteractionCellID, - action: { - viewModel.invoke(for: rendableAccount, event: .visibleToggle) - } - ) + tokenCell( + rendableAccount: rendableAccount, + showPnL: true + ) + .swipeActions( + isVisible: false, + currentUserInteractionCellID: $currentUserInteractionCellID, + action: { + viewModel.invoke(for: rendableAccount, event: .visibleToggle) + } + ) } .transition(AnyTransition.opacity.animation(.linear(duration: 0.3))) } @@ -130,8 +139,14 @@ struct CryptoAccountsView: View { } @ViewBuilder - private func tokenCell(rendableAccount: any RenderableAccount) -> some View { - CryptoAccountCellView(rendable: rendableAccount) { + private func tokenCell( + rendableAccount: any RenderableAccount, + showPnL: Bool + ) -> some View { + CryptoAccountCellView( + rendable: rendableAccount, + showPnL: showPnL + ) { viewModel.invoke(for: rendableAccount, event: .tap) } onButtonTap: { viewModel.invoke(for: rendableAccount, event: .extraButtonTap) diff --git a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsViewModel.swift b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsViewModel.swift index 2ec0b64af9..479014b0ba 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsViewModel.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Accounts/CryptoAccountsViewModel.swift @@ -115,6 +115,9 @@ final class CryptoAccountsViewModel: BaseViewModel, ObservableObject { func refresh() async { await HomeAccountsSynchronisationService().refresh() + Task { + await Resolver.resolve(PnLRepository.self).reload() + } } func scrollToTop() { diff --git a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Actions Panel/CryptoActionsPanelView.swift b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Actions Panel/CryptoActionsPanelView.swift index 25c023dfe6..8c15cefdcc 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Actions Panel/CryptoActionsPanelView.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Components/Crypto Actions Panel/CryptoActionsPanelView.swift @@ -1,4 +1,5 @@ import Foundation +import Resolver import SwiftUI /// View of `CryptoActionsPanel` scene @@ -7,11 +8,7 @@ struct CryptoActionsPanelView: View { @ObservedObject var viewModel: CryptoActionsPanelViewModel - // MARK: - Initializer - - init(viewModel: CryptoActionsPanelViewModel) { - self.viewModel = viewModel - } + let pnlTapAction: (() -> Void)? // MARK: - View content @@ -20,8 +17,10 @@ struct CryptoActionsPanelView: View { actions: viewModel.actions, balance: viewModel.balance, usdAmount: "", + pnlRepository: Resolver.resolve(), action: viewModel.actionClicked, - balanceTapAction: viewModel.balanceTapped + balanceTapAction: viewModel.balanceTapped, + pnlTapAction: pnlTapAction ) .onAppear { viewModel.viewDidAppear() diff --git a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift index d661384a84..70febb676f 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoCoordinator.swift @@ -12,6 +12,7 @@ import Wormhole /// The scenes that the `Crypto` scene can navigate to enum CryptoNavigation: Equatable { // With tokens + case allTimePnLInfo case buy case receive(publicKey: PublicKey) case send @@ -203,6 +204,12 @@ final class CryptoCoordinator: Coordinator { }) .map { _ in () } .eraseToAnyPublisher() + case .allTimePnLInfo: + return Just({ [weak self] in + guard let self else { return } + showPnLInfo() + }()) + .eraseToAnyPublisher() default: return Just(()) .eraseToAnyPublisher() @@ -226,4 +233,18 @@ final class CryptoCoordinator: Coordinator { .sink(receiveValue: {}) .store(in: &subscriptions) } + + private func showPnLInfo() { + let coordinator = BottomSheetInfoCoordinator( + parentVC: tabBarController, + rootView: AllTimePnLInfoBottomSheet( + repository: Resolver.resolve(), + mint: nil + ) + ) + + coordinate(to: coordinator) + .sink { _ in } + .store(in: &subscriptions) + } } diff --git a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoView.swift b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoView.swift index b935a32969..7393dc0871 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoView.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoView.swift @@ -1,4 +1,5 @@ import Combine +import Resolver import SwiftUI /// View of `Crypto` scene @@ -25,7 +26,10 @@ struct CryptoView: View { // MARK: - View content private var actionsPanelView: CryptoActionsPanelView { - CryptoActionsPanelView(viewModel: actionsPanelViewModel) + CryptoActionsPanelView(viewModel: actionsPanelViewModel) { + viewModel.navigation + .send(.allTimePnLInfo) + } } var body: some View { diff --git a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoViewModel.swift b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoViewModel.swift index 1af470d5ed..e6adf1a2d1 100644 --- a/p2p_wallet/Scenes/Main/Crypto/Container/CryptoViewModel.swift +++ b/p2p_wallet/Scenes/Main/Crypto/Container/CryptoViewModel.swift @@ -46,7 +46,7 @@ final class CryptoViewModel: BaseViewModel, ObservableObject { // MARK: - Methods - func reload() async { + private func reload() async { await CryptoAccountsSynchronizationService().refresh() } @@ -175,6 +175,18 @@ private extension CryptoViewModel { self?.updateAddressIfNeeded() } .store(in: &subscriptions) + + // solana account vs pnl, get for the first time + solanaAccountsService.statePublisher + .receive(on: RunLoop.main) + .filter { $0.status == .ready } + .prefix(1) + .sink { _ in + Task { + await Resolver.resolve(PnLRepository.self).reload() + } + } + .store(in: &subscriptions) } } diff --git a/p2p_wallet/Scenes/Main/History/New/HistoryView.swift b/p2p_wallet/Scenes/Main/History/New/HistoryView.swift index c1133ac4f0..41a3660029 100644 --- a/p2p_wallet/Scenes/Main/History/New/HistoryView.swift +++ b/p2p_wallet/Scenes/Main/History/New/HistoryView.swift @@ -63,7 +63,7 @@ struct NewHistoryView: View { } .padding(.vertical, 8) } - .customRefreshable { try? await viewModel.reload() } + .customRefreshable { try? await viewModel.refresh() } .background(Color(.smoke)) .onAppear { viewModel.onAppear() diff --git a/p2p_wallet/Scenes/Main/History/New/HistoryViewModel.swift b/p2p_wallet/Scenes/Main/History/New/HistoryViewModel.swift index 05d6bdca8f..15e32e8a01 100644 --- a/p2p_wallet/Scenes/Main/History/New/HistoryViewModel.swift +++ b/p2p_wallet/Scenes/Main/History/New/HistoryViewModel.swift @@ -235,6 +235,13 @@ class HistoryViewModel: BaseViewModel, ObservableObject { await sellDataService?.update(region: nil) } + func refresh() async throws { + try await reload() + Task.detached { + await Resolver.resolve(PnLRepository.self).reload() + } + } + func fetch() { history.fetch() Task { diff --git a/p2p_wallet/Scenes/Main/History/New/Repository/HistoryRepository.swift b/p2p_wallet/Scenes/Main/History/New/Repository/HistoryRepository.swift index 121cf5b663..b73e86e9ef 100644 --- a/p2p_wallet/Scenes/Main/History/New/Repository/HistoryRepository.swift +++ b/p2p_wallet/Scenes/Main/History/New/Repository/HistoryRepository.swift @@ -4,7 +4,7 @@ import KeyAppKitCore import Resolver import SolanaSwift -class HistoryRepository: Repository { +class HistoryRepository { typealias Element = HistoryTransaction var provider: KeyAppHistoryProvider diff --git a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsView.swift b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsView.swift index 4038ed10e9..6b36e36d7d 100644 --- a/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsView.swift +++ b/p2p_wallet/Scenes/Main/NewHome/Subview/AccountList/HomeAccountsView.swift @@ -46,8 +46,10 @@ struct HomeAccountsView: View { actions: [], balance: viewModel.balance, usdAmount: viewModel.usdcAmount, + pnlRepository: Resolver.resolve(), action: { _ in }, - balanceTapAction: viewModel.balanceTapped + balanceTapAction: viewModel.balanceTapped, + pnlTapAction: {} ) } diff --git a/p2p_wallet/Scenes/Main/WalletDetail/New/AccountDetailsCoordinator.swift b/p2p_wallet/Scenes/Main/WalletDetail/New/AccountDetailsCoordinator.swift index ca31c64754..fe18353c73 100644 --- a/p2p_wallet/Scenes/Main/WalletDetail/New/AccountDetailsCoordinator.swift +++ b/p2p_wallet/Scenes/Main/WalletDetail/New/AccountDetailsCoordinator.swift @@ -59,13 +59,15 @@ class AccountDetailsCoordinator: SmartCoordinator 3 ? 12 : 32) { - ForEach(detailAccount.rendableAccountDetails.actions) { action in + HStack(spacing: viewModel.rendableAccountDetails.actions.count > 3 ? 12 : 32) { + ForEach(viewModel.rendableAccountDetails.actions) { action in CircleButton(title: action.title, image: action.icon) { - detailAccount.rendableAccountDetails.onAction(action) + viewModel.rendableAccountDetails.onAction(action) } } } .padding(.top, 32) - if let banner = detailAccount.banner { + if let banner = viewModel.banner { SwapEthBanner(text: banner.title, action: banner.action, close: { withAnimation { banner.close() @@ -42,6 +68,24 @@ struct AccountDetailsView: View { } } } + + @ViewBuilder private func pnlContentView( + pnl: PnLModel?, + mint: String + ) -> some View { + if let percentage = pnl?.pnlByMint[mint]?.percent { + Text(L10n.last24h("\(percentage)")) + .font(uiFont: .font(of: .text3)) + .foregroundColor(Color(.night)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.snow)) + .cornerRadius(8) + .onTapGesture { + viewModel.actionSubject.send(.openPnL) + } + } + } } struct AccountDetailsView_Previews: PreviewProvider { @@ -52,7 +96,7 @@ struct AccountDetailsView_Previews: PreviewProvider { historyList.fetch() return AccountDetailsView( - detailAccount: .init( + viewModel: .init( rendableAccountDetails: MockRendableAccountDetails( title: "USDC", amountInToken: "1 000.97 USDC", @@ -61,7 +105,7 @@ struct AccountDetailsView_Previews: PreviewProvider { onAction: { _ in } ) ), - historyList: historyList + historyListViewModel: historyList ) } } diff --git a/p2p_wallet/Scenes/Main/WalletDetail/New/AccountDetailsViewModel.swift b/p2p_wallet/Scenes/Main/WalletDetail/New/AccountDetailsViewModel.swift index fabfb7eac9..fdc8b534c1 100644 --- a/p2p_wallet/Scenes/Main/WalletDetail/New/AccountDetailsViewModel.swift +++ b/p2p_wallet/Scenes/Main/WalletDetail/New/AccountDetailsViewModel.swift @@ -14,6 +14,7 @@ enum AccountDetailsAction { case openSwap(SolanaAccount?) case openSwapWithDestination(SolanaAccount?, SolanaAccount?) case openCashOut + case openPnL } class AccountDetailsViewModel: BaseViewModel, ObservableObject { diff --git a/p2p_wallet/UI/SwiftUI/ActionsPanel/ActionsPanelView.swift b/p2p_wallet/UI/SwiftUI/ActionsPanel/ActionsPanelView.swift index 441c073184..9cf5d69ddb 100644 --- a/p2p_wallet/UI/SwiftUI/ActionsPanel/ActionsPanelView.swift +++ b/p2p_wallet/UI/SwiftUI/ActionsPanel/ActionsPanelView.swift @@ -1,48 +1,88 @@ -import Combine import SwiftUI struct ActionsPanelView: View { let actions: [WalletActionType] let balance: String let usdAmount: String + let pnlRepository: PnLRepository let action: (WalletActionType) -> Void let balanceTapAction: (() -> Void)? + let pnlTapAction: (() -> Void)? var body: some View { VStack(alignment: .center, spacing: 0) { - if !balance.isEmpty { - Text(balance) - .font(uiFont: .font(of: 64, weight: .semibold)) - .lineLimit(1) - .minimumScaleFactor(0.5) - .foregroundColor(Color(.night)) - .padding(.top, 18) - .padding(.bottom, usdAmount.isEmpty ? 24 : 12) - .onTapGesture { - balanceTapAction?() - } - } else { - Rectangle() - .fill(Color.clear) - .padding(.top, 24) - } + balanceView + if !usdAmount.isEmpty { - Text(usdAmount) + usdAmountView + } + + pnlView + .onTapGesture { + pnlTapAction?() + } + .padding(.top, usdAmount.isEmpty ? 12 : 0) + + actionsView + .padding(.top, 36) + } + .background(Color(.smoke)) + } + + // MARK: - ViewBuilders + + @ViewBuilder private var balanceView: some View { + if !balance.isEmpty { + Text(balance) + .font(uiFont: .font(of: 64, weight: .semibold)) + .lineLimit(1) + .minimumScaleFactor(0.5) + .foregroundColor(Color(.night)) + .padding(.top, 18) + .onTapGesture { + balanceTapAction?() + } + } else { + Rectangle() + .fill(Color.clear) + .padding(.top, 24) + } + } + + @ViewBuilder private var usdAmountView: some View { + Text(usdAmount) + .font(uiFont: .font(of: .text3)) + .foregroundColor(Color(.night)) + } + + @ViewBuilder private var pnlView: some View { + PnLView( + pnlRepository: pnlRepository, + skeletonSize: .init(width: 100, height: 16) + ) { pnl in + if let percentage = pnl?.total?.percent { + Text(L10n.last24h("\(percentage)")) .font(uiFont: .font(of: .text3)) .foregroundColor(Color(.night)) - .padding(.bottom, 25) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color(.snow)) + .cornerRadius(8) } - HStack(spacing: 12) { - ForEach(actions, id: \.text) { actionType in - tokenOperation(title: actionType.text, image: actionType.icon) { - action(actionType) - } + } + .frame(height: 16) + } + + @ViewBuilder private var actionsView: some View { + HStack(spacing: 12) { + ForEach(actions, id: \.text) { actionType in + tokenOperation(title: actionType.text, image: actionType.icon) { + action(actionType) } } - .frame(maxWidth: .infinity) - .padding(.bottom, 2) } - .background(Color(.smoke)) + .frame(maxWidth: .infinity) + .padding(.bottom, 2) } private func tokenOperation(title: String, image: ImageResource, action: @escaping () -> Void) -> some View { diff --git a/p2p_wallet/UI/SwiftUI/PnL/PnLView.swift b/p2p_wallet/UI/SwiftUI/PnL/PnLView.swift new file mode 100644 index 0000000000..e7b18fa295 --- /dev/null +++ b/p2p_wallet/UI/SwiftUI/PnL/PnLView.swift @@ -0,0 +1,33 @@ +import PnLService +import Repository +import SwiftUI + +struct PnLView: View { + let pnlRepository: PnLRepository + let skeletonSize: CGSize + var content: (PnLModel?) -> Content + + init( + pnlRepository: PnLRepository, + skeletonSize: CGSize, + @ViewBuilder content: @escaping (PnLModel?) -> Content + ) { + self.pnlRepository = pnlRepository + self.skeletonSize = skeletonSize + self.content = content + } + + var body: some View { + RepositoryView( + repository: pnlRepository + ) { pnl in + content(pnl) + .skeleton(with: pnl == nil, size: skeletonSize) + } errorView: { _, pnl in + // ignore error + content(pnl) + } content: { pnl in + content(pnl) + } + } +} diff --git a/project.yml b/project.yml index 5519527951..e2a2212248 100644 --- a/project.yml +++ b/project.yml @@ -245,6 +245,11 @@ targets: product: Wormhole - package: KeyAppKit product: Jupiter + - package: KeyAppKit + product: Repository + - package: KeyAppKit + product: PnLService + - package: BEPureLayout - package: Lokalise