diff --git a/Features/Assets/Sources/Scenes/AssetScene.swift b/Features/Assets/Sources/Scenes/AssetScene.swift index 5958af27b..f15c65188 100644 --- a/Features/Assets/Sources/Scenes/AssetScene.swift +++ b/Features/Assets/Sources/Scenes/AssetScene.swift @@ -19,7 +19,8 @@ public struct AssetScene: View { Section { } header: { WalletHeaderView( model: model.assetHeaderModel, - isHideBalanceEnalbed: .constant(false), + isPrivacyEnabled: .constant(false), + balanceActionType: .none, onHeaderAction: model.onSelectHeader, onInfoAction: model.onSelectWalletHeaderInfo ) diff --git a/Features/Assets/Sources/ViewModels/AssetHeaderViewModel.swift b/Features/Assets/Sources/ViewModels/AssetHeaderViewModel.swift index 0d4fd249b..98f5335ff 100644 --- a/Features/Assets/Sources/ViewModels/AssetHeaderViewModel.swift +++ b/Features/Assets/Sources/ViewModels/AssetHeaderViewModel.swift @@ -12,8 +12,6 @@ struct AssetHeaderViewModel: Sendable { } extension AssetHeaderViewModel: HeaderViewModel { - var allowHiddenBalance: Bool { false } - var isWatchWallet: Bool { walletModel.wallet.type == .view } diff --git a/Features/MarketInsight/Sources/Scenes/ChartScene.swift b/Features/MarketInsight/Sources/Scenes/ChartScene.swift index 7c2d5c7c8..94d72e4d4 100644 --- a/Features/MarketInsight/Sources/Scenes/ChartScene.swift +++ b/Features/MarketInsight/Sources/Scenes/ChartScene.swift @@ -22,27 +22,10 @@ public struct ChartScene: View { public var body: some View { List { Section { } header: { - VStack { - VStack { - switch model.state { - case .noData: - StateEmptyView(title: model.emptyTitle) - case .loading: - LoadingView() - case .data(let chartModel): - ChartView(model: chartModel) - case .error(let error): - StateEmptyView( - title: Localized.Errors.errorOccured, - description: error.networkOrNoDataDescription, - image: Images.ErrorConent.error - ) - } - } - .frame(height: 320) - - PeriodSelectorView(selectedPeriod: $model.currentPeriod) - } + ChartStateView( + state: model.state, + selectedPeriod: $model.selectedPeriod + ) } .cleanListRow() diff --git a/Features/MarketInsight/Sources/Types/ChartDateValue.swift b/Features/MarketInsight/Sources/Types/ChartDateValue.swift deleted file mode 100644 index 26a3496e8..000000000 --- a/Features/MarketInsight/Sources/Types/ChartDateValue.swift +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation - -public struct ChartDateValue: Sendable { - public let date: Date - public let value: Double - - public init(date: Date, value: Double) { - self.date = date - self.value = value - } -} diff --git a/Features/MarketInsight/Sources/ViewModels/ChartPriceModel.swift b/Features/MarketInsight/Sources/ViewModels/ChartPriceModel.swift deleted file mode 100644 index be8cd4c3d..000000000 --- a/Features/MarketInsight/Sources/ViewModels/ChartPriceModel.swift +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives -import SwiftUI -import PrimitivesComponents -import Preferences -import Formatters - -struct ChartPriceModel { - - let period: ChartPeriod - let date: Date? - let price: Double - let priceChange: Double - - private let formatterPrice = CurrencyFormatter(currencyCode: Preferences.standard.currency) - private let formatterPercent = CurrencyFormatter(type: .percent, currencyCode: Preferences.standard.currency) - - var dateText: String? { - if let date { - return ChartDateFormatter(period: period, date: date).dateText - } - return .none - } - - var priceText: String { - formatterPrice.string(price) - } - - var priceChangeText: String? { - formatterPercent.string(priceChange) - } - - var priceChangeTextColor: Color { - PriceViewModel.priceChangeTextColor(value: priceChange) - } -} diff --git a/Features/MarketInsight/Sources/ViewModels/ChartSceneViewModel.swift b/Features/MarketInsight/Sources/ViewModels/ChartSceneViewModel.swift index 6cd7b449c..241638eca 100644 --- a/Features/MarketInsight/Sources/ViewModels/ChartSceneViewModel.swift +++ b/Features/MarketInsight/Sources/ViewModels/ChartSceneViewModel.swift @@ -11,6 +11,7 @@ import PriceService import Preferences import PriceAlertService import SwiftUI +import Formatters @MainActor @Observable @@ -25,7 +26,7 @@ public final class ChartSceneViewModel { private let preferences: Preferences = .standard var state: StateViewType = .loading - var currentPeriod: ChartPeriod { + var selectedPeriod: ChartPeriod { didSet { Task { await fetch() } } @@ -41,7 +42,7 @@ public final class ChartSceneViewModel { var emptyTitle: String { Localized.Common.notAvailable } var errorTitle: String { Localized.Errors.errorOccured } var hasMarketData: Bool { priceData?.market != nil } - + var priceAlertsViewModel: PriceAlertsViewModel { PriceAlertsViewModel(priceAlerts: priceData?.priceAlerts ?? []) } var showPriceAlerts: Bool { priceAlertsViewModel.hasPriceAlerts && isPriceAvailable } var isPriceAvailable: Bool { PriceViewModel(price: priceData?.price, currencyCode: preferences.currency).isPriceAvailable } @@ -60,7 +61,7 @@ public final class ChartSceneViewModel { self.assetModel = assetModel self.priceAlertService = priceAlertService self.walletId = walletId - self.currentPeriod = currentPeriod + self.selectedPeriod = currentPeriod self.priceRequest = PriceRequest(assetId: assetModel.asset.id) self.isPresentingSetPriceAlert = isPresentingSetPriceAlert } @@ -79,7 +80,7 @@ extension ChartSceneViewModel { do { let values = try await service.getCharts( assetId: assetModel.asset.id, - period: currentPeriod + period: selectedPeriod ) if let market = values.market { try priceService.updateMarketPrice(assetId: assetModel.asset.id, market: market, currency: preferences.currency) @@ -90,13 +91,19 @@ extension ChartSceneViewModel { var charts = values.prices.map { ChartDateValue(date: Date(timeIntervalSince1970: TimeInterval($0.timestamp)), value: Double($0.value) * rate) } - + if let price = price, let last = charts.last, price.updatedAt > last.date { charts.append(ChartDateValue(date: .now, value: price.price)) } let chartValues = try ChartValues.from(charts: charts) - let model = ChartValuesViewModel(period: currentPeriod, price: price?.mapToPrice(), values: chartValues) + let formatter = CurrencyFormatter(currencyCode: preferences.currency) + let model = ChartValuesViewModel( + period: selectedPeriod, + price: price?.mapToPrice(), + values: chartValues, + formatter: formatter + ) state = .data(model) } catch { state = .error(error) diff --git a/Features/MarketInsight/Sources/ViewModels/ChartValuesViewModel.swift b/Features/MarketInsight/Sources/ViewModels/ChartValuesViewModel.swift deleted file mode 100644 index 0d067473d..000000000 --- a/Features/MarketInsight/Sources/ViewModels/ChartValuesViewModel.swift +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import Foundation -import Primitives -import Charts -import Preferences -import Formatters - -public struct ChartValuesViewModel: Sendable { - let period: ChartPeriod - let price: Price? - let values: ChartValues - let formatter = CurrencyFormatter(currencyCode: Preferences.standard.currency) - - public static let defaultPeriod = ChartPeriod.day - - public init( - period: ChartPeriod, - price: Price?, - values: ChartValues - ) { - self.period = period - self.price = price - self.values = values - } - - public var charts: [ChartDateValue] { - values.charts - } - - public var lowerBoundValueText: String { - formatter.string(values.lowerBoundValue) - } - - public var upperBoundValueText: String { - formatter.string(values.upperBoundValue) - } - - var chartPriceModel: ChartPriceModel? { - if let price { - if period == Self.defaultPeriod { - return ChartPriceModel( - period: period, - date: .none, - price: price.price, - priceChange: price.priceChangePercentage24h - ) - } else { - let change = values.priceChange(base: values.firstValue, price: price.price) - return ChartPriceModel(period: period, date: .none, price: change.price, priceChange: change.priceChange) - } - } - return .none - } -} diff --git a/Features/MarketInsight/Sources/Views/ChartView.swift b/Features/MarketInsight/Sources/Views/ChartView.swift deleted file mode 100644 index b71cb69b6..000000000 --- a/Features/MarketInsight/Sources/Views/ChartView.swift +++ /dev/null @@ -1,182 +0,0 @@ -// Copyright (c). Gem Wallet. All rights reserved. - -import SwiftUI -import Style -import Charts -import Primitives - -struct ChartView: View { - let model: ChartValuesViewModel - - static let date = "Date" - static let value = "Value" - - @State private var selectedValue: ChartPriceModel? { - didSet { - if let selectedValue, selectedValue.date != oldValue?.date { - vibrate() - } - } - } - - init(model: ChartValuesViewModel) { - self.model = model - } - - var body: some View { - VStack { - chartPriceView - } - .padding(.top, .small) - .padding(.bottom, .tiny) - - Chart { - ForEach(model.charts, id: \.date) { item in - LineMark( - x: .value(Self.date, item.date), - y: .value(Self.value, item.value) - ) - .lineStyle(StrokeStyle(lineWidth: 3)) - .foregroundStyle(Colors.blue) - .interpolationMethod(.cardinal) - } - if let selectedValue, let date = selectedValue.date { - PointMark( - x: .value(Self.date, date), - y: .value(Self.value, selectedValue.price) - ) - .symbol() { - Circle() - .strokeBorder(Colors.blue, lineWidth: 2) - .background(Circle() - .foregroundColor(Colors.white)) - .frame(width: 12) - } - .foregroundStyle(Colors.blue) - - RuleMark( - x: .value(Self.date, date) - ) - .foregroundStyle(Colors.blue) - .lineStyle(StrokeStyle(lineWidth: 1, dash: [5])) - } - } - .chartOverlay { (proxy: ChartProxy) in - GeometryReader { geometry in - Rectangle().fill(.clear).contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - if let element = findElement(location: value.location, proxy: proxy, geometry: geometry) { - let change = model.values.priceChange(base: model.values.firstValue, price: element.value) - - selectedValue = ChartPriceModel( - period: model.period, - date: element.date, - price: change.price, - priceChange: change.priceChange - ) - } - }.onEnded { _ in - selectedValue = .none - } - ) - } - } - .padding(.vertical, .large) - .chartXAxis(.hidden) - .chartYAxis(.hidden) - .chartYScale(domain: model.values.yScale) - .chartBackground { proxy in - ZStack(alignment: .topLeading) { - GeometryReader { geo in - let maxWidth = 88.0 - - // Get chart bounds - if let plotFrame = proxy.plotFrame { - let chartBounds = geo[plotFrame] - - // lower bound - if let lowerBoundX = proxy.position(forX: model.values.lowerBoundDate) { - let x = calculateX(x: lowerBoundX, maxWidth: maxWidth, geoWidth: geo.size.width) - Text(model.lowerBoundValueText) - .font(.caption2) - .foregroundColor(Colors.gray) - .frame(width: maxWidth) - .offset(x: x, y: chartBounds.maxY + .small) // Plus spacing (8px) below chart - } - - // upper bound - if let upperBoundX = proxy.position(forX: model.values.upperBoundDate) { - let x = calculateX(x: upperBoundX, maxWidth: maxWidth, geoWidth: geo.size.width) - - Text(model.upperBoundValueText) - .font(.caption2) - .foregroundColor(Colors.gray) - .frame(width: maxWidth) - .offset(x: x, y: chartBounds.minY - .large) // Minus text height (~16px) + spacing (8px) - } - } - } - } - } - .padding(0) - } - - @ViewBuilder - private var chartPriceView: some View { - if let selectedValue { - ChartPriceView.from(model: selectedValue) - } else if let chartPriceModel = model.chartPriceModel { - ChartPriceView.from(model: chartPriceModel) - } - } - - private func findElement(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> ChartDateValue? { - guard let plotFrame = proxy.plotFrame else { - return .none - } - let relativeXPosition = location.x - geometry[plotFrame].origin.x - - if let date = proxy.value(atX: relativeXPosition) as Date? { - // Find the closest date element. - var minDistance: TimeInterval = .infinity - var dataIndex: Int? = nil - for (index, _) in model.charts.enumerated() { - let nthSalesDataDistance = model.charts[index].date.distance(to: date) - if abs(nthSalesDataDistance) < minDistance { - minDistance = abs(nthSalesDataDistance) - dataIndex = index - } - } - if let dataIndex { - return model.charts[dataIndex] - } - } - return .none - } - - private func calculateX(x: CGFloat, maxWidth: CGFloat, geoWidth: CGFloat) -> CGFloat { - let halfWidth = maxWidth / 2 - if x < halfWidth { - return x - halfWidth/2 - } else { - return min(x - halfWidth, geoWidth - maxWidth) - } - } - - private func vibrate() { - UIImpactFeedbackGenerator(style: .light).impactOccurred() - } -} - -extension ChartPriceView { - static func from(model: ChartPriceModel) -> some View { - ChartPriceView( - date: model.dateText, - price: model.priceText, - priceChange: model.priceChangeText, - priceChangeTextColor: model.priceChangeTextColor - ) - } -} diff --git a/Features/Perpetuals/Sources/Scenes/PerpetualPortfolioScene.swift b/Features/Perpetuals/Sources/Scenes/PerpetualPortfolioScene.swift new file mode 100644 index 000000000..2d0963d41 --- /dev/null +++ b/Features/Perpetuals/Sources/Scenes/PerpetualPortfolioScene.swift @@ -0,0 +1,65 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SwiftUI +import Primitives +import Components +import Style +import PrimitivesComponents + +public struct PerpetualPortfolioScene: View { + private let fetchTimer = Timer.publish(every: 60, tolerance: 1, on: .main, in: .common).autoconnect() + @State private var model: PerpetualPortfolioSceneViewModel + + public init(model: PerpetualPortfolioSceneViewModel) { + _model = State(initialValue: model) + } + + public var body: some View { + NavigationStack { + List { + Section { } header: { + VStack { + Picker("", selection: $model.selectedChartType) { + ForEach(PerpetualPortfolioChartType.allCases) { type in + Text(model.chartTypeTitle(type)).tag(type) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, Spacing.medium) + .padding(.vertical, Spacing.small) + + ChartStateView( + state: model.chartState, + selectedPeriod: $model.selectedPeriod, + periods: model.periods + ) + } + } + .cleanListRow() + + Section(header: Text(model.infoSectionTitle)) { + ListItemView(title: model.unrealizedPnlTitle, subtitle: model.unrealizedPnlValue.text, subtitleStyle: model.unrealizedPnlValue.style) + ListItemView(title: model.accountLeverageTitle, subtitle: model.accountLeverageText) + ListItemView(title: model.marginUsageTitle, subtitle: model.marginUsageText) + ListItemView(title: model.allTimePnlTitle, subtitle: model.allTimePnlValue.text, subtitleStyle: model.allTimePnlValue.style) + ListItemView(title: model.volumeTitle, subtitle: model.volumeText) + } + } + .navigationTitle(model.navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbarDismissItem(type: .close, placement: .cancellationAction) + .task { + await model.fetch() + } + .refreshable { + await model.fetch() + } + .onReceive(fetchTimer) { _ in + Task { + await model.fetch() + } + } + .listSectionSpacing(.compact) + } + } +} diff --git a/Features/Perpetuals/Sources/Scenes/PerpetualsScene.swift b/Features/Perpetuals/Sources/Scenes/PerpetualsScene.swift index f0bfd27a6..1653c2108 100644 --- a/Features/Perpetuals/Sources/Scenes/PerpetualsScene.swift +++ b/Features/Perpetuals/Sources/Scenes/PerpetualsScene.swift @@ -59,6 +59,14 @@ public struct PerpetualsScene: View { ) ) } + .sheet(isPresented: $model.isPresentingPortfolio) { + PerpetualPortfolioScene( + model: PerpetualPortfolioSceneViewModel( + wallet: model.wallet, + perpetualService: model.perpetualService + ) + ) + } } var list: some View { @@ -67,7 +75,8 @@ public struct PerpetualsScene: View { Section { } header: { WalletHeaderView( model: model.headerViewModel, - isHideBalanceEnalbed: .constant(model.preferences.isHideBalanceEnabled), + isPrivacyEnabled: .constant(false), + balanceActionType: .action(model.onSelectBalance), onHeaderAction: model.onSelectHeaderAction, onInfoAction: .none ) diff --git a/Features/Perpetuals/Sources/ViewModels/PerpetualPortfolioSceneViewModel.swift b/Features/Perpetuals/Sources/ViewModels/PerpetualPortfolioSceneViewModel.swift new file mode 100644 index 000000000..c0c09d3ea --- /dev/null +++ b/Features/Perpetuals/Sources/ViewModels/PerpetualPortfolioSceneViewModel.swift @@ -0,0 +1,138 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import SwiftUI +import Primitives +import Components +import Style +import Formatters +import PerpetualService +import PrimitivesComponents +import Localization + +@Observable +@MainActor +public final class PerpetualPortfolioSceneViewModel { + private let wallet: Wallet + private let perpetualService: PerpetualServiceable + private let currencyFormatter = CurrencyFormatter(type: .currency, currencyCode: Currency.usd.rawValue) + + var state: StateViewType = .loading { + didSet { + switch state { + case .data(let data): portfolio = data + case .error: portfolio = nil + case .loading, .noData: break + } + } + } + var selectedPeriod: ChartPeriod = .day + var selectedChartType: PerpetualPortfolioChartType = .value + + private var portfolio: PerpetualPortfolio? + + public init( + wallet: Wallet, + perpetualService: PerpetualServiceable + ) { + self.wallet = wallet + self.perpetualService = perpetualService + } + + var navigationTitle: String { Localized.Perpetuals.title } + var infoSectionTitle: String { Localized.Common.info } + + var periods: [ChartPeriod] { + guard let periods = portfolio?.availablePeriods, !periods.isEmpty else { + return [.day, .week, .month, .all] + } + return periods + } + + var chartState: StateViewType { + switch state { + case .loading: .loading + case .noData: .noData + case .error(let error): .error(error) + case .data(let data): chartModel(data: data).map { .data($0) } ?? .noData + } + } + + func chartTypeTitle(_ type: PerpetualPortfolioChartType) -> String { + switch type { + case .value: Localized.Perpetual.value + case .pnl: Localized.Perpetual.pnl + } + } + + func fetch() async { + guard let address = wallet.perpetualAddress else { return } + state = .loading + do { + let data = try await perpetualService.portfolio(address: address) + if !data.availablePeriods.contains(selectedPeriod), let first = data.availablePeriods.first { + selectedPeriod = first + } + state = .data(data) + } catch { + state = .error(error) + } + } +} + +// MARK: - Stats + +extension PerpetualPortfolioSceneViewModel { + var unrealizedPnlTitle: String { Localized.Perpetual.unrealizedPnl } + var unrealizedPnlValue: TextValue { TextValue(text: unrealizedPnlModel.text ?? "-", style: unrealizedPnlModel.textStyle) } + + var accountLeverageTitle: String { Localized.Perpetual.accountLeverage } + var accountLeverageText: String { portfolio?.accountSummary.map { String(format: "%.2fx", $0.accountLeverage) } ?? "-" } + + var marginUsageTitle: String { Localized.Perpetual.marginUsage } + var marginUsageText: String { portfolio?.accountSummary.map { CurrencyFormatter.percentSignLess.string($0.marginUsage * 100) } ?? "-" } + + var allTimePnlTitle: String { Localized.Perpetual.allTimePnl } + var allTimePnlValue: TextValue { TextValue(text: allTimePnlModel.text ?? "-", style: allTimePnlModel.textStyle) } + + var volumeTitle: String { Localized.Perpetual.volume } + var volumeText: String { portfolio.map { currencyFormatter.string($0.allTime?.volume ?? 0) } ?? "-" } +} + +// MARK: - Private + +extension PerpetualPortfolioSceneViewModel { + private var unrealizedPnlModel: PriceChangeViewModel { priceChangeModel(value: portfolio?.accountSummary?.unrealizedPnl) } + private var allTimePnlModel: PriceChangeViewModel { priceChangeModel(value: portfolio?.allTime?.pnlHistory.last?.value) } + + private func priceChangeModel(value: Double?) -> PriceChangeViewModel { + PriceChangeViewModel(value: value, currencyFormatter: currencyFormatter) + } + + private func chartModel(data: PerpetualPortfolio) -> ChartValuesViewModel? { + guard let timeframe = data.timeframeData(for: selectedPeriod) else { + return nil + } + let charts: [ChartDateValue] = switch selectedChartType { + case .value: timeframe.accountValueHistory + case .pnl: timeframe.pnlHistory + } + guard let values = try? ChartValues.from(charts: charts), values.hasVariation else { + return nil + } + let valueChange = values.lastValue - values.firstValue + let price = Price( + price: valueChange, + priceChangePercentage24h: values.percentageChange(from: values.firstValue, to: values.lastValue), + updatedAt: .now + ) + return ChartValuesViewModel( + period: selectedPeriod, + price: price, + values: values, + lineColor: Colors.blue, + formatter: currencyFormatter, + type: .priceChange + ) + } +} diff --git a/Features/Perpetuals/Sources/ViewModels/PerpetualPositionViewModel.swift b/Features/Perpetuals/Sources/ViewModels/PerpetualPositionViewModel.swift index 4d5e308e3..4e1f9915a 100644 --- a/Features/Perpetuals/Sources/ViewModels/PerpetualPositionViewModel.swift +++ b/Features/Perpetuals/Sources/ViewModels/PerpetualPositionViewModel.swift @@ -86,18 +86,12 @@ public struct PerpetualPositionViewModel { } public var fundingPaymentsTitle: String { Localized.Info.FundingPayments.title } - public var fundingPaymentsText: String { - guard let funding = data.position.funding else { return "--" } - return currencyFormatter.string(Double(funding)) - } - public var fundingPaymentsColor: Color { - guard let funding = data.position.funding else { return .secondary } - return PriceChangeColor.color(for: Double(funding)) - } - - public var fundingPaymentsTextStyle: TextStyle { - TextStyle(font: .callout, color: fundingPaymentsColor) + private var fundingPaymentsModel: PriceChangeViewModel { + PriceChangeViewModel(value: data.position.funding.map { Double($0) }, currencyFormatter: currencyFormatter) } + public var fundingPaymentsText: String { fundingPaymentsModel.text ?? "-" } + public var fundingPaymentsColor: Color { fundingPaymentsModel.color } + public var fundingPaymentsTextStyle: TextStyle { fundingPaymentsModel.textStyle } public var sizeTitle: String { Localized.Perpetual.size } public var sizeValueText: String { diff --git a/Features/Perpetuals/Sources/ViewModels/PerpetualSceneViewModel.swift b/Features/Perpetuals/Sources/ViewModels/PerpetualSceneViewModel.swift index beb5259f0..3ffdb5cdb 100644 --- a/Features/Perpetuals/Sources/ViewModels/PerpetualSceneViewModel.swift +++ b/Features/Perpetuals/Sources/ViewModels/PerpetualSceneViewModel.swift @@ -127,7 +127,9 @@ public final class PerpetualSceneViewModel { } Task { do { - try await perpetualService.updatePositions(wallet: wallet) + if let address = wallet.perpetualAddress { + try await perpetualService.updatePositions(address: address, walletId: wallet.walletId) + } } catch { debugLog("Failed to load data: \(error)") } diff --git a/Features/Perpetuals/Sources/ViewModels/PerpetualsHeaderViewModel.swift b/Features/Perpetuals/Sources/ViewModels/PerpetualsHeaderViewModel.swift index ad5417309..e61ff812d 100644 --- a/Features/Perpetuals/Sources/ViewModels/PerpetualsHeaderViewModel.swift +++ b/Features/Perpetuals/Sources/ViewModels/PerpetualsHeaderViewModel.swift @@ -24,7 +24,6 @@ public struct PerpetualsHeaderViewModel { } extension PerpetualsHeaderViewModel: HeaderViewModel { - public var allowHiddenBalance: Bool { true } public var isWatchWallet: Bool { walletType == .view } public var title: String { currencyFormatter.string(balance.total) } public var assetImage: AssetImage? { .none } diff --git a/Features/Perpetuals/Sources/ViewModels/PerpetualsSceneViewModel.swift b/Features/Perpetuals/Sources/ViewModels/PerpetualsSceneViewModel.swift index a0316a089..5544ecd10 100644 --- a/Features/Perpetuals/Sources/ViewModels/PerpetualsSceneViewModel.swift +++ b/Features/Perpetuals/Sources/ViewModels/PerpetualsSceneViewModel.swift @@ -15,7 +15,7 @@ import ActivityService @Observable @MainActor public final class PerpetualsSceneViewModel { - private let perpetualService: PerpetualServiceable + let perpetualService: PerpetualServiceable let activityService: ActivityService let preferences: Preferences = .standard @@ -35,6 +35,7 @@ public final class PerpetualsSceneViewModel { var searchQuery: String = .empty var isSearching: Bool = false var isPresentingRecents: Bool = false + var isPresentingPortfolio: Bool = false let onSelectAssetType: ((SelectAssetType) -> Void)? let onSelectAsset: ((Asset) -> Void)? @@ -96,8 +97,9 @@ extension PerpetualsSceneViewModel { } private func updatePositions() async { + guard let address = wallet.perpetualAddress else { return } do { - try await perpetualService.updatePositions(wallet: wallet) + try await perpetualService.updatePositions(address: address, walletId: wallet.walletId) } catch { debugLog("Failed to update positions: \(error)") } @@ -169,4 +171,8 @@ extension PerpetualsSceneViewModel { onSelectAsset?(asset) isPresentingRecents = false } + + func onSelectBalance() { + isPresentingPortfolio = true + } } diff --git a/Features/Perpetuals/Sources/Views/CandlestickChartView.swift b/Features/Perpetuals/Sources/Views/CandlestickChartView.swift index 4e7872a1e..ac3294c83 100644 --- a/Features/Perpetuals/Sources/Views/CandlestickChartView.swift +++ b/Features/Perpetuals/Sources/Views/CandlestickChartView.swift @@ -5,6 +5,7 @@ import Charts import Style import Primitives import PrimitivesComponents +import Formatters private struct ChartKey { static let date = "Date" @@ -20,8 +21,9 @@ struct CandlestickChartView: View { private let period: ChartPeriod private let basePrice: Double? private let lineModels: [ChartLineViewModel] + private let formatter: CurrencyFormatter - @State private var selectedCandle: ChartPriceModel? { + @State private var selectedCandle: ChartPriceViewModel? { didSet { if let selectedCandle, selectedCandle.date != oldValue?.date { vibrate() @@ -33,12 +35,14 @@ struct CandlestickChartView: View { data: [ChartCandleStick], period: ChartPeriod = .day, basePrice: Double? = nil, - lineModels: [ChartLineViewModel] = [] + lineModels: [ChartLineViewModel] = [], + formatter: CurrencyFormatter = CurrencyFormatter(type: .currency, currencyCode: Currency.usd.rawValue) ) { self.data = data self.period = period self.basePrice = basePrice ?? data.first?.close self.lineModels = lineModels + self.formatter = formatter } var body: some View { @@ -51,9 +55,9 @@ struct CandlestickChartView: View { private var priceHeader: some View { VStack { if let selectedCandle { - ChartPriceView.from(model: selectedCandle) + ChartPriceView(model: selectedCandle) } else if let currentPrice = currentPriceModel { - ChartPriceView.from(model: currentPrice) + ChartPriceView(model: currentPrice) } } .padding(.top, Spacing.small) @@ -183,35 +187,26 @@ struct CandlestickChartView: View { } } - private var currentPriceModel: ChartPriceModel? { + private var currentPriceModel: ChartPriceViewModel? { guard let lastCandle = data.last, let base = basePrice else { return nil } - - let priceChange = lastCandle.close - base - let priceChangePercentage = (priceChange / base) * 100 - - return ChartPriceModel( + return ChartPriceViewModel( period: period, date: nil, price: lastCandle.close, - priceChange: priceChange, - priceChangePercentage: priceChangePercentage, - currency: .default + priceChangePercentage: ChartPriceViewModel.priceChangePercentage(close: lastCandle.close, base: base), + formatter: formatter ) } - private func createPriceModel(for candle: ChartCandleStick) -> ChartPriceModel { + private func createPriceModel(for candle: ChartCandleStick) -> ChartPriceViewModel { let base = basePrice ?? data.first?.close ?? candle.close - let priceChange = candle.close - base - let priceChangePercentage = (priceChange / base) * 100 - - return ChartPriceModel( + return ChartPriceViewModel( period: period, date: candle.date, price: candle.close, - priceChange: priceChange, - priceChangePercentage: priceChangePercentage, - currency: .default + priceChangePercentage: ChartPriceViewModel.priceChangePercentage(close: candle.close, base: base), + formatter: formatter ) } diff --git a/Features/Perpetuals/TestKit/PerpetualPortfolioSceneViewModel+TestKit.swift b/Features/Perpetuals/TestKit/PerpetualPortfolioSceneViewModel+TestKit.swift new file mode 100644 index 000000000..e7d036667 --- /dev/null +++ b/Features/Perpetuals/TestKit/PerpetualPortfolioSceneViewModel+TestKit.swift @@ -0,0 +1,20 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Perpetuals +import Primitives +import PrimitivesTestKit +import PerpetualService +import PerpetualServiceTestKit + +public extension PerpetualPortfolioSceneViewModel { + @MainActor + static func mock( + wallet: Wallet = .mock(), + perpetualService: PerpetualServiceable = PerpetualService.mock() + ) -> PerpetualPortfolioSceneViewModel { + PerpetualPortfolioSceneViewModel( + wallet: wallet, + perpetualService: perpetualService + ) + } +} diff --git a/Features/Perpetuals/Tests/PerpetualPortfolioSceneViewModelTests.swift b/Features/Perpetuals/Tests/PerpetualPortfolioSceneViewModelTests.swift new file mode 100644 index 000000000..e8596a8e7 --- /dev/null +++ b/Features/Perpetuals/Tests/PerpetualPortfolioSceneViewModelTests.swift @@ -0,0 +1,95 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Testing +import Primitives +import PrimitivesTestKit +import PrimitivesComponents +import Components +import PerpetualsTestKit +@testable import Perpetuals + +struct PerpetualPortfolioSceneViewModelTests { + + @Test + @MainActor + func navigationTitle() { + #expect(PerpetualPortfolioSceneViewModel.mock().navigationTitle == "Perpetuals") + } + + @Test + @MainActor + func chartTypeTitle() { + let model = PerpetualPortfolioSceneViewModel.mock() + #expect(model.chartTypeTitle(.value) == "Value") + #expect(model.chartTypeTitle(.pnl) == "PnL") + } + + @Test + @MainActor + func periods() { + let model = PerpetualPortfolioSceneViewModel.mock() + #expect(model.periods == [.day, .week, .month, .all]) + + model.state = .data(.mock(day: .mock(), week: .mock(), month: nil, allTime: nil)) + #expect(model.periods == [.day, .week]) + } + + @Test + @MainActor + func chartState() { + let model = PerpetualPortfolioSceneViewModel.mock() + + model.state = .loading + #expect(model.chartState.isLoading) + + model.state = .noData + #expect(model.chartState.isNoData) + + model.state = .error(AnyError("test")) + #expect(model.chartState.isError) + + model.state = .data(.mock(day: .mock(accountValueHistory: ChartDateValue.mockHistory(values: [100, 100, 100])))) + #expect(model.chartState.isNoData) + + model.state = .data(.mock(day: .mock(accountValueHistory: ChartDateValue.mockHistory(values: [100, 105, 110])))) + #expect(model.chartState.value != nil) + } + + @Test + @MainActor + func chartStateType() { + let model = PerpetualPortfolioSceneViewModel.mock() + model.state = .data(.mock(day: .mock( + accountValueHistory: ChartDateValue.mockHistory(values: [100, 110]), + pnlHistory: ChartDateValue.mockHistory(values: [0, 10]) + ))) + + model.selectedChartType = .value + if case .data(let chartModel) = model.chartState { + #expect(chartModel.type == .priceChange) + } + + model.selectedChartType = .pnl + if case .data(let chartModel) = model.chartState { + #expect(chartModel.type == .priceChange) + } + } + + @Test + @MainActor + func valueChangeCalculation() { + let model = PerpetualPortfolioSceneViewModel.mock() + model.state = .data(.mock(day: .mock(accountValueHistory: ChartDateValue.mockHistory(values: [0, 50, 30, 100])))) + + if case .data(let chartModel) = model.chartState { + #expect(chartModel.price?.price == 100) + #expect(chartModel.price?.priceChangePercentage24h == 0) + } + + model.state = .data(.mock(day: .mock(accountValueHistory: ChartDateValue.mockHistory(values: [50, 100, 75])))) + if case .data(let chartModel) = model.chartState { + #expect(chartModel.price?.price == 25) + #expect(chartModel.price?.priceChangePercentage24h == 50) + } + } +} diff --git a/Features/Staking/Sources/Scenes/StakeDetailScene.swift b/Features/Staking/Sources/Scenes/StakeDetailScene.swift index bc517f76a..3344dd913 100644 --- a/Features/Staking/Sources/Scenes/StakeDetailScene.swift +++ b/Features/Staking/Sources/Scenes/StakeDetailScene.swift @@ -16,7 +16,8 @@ public struct StakeDetailScene: View { Section { } header: { WalletHeaderView( model: model.validatorHeaderViewModel, - isHideBalanceEnalbed: .constant(false), + isPrivacyEnabled: .constant(false), + balanceActionType: .none, onHeaderAction: nil, onInfoAction: nil ) diff --git a/Features/Staking/Sources/ViewModels/ValidatorHeaderViewModel.swift b/Features/Staking/Sources/ViewModels/ValidatorHeaderViewModel.swift index 23e68823c..c43566bca 100644 --- a/Features/Staking/Sources/ViewModels/ValidatorHeaderViewModel.swift +++ b/Features/Staking/Sources/ViewModels/ValidatorHeaderViewModel.swift @@ -10,7 +10,6 @@ struct ValidatorHeaderViewModel: HeaderViewModel { let isWatchWallet: Bool = false let buttons: [HeaderButton] = [] - let allowHiddenBalance: Bool = false var assetImage: AssetImage? { model.validatorImage diff --git a/Features/WalletTab/Sources/Scenes/WalletScene.swift b/Features/WalletTab/Sources/Scenes/WalletScene.swift index c395b9236..ee1b050d9 100644 --- a/Features/WalletTab/Sources/Scenes/WalletScene.swift +++ b/Features/WalletTab/Sources/Scenes/WalletScene.swift @@ -23,7 +23,8 @@ public struct WalletScene: View { Section { } header: { WalletHeaderView( model: model.walletHeaderModel, - isHideBalanceEnalbed: $preferences.isHideBalanceEnabled, + isPrivacyEnabled: $preferences.isHideBalanceEnabled, + balanceActionType: .privacyToggle, onHeaderAction: model.onHeaderAction, onInfoAction: model.onSelectWatchWalletInfo ) diff --git a/Packages/Blockchain/Sources/Gateway/GatewayService.swift b/Packages/Blockchain/Sources/Gateway/GatewayService.swift index 40173a5df..58c452346 100644 --- a/Packages/Blockchain/Sources/Gateway/GatewayService.swift +++ b/Packages/Blockchain/Sources/Gateway/GatewayService.swift @@ -166,11 +166,15 @@ extension GatewayService { } } - public func getCandlesticks(chain: Primitives.Chain, symbol: String, period: ChartPeriod) async throws -> [ChartCandleStick] { - try await gateway.getCandlesticks(chain: chain.rawValue, symbol: symbol, period: period.rawValue).map { + public func getPerpetualCandlesticks(chain: Primitives.Chain, symbol: String, period: ChartPeriod) async throws -> [ChartCandleStick] { + try await gateway.getPerpetualCandlesticks(chain: chain.rawValue, symbol: symbol, period: period.rawValue).map { try $0.map() } } + + public func getPerpetualPortfolio(chain: Primitives.Chain, address: String) async throws -> PerpetualPortfolio { + try await gateway.getPerpetualPortfolio(chain: chain.rawValue, address: address).map() + } } extension GatewayService { diff --git a/Packages/Components/Sources/Types/BalanceActionType.swift b/Packages/Components/Sources/Types/BalanceActionType.swift new file mode 100644 index 000000000..470a8b16a --- /dev/null +++ b/Packages/Components/Sources/Types/BalanceActionType.swift @@ -0,0 +1,9 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public enum BalanceActionType: Sendable { + case privacyToggle + case action(@MainActor @Sendable () -> Void) + case none +} diff --git a/Packages/FeatureServices/PerpetualService/PerpetualObserverService.swift b/Packages/FeatureServices/PerpetualService/PerpetualObserverService.swift index 7548150b0..c55fdb051 100644 --- a/Packages/FeatureServices/PerpetualService/PerpetualObserverService.swift +++ b/Packages/FeatureServices/PerpetualService/PerpetualObserverService.swift @@ -4,33 +4,33 @@ import Foundation import Primitives public actor PerpetualObserverService: Sendable { - + private let perpetualService: PerpetualServiceable private var updateTask: Task? private var currentWallet: Wallet? - + public init(perpetualService: PerpetualServiceable) { self.perpetualService = perpetualService } - + deinit { updateTask?.cancel() } - + public func connect(for wallet: Wallet) async { guard currentWallet?.id != wallet.id else { return } - + await disconnect() currentWallet = wallet - + updateTask = Task { [weak self] in while !Task.isCancelled { guard let self, let wallet = await self.currentWallet else { return } do { let positions = try await self.perpetualService.getPositions(walletId: wallet.walletId) - if positions.isNotEmpty { - try await self.perpetualService.updatePositions(wallet: wallet) + if let address = wallet.perpetualAddress, positions.isNotEmpty { + try await self.perpetualService.updatePositions(address: address, walletId: wallet.walletId) } } catch { debugLog("PerpetualObserverService error getting positions: \(error)") @@ -40,7 +40,7 @@ public actor PerpetualObserverService: Sendable { } } } - + public func disconnect() async { updateTask?.cancel() updateTask = nil diff --git a/Packages/FeatureServices/PerpetualService/PerpetualProvider.swift b/Packages/FeatureServices/PerpetualService/PerpetualProvider.swift index 7e3232d1f..eb532f21d 100644 --- a/Packages/FeatureServices/PerpetualService/PerpetualProvider.swift +++ b/Packages/FeatureServices/PerpetualService/PerpetualProvider.swift @@ -9,6 +9,7 @@ public protocol PerpetualProvidable: Sendable { func getPositions(address: String) async throws -> PerpetualPositionsSummary func getPerpetualsData() async throws -> [PerpetualData] func getCandlesticks(symbol: String, period: ChartPeriod) async throws -> [ChartCandleStick] + func getPortfolio(address: String) async throws -> PerpetualPortfolio } struct GatewayPerpetualProvider: PerpetualProvidable { @@ -37,6 +38,10 @@ struct GatewayPerpetualProvider: PerpetualProvidable { } func getCandlesticks(symbol: String, period: ChartPeriod) async throws -> [ChartCandleStick] { - try await gateway.getCandlesticks(chain: chain, symbol: symbol, period: period) + try await gateway.getPerpetualCandlesticks(chain: chain, symbol: symbol, period: period) + } + + func getPortfolio(address: String) async throws -> PerpetualPortfolio { + try await gateway.getPerpetualPortfolio(chain: chain, address: address) } } diff --git a/Packages/FeatureServices/PerpetualService/PerpetualService.swift b/Packages/FeatureServices/PerpetualService/PerpetualService.swift index 2a48d5619..bfb88a516 100644 --- a/Packages/FeatureServices/PerpetualService/PerpetualService.swift +++ b/Packages/FeatureServices/PerpetualService/PerpetualService.swift @@ -36,19 +36,14 @@ public struct PerpetualService: PerpetualServiceable { try store.getPerpetuals() } - public func updatePositions(wallet: Wallet) async throws { - guard let account = wallet.accounts.first(where: { - $0.chain == .arbitrum || $0.chain == .hyperCore || $0.chain == .hyperliquid - }) else { - return - } - let summary = try await provider.getPositions(address: account.address) - - try syncProviderBalances(walletId: wallet.walletId, balance: summary.balance) + public func updatePositions(address: String, walletId: WalletId) async throws { + let summary = try await provider.getPositions(address: address) + + try syncProviderBalances(walletId: walletId, balance: summary.balance) try syncProviderPositions( positions: summary.positions, provider: provider.provider(), - walletId: wallet.walletId + walletId: walletId ) } @@ -127,7 +122,11 @@ public struct PerpetualService: PerpetualServiceable { public func candlesticks(symbol: String, period: ChartPeriod) async throws -> [ChartCandleStick] { return try await provider.getCandlesticks(symbol: symbol, period: period) } - + + public func portfolio(address: String) async throws -> PerpetualPortfolio { + try await provider.getPortfolio(address: address) + } + public func setPinned(_ isPinned: Bool, perpetualId: String) throws { try store.setPinned(for: [perpetualId], value: isPinned) } diff --git a/Packages/FeatureServices/PerpetualService/Protocols/PerpetualServiceable.swift b/Packages/FeatureServices/PerpetualService/Protocols/PerpetualServiceable.swift index cfa4fc14a..a57ac30c4 100644 --- a/Packages/FeatureServices/PerpetualService/Protocols/PerpetualServiceable.swift +++ b/Packages/FeatureServices/PerpetualService/Protocols/PerpetualServiceable.swift @@ -6,9 +6,10 @@ import Primitives public protocol PerpetualServiceable: Sendable { func getPositions(walletId: WalletId) async throws -> [PerpetualPosition] func getMarkets() async throws -> [Perpetual] - func updatePositions(wallet: Wallet) async throws + func updatePositions(address: String, walletId: WalletId) async throws func updateMarkets() async throws func updateMarket(symbol: String) async throws func candlesticks(symbol: String, period: ChartPeriod) async throws -> [ChartCandleStick] + func portfolio(address: String) async throws -> PerpetualPortfolio func setPinned(_ isPinned: Bool, perpetualId: String) throws } diff --git a/Packages/FeatureServices/PerpetualService/TestKit/PerpetualService+TestKit.swift b/Packages/FeatureServices/PerpetualService/TestKit/PerpetualService+TestKit.swift index 48919071f..8addeb6ac 100644 --- a/Packages/FeatureServices/PerpetualService/TestKit/PerpetualService+TestKit.swift +++ b/Packages/FeatureServices/PerpetualService/TestKit/PerpetualService+TestKit.swift @@ -44,4 +44,8 @@ public struct PerpetualProviderMock: PerpetualProvidable { public func getCandlesticks(symbol: String, period: ChartPeriod) async throws -> [ChartCandleStick] { [] } + + public func getPortfolio(address: String) async throws -> PerpetualPortfolio { + PerpetualPortfolio(day: nil, week: nil, month: nil, allTime: nil, accountSummary: nil) + } } diff --git a/Packages/GemstonePrimitives/Sources/Extensions/GemChartDateValue+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/GemChartDateValue+GemstonePrimitives.swift new file mode 100644 index 000000000..629f1b50d --- /dev/null +++ b/Packages/GemstonePrimitives/Sources/Extensions/GemChartDateValue+GemstonePrimitives.swift @@ -0,0 +1,14 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +extension GemChartDateValue { + public func map() -> ChartDateValue { + ChartDateValue( + date: Date(timeIntervalSince1970: TimeInterval(date)), + value: value + ) + } +} diff --git a/Packages/GemstonePrimitives/Sources/Extensions/GemPerpetualPortfolio+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/GemPerpetualPortfolio+GemstonePrimitives.swift new file mode 100644 index 000000000..45bdbdf07 --- /dev/null +++ b/Packages/GemstonePrimitives/Sources/Extensions/GemPerpetualPortfolio+GemstonePrimitives.swift @@ -0,0 +1,38 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Gemstone +import Primitives + +extension GemPerpetualPortfolio { + public func map() -> PerpetualPortfolio { + PerpetualPortfolio( + day: day?.map(), + week: week?.map(), + month: month?.map(), + allTime: allTime?.map(), + accountSummary: accountSummary?.map() + ) + } +} + +extension GemPerpetualPortfolioTimeframeData { + public func map() -> PerpetualPortfolioTimeframeData { + PerpetualPortfolioTimeframeData( + accountValueHistory: accountValueHistory.map { $0.map() }, + pnlHistory: pnlHistory.map { $0.map() }, + volume: volume + ) + } +} + +extension GemPerpetualAccountSummary { + public func map() -> PerpetualAccountSummary { + PerpetualAccountSummary( + accountValue: accountValue, + accountLeverage: accountLeverage, + marginUsage: marginUsage, + unrealizedPnl: unrealizedPnl + ) + } +} diff --git a/Packages/Localization/Sources/Localized.swift b/Packages/Localization/Sources/Localized.swift index 4fe542342..4fdc5f410 100644 --- a/Packages/Localization/Sources/Localized.swift +++ b/Packages/Localization/Sources/Localized.swift @@ -903,6 +903,10 @@ public enum Localized { } } public enum Perpetual { + /// Account Leverage + public static let accountLeverage = Localized.tr("Localizable", "perpetual.account_leverage", fallback: "Account Leverage") + /// All Time PnL + public static let allTimePnl = Localized.tr("Localizable", "perpetual.all_time_pnl", fallback: "All Time PnL") /// Auto Close public static let autoClose = Localized.tr("Localizable", "perpetual.auto_close", fallback: "Auto Close") /// Close %@ @@ -927,6 +931,8 @@ public enum Localized { public static let long = Localized.tr("Localizable", "perpetual.long", fallback: "Long") /// Margin public static let margin = Localized.tr("Localizable", "perpetual.margin", fallback: "Margin") + /// Margin Usage + public static let marginUsage = Localized.tr("Localizable", "perpetual.margin_usage", fallback: "Margin Usage") /// Market Price public static let marketPrice = Localized.tr("Localizable", "perpetual.market_price", fallback: "Market Price") /// Modify @@ -953,6 +959,12 @@ public enum Localized { public static let short = Localized.tr("Localizable", "perpetual.short", fallback: "Short") /// Size public static let size = Localized.tr("Localizable", "perpetual.size", fallback: "Size") + /// Unrealized PnL + public static let unrealizedPnl = Localized.tr("Localizable", "perpetual.unrealized_pnl", fallback: "Unrealized PnL") + /// Value + public static let value = Localized.tr("Localizable", "perpetual.value", fallback: "Value") + /// Volume + public static let volume = Localized.tr("Localizable", "perpetual.volume", fallback: "Volume") public enum AutoClose { /// Expected loss public static let expectedLoss = Localized.tr("Localizable", "perpetual.auto_close.expected_loss", fallback: "Expected loss") diff --git a/Packages/Localization/Sources/Resources/ar.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/ar.lproj/Localizable.strings index 93a2e4597..7e24e5041 100644 --- a/Packages/Localization/Sources/Resources/ar.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/ar.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "عدد العملات المتاحة حاليًا والمتداولة في السوق."; "info.total_supply.description" = "إجمالي عدد العملات المعدنية الموجودة، بما في ذلك العملات المعدنية المقفلة أو المحجوزة."; "info.max_supply.title" = "أقصى إمداد"; -"info.max_supply.description" = "الحد الأقصى لعدد العملات المعدنية التي ستوجد على الإطلاق."; \ No newline at end of file +"info.max_supply.description" = "الحد الأقصى لعدد العملات المعدنية التي ستوجد على الإطلاق."; +"perpetual.value" = "قيمة"; +"perpetual.unrealized_pnl" = "الأرباح والخسائر غير المحققة"; +"perpetual.volume" = "مقدار"; +"perpetual.all_time_pnl" = "إجمالي الربح والخسارة على مر الزمن"; +"perpetual.margin_usage" = "استخدام الهامش"; +"perpetual.account_leverage" = "الاستفادة من الحساب"; +"asset.all_time_high" = "أعلى مستوى على الإطلاق"; +"asset.all_time_low" = "أدنى مستوى على الإطلاق"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/bn.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/bn.lproj/Localizable.strings index b59ba1a92..7888d712d 100644 --- a/Packages/Localization/Sources/Resources/bn.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/bn.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "বর্তমানে বাজারে উপলব্ধ এবং লেনদেন হওয়া কয়েনের সংখ্যা।\""; "info.total_supply.description" = "লক করা বা সংরক্ষিত কয়েন সহ বিদ্যমান মোট কয়েনের সংখ্যা।"; "info.max_supply.title" = "সর্বোচ্চ সরবরাহ"; -"info.max_supply.description" = "সর্বোচ্চ সংখ্যক কয়েন বিদ্যমান থাকবে।"; \ No newline at end of file +"info.max_supply.description" = "সর্বোচ্চ সংখ্যক কয়েন বিদ্যমান থাকবে।"; +"perpetual.value" = "মূল্য"; +"perpetual.unrealized_pnl" = "অবাস্তব PnL"; +"perpetual.volume" = "আয়তন"; +"perpetual.all_time_pnl" = "সর্বকালের পিএনএল"; +"perpetual.margin_usage" = "মার্জিন ব্যবহার"; +"perpetual.account_leverage" = "অ্যাকাউন্ট লিভারেজ"; +"asset.all_time_high" = "সর্বকালের সর্বোচ্চ"; +"asset.all_time_low" = "সর্বকালের সর্বনিম্ন"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/cs.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/cs.lproj/Localizable.strings index d8f16a8c8..cb9b16ae1 100644 --- a/Packages/Localization/Sources/Resources/cs.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/cs.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Počet mincí aktuálně dostupných a obchodovaných na trhu."; "info.total_supply.description" = "Celkový počet existujících mincí, včetně uzamčených nebo rezervovaných mincí."; "info.max_supply.title" = "Maximální nabídka"; -"info.max_supply.description" = "Maximální počet mincí, které kdy budou existovat."; \ No newline at end of file +"info.max_supply.description" = "Maximální počet mincí, které kdy budou existovat."; +"perpetual.value" = "Hodnota"; +"perpetual.unrealized_pnl" = "Nerealizovaný zisk/ztráta"; +"perpetual.volume" = "Objem"; +"perpetual.all_time_pnl" = "Výhra všech dob"; +"perpetual.margin_usage" = "Využití marže"; +"perpetual.account_leverage" = "Pákový efekt účtu"; +"asset.all_time_high" = "Historické maximum"; +"asset.all_time_low" = "Historické minimum"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/da.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/da.lproj/Localizable.strings index 8e95a3e6a..e75a7bd21 100644 --- a/Packages/Localization/Sources/Resources/da.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/da.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Antallet af tilgængelige og handlede mønter på markedet i øjeblikket."; "info.total_supply.description" = "Det samlede antal mønter, der findes, inklusive låste eller reserverede mønter."; "info.max_supply.title" = "Maks. forsyning"; -"info.max_supply.description" = "Det maksimale antal mønter, der nogensinde vil eksistere."; \ No newline at end of file +"info.max_supply.description" = "Det maksimale antal mønter, der nogensinde vil eksistere."; +"perpetual.value" = "Værdi"; +"perpetual.unrealized_pnl" = "Urealiseret PnL"; +"perpetual.volume" = "Bind"; +"perpetual.all_time_pnl" = "Alle tiders PnL"; +"perpetual.margin_usage" = "Marginforbrug"; +"perpetual.account_leverage" = "Kontogearing"; +"asset.all_time_high" = "Alle tiders højder"; +"asset.all_time_low" = "Laveste niveau nogensinde"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/de.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/de.lproj/Localizable.strings index 980e394ee..a5509e25c 100644 --- a/Packages/Localization/Sources/Resources/de.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/de.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Die Anzahl der aktuell verfügbaren und auf dem Markt gehandelten Coins."; "info.total_supply.description" = "Die Gesamtzahl der existierenden Münzen, einschließlich gesperrter oder reservierter Münzen."; "info.max_supply.title" = "Maximale Versorgung"; -"info.max_supply.description" = "Die maximale Anzahl an Münzen, die jemals existieren wird."; \ No newline at end of file +"info.max_supply.description" = "Die maximale Anzahl an Münzen, die jemals existieren wird."; +"perpetual.value" = "Wert"; +"perpetual.unrealized_pnl" = "Nicht realisierte Gewinn- und Verlustrechnung"; +"perpetual.volume" = "Volumen"; +"perpetual.all_time_pnl" = "Gesamtgewinn und -verlust"; +"perpetual.margin_usage" = "Margennutzung"; +"perpetual.account_leverage" = "Kontohebel"; +"asset.all_time_high" = "Allzeithoch"; +"asset.all_time_low" = "Allzeittief"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings index fe0a53a7f..f33dcc824 100644 --- a/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/en.lproj/Localizable.strings @@ -572,5 +572,11 @@ "info.total_supply.description" = "The total number of coins that exist, including locked or reserved coins."; "info.max_supply.title" = "Max Supply"; "info.max_supply.description" = "The maximum number of coins that will ever exist."; +"perpetual.value" = "Value"; +"perpetual.unrealized_pnl" = "Unrealized PnL"; +"perpetual.volume" = "Volume"; +"perpetual.all_time_pnl" = "All Time PnL"; +"perpetual.margin_usage" = "Margin Usage"; +"perpetual.account_leverage" = "Account Leverage"; "asset.all_time_high" = "All Time High"; "asset.all_time_low" = "All Time Low"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/es.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/es.lproj/Localizable.strings index 773ea7fed..6627d234c 100644 --- a/Packages/Localization/Sources/Resources/es.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/es.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "La cantidad de monedas actualmente disponibles y comercializadas en el mercado.\""; "info.total_supply.description" = "El número total de monedas que existen, incluidas las monedas bloqueadas o reservadas."; "info.max_supply.title" = "Suministro máximo"; -"info.max_supply.description" = "El número máximo de monedas que jamás existirán."; \ No newline at end of file +"info.max_supply.description" = "El número máximo de monedas que jamás existirán."; +"perpetual.value" = "Valor"; +"perpetual.unrealized_pnl" = "PnL no realizado"; +"perpetual.volume" = "Volumen"; +"perpetual.all_time_pnl" = "PnL de todos los tiempos"; +"perpetual.margin_usage" = "Uso del margen"; +"perpetual.account_leverage" = "Apalancamiento de la cuenta"; +"asset.all_time_high" = "Máximo histórico"; +"asset.all_time_low" = "Mínimo histórico"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/fa.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/fa.lproj/Localizable.strings index 77dbfc936..835961494 100644 --- a/Packages/Localization/Sources/Resources/fa.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/fa.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "تعداد کوین‌های موجود و قابل معامله در بازار."; "info.total_supply.description" = "تعداد کل سکه‌های موجود، شامل سکه‌های قفل‌شده یا رزرو شده."; "info.max_supply.title" = "حداکثر عرضه"; -"info.max_supply.description" = "حداکثر تعداد سکه‌هایی که تا به حال وجود خواهد داشت."; \ No newline at end of file +"info.max_supply.description" = "حداکثر تعداد سکه‌هایی که تا به حال وجود خواهد داشت."; +"perpetual.value" = "ارزش"; +"perpetual.unrealized_pnl" = "سود و زیان تحقق نیافته"; +"perpetual.volume" = "حجم"; +"perpetual.all_time_pnl" = "سود و زیان تمام وقت"; +"perpetual.margin_usage" = "استفاده از مارجین"; +"perpetual.account_leverage" = "اهرم حساب"; +"asset.all_time_high" = "بالاترین رکورد تمام دوران"; +"asset.all_time_low" = "پایین‌ترین سطح تمام دوران"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/fil.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/fil.lproj/Localizable.strings index 319a4388b..020661ed8 100644 --- a/Packages/Localization/Sources/Resources/fil.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/fil.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Ang bilang ng mga barya na kasalukuyang magagamit at ipinagbibili sa merkado.\""; "info.total_supply.description" = "Ang kabuuang bilang ng mga barya na umiiral, kabilang ang mga naka-lock o nakareserbang barya."; "info.max_supply.title" = "Pinakamataas na Suplay"; -"info.max_supply.description" = "Ang pinakamataas na bilang ng mga barya na iiral kailanman."; \ No newline at end of file +"info.max_supply.description" = "Ang pinakamataas na bilang ng mga barya na iiral kailanman."; +"perpetual.value" = "Halaga"; +"perpetual.unrealized_pnl" = "Hindi Natanto na PnL"; +"perpetual.volume" = "Dami"; +"perpetual.all_time_pnl" = "All Time PnL"; +"perpetual.margin_usage" = "Paggamit ng Margin"; +"perpetual.account_leverage" = "Leverage ng Account"; +"asset.all_time_high" = "Mataas sa Lahat ng Panahon"; +"asset.all_time_low" = "Mababa sa Lahat ng Panahon"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/fr.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/fr.lproj/Localizable.strings index 978621347..52d50ed24 100644 --- a/Packages/Localization/Sources/Resources/fr.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/fr.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Le nombre de pièces actuellement disponibles et échangées sur le marché."; "info.total_supply.description" = "Le nombre total de pièces existantes, y compris les pièces bloquées ou réservées."; "info.max_supply.title" = "Max Supply"; -"info.max_supply.description" = "Le nombre maximal de pièces qui existeront jamais."; \ No newline at end of file +"info.max_supply.description" = "Le nombre maximal de pièces qui existeront jamais."; +"perpetual.value" = "Valeur"; +"perpetual.unrealized_pnl" = "P&L non réalisé"; +"perpetual.volume" = "Volume"; +"perpetual.all_time_pnl" = "PnL de tous les temps"; +"perpetual.margin_usage" = "Utilisation de la marge"; +"perpetual.account_leverage" = "Effet de levier du compte"; +"asset.all_time_high" = "Record absolu"; +"asset.all_time_low" = "Plus bas historique"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/ha.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/ha.lproj/Localizable.strings index 848f92feb..1cc783c67 100644 --- a/Packages/Localization/Sources/Resources/ha.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/ha.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Adadin tsabar kuɗi da ake da su a kasuwa a yanzu haka.\""; "info.total_supply.description" = "Jimillar tsabar kuɗin da ke akwai, gami da tsabar kuɗi da aka kulle ko aka ajiye."; "info.max_supply.title" = "Mafi girman wadata"; -"info.max_supply.description" = "Matsakaicin adadin tsabar kuɗi da za su wanzu."; \ No newline at end of file +"info.max_supply.description" = "Matsakaicin adadin tsabar kuɗi da za su wanzu."; +"perpetual.value" = "darajar"; +"perpetual.unrealized_pnl" = "PnL mara Gaskiya"; +"perpetual.volume" = "Ƙarar girma"; +"perpetual.all_time_pnl" = "PnL na Duk Lokaci"; +"perpetual.margin_usage" = "Amfani da Gefen"; +"perpetual.account_leverage" = "Amfani da Asusu"; +"asset.all_time_high" = "Babban Lokaci"; +"asset.all_time_low" = "Ƙasa a Kowane Lokaci"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/he.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/he.lproj/Localizable.strings index 8b36ff6ad..071d81f8d 100644 --- a/Packages/Localization/Sources/Resources/he.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/he.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "מספר המטבעות הזמינים ונסחרים כעת בשוק."; "info.total_supply.description" = "המספר הכולל של מטבעות שקיימים, כולל מטבעות נעולים או שמורים."; "info.max_supply.title" = "אספקה ​​מקסימלית"; -"info.max_supply.description" = "המספר המקסימלי של מטבעות שאי פעם יהיה קיים."; \ No newline at end of file +"info.max_supply.description" = "המספר המקסימלי של מטבעות שאי פעם יהיה קיים."; +"perpetual.value" = "עֵרֶך"; +"perpetual.unrealized_pnl" = "רווח והפסד לא ממומשים"; +"perpetual.volume" = "כֶּרֶך"; +"perpetual.all_time_pnl" = "כל הזמנים PnL"; +"perpetual.margin_usage" = "שימוש בשוליים"; +"perpetual.account_leverage" = "מינוף חשבון"; +"asset.all_time_high" = "שיא כל הזמנים"; +"asset.all_time_low" = "שפל של כל הזמנים"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/hi.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/hi.lproj/Localizable.strings index 44638e6dc..5e471d232 100644 --- a/Packages/Localization/Sources/Resources/hi.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/hi.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "बाजार में वर्तमान में उपलब्ध और कारोबार कर रहे सिक्कों की संख्या।\""; "info.total_supply.description" = "लॉक किए गए या आरक्षित सिक्कों सहित, मौजूद सिक्कों की कुल संख्या।"; "info.max_supply.title" = "अधिकतम आपूर्ति"; -"info.max_supply.description" = "कुल मिलाकर मौजूद सिक्कों की अधिकतम संख्या।"; \ No newline at end of file +"info.max_supply.description" = "कुल मिलाकर मौजूद सिक्कों की अधिकतम संख्या।"; +"perpetual.value" = "कीमत"; +"perpetual.unrealized_pnl" = "अवास्तविक लाभ-हानि"; +"perpetual.volume" = "आयतन"; +"perpetual.all_time_pnl" = "सर्वकालिक लाभ एवं हानि"; +"perpetual.margin_usage" = "मार्जिन उपयोग"; +"perpetual.account_leverage" = "खाता उत्तोलन"; +"asset.all_time_high" = "सर्वकालिक उच्च"; +"asset.all_time_low" = "सबसे कम"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/id.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/id.lproj/Localizable.strings index 778f0d9b4..5f22a0e42 100644 --- a/Packages/Localization/Sources/Resources/id.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/id.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Jumlah koin yang saat ini tersedia dan diperdagangkan di pasar.\""; "info.total_supply.description" = "Jumlah total koin yang ada, termasuk koin yang terkunci atau dicadangkan."; "info.max_supply.title" = "Pasokan Maksimum"; -"info.max_supply.description" = "Jumlah maksimum koin yang akan pernah ada."; \ No newline at end of file +"info.max_supply.description" = "Jumlah maksimum koin yang akan pernah ada."; +"perpetual.value" = "Nilai"; +"perpetual.unrealized_pnl" = "Laba Rugi yang Belum Terealisasi"; +"perpetual.volume" = "Volume"; +"perpetual.all_time_pnl" = "Laba Rugi Sepanjang Masa"; +"perpetual.margin_usage" = "Penggunaan Margin"; +"perpetual.account_leverage" = "Pemanfaatan Akun"; +"asset.all_time_high" = "Tertinggi Sepanjang Masa"; +"asset.all_time_low" = "Terendah Sepanjang Masa"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/it.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/it.lproj/Localizable.strings index f4b46160f..a3a6bae15 100644 --- a/Packages/Localization/Sources/Resources/it.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/it.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Il numero di monete attualmente disponibili e scambiate sul mercato.\""; "info.total_supply.description" = "Il numero totale di monete esistenti, comprese quelle bloccate o riservate."; "info.max_supply.title" = "Massima fornitura"; -"info.max_supply.description" = "Il numero massimo di monete che esisteranno mai."; \ No newline at end of file +"info.max_supply.description" = "Il numero massimo di monete che esisteranno mai."; +"perpetual.value" = "Valore"; +"perpetual.unrealized_pnl" = "PnL non realizzato"; +"perpetual.volume" = "Volume"; +"perpetual.all_time_pnl" = "PnL di tutti i tempi"; +"perpetual.margin_usage" = "Utilizzo del margine"; +"perpetual.account_leverage" = "Leva finanziaria del conto"; +"asset.all_time_high" = "Massimo storico"; +"asset.all_time_low" = "Minimo storico"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/ja.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/ja.lproj/Localizable.strings index fb89f3b2a..04b8cce6a 100644 --- a/Packages/Localization/Sources/Resources/ja.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/ja.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "現在市場で入手可能で取引されているコインの数。"; "info.total_supply.description" = "ロックされたコインや予約されたコインを含む、存在するコインの合計数。"; "info.max_supply.title" = "最大供給"; -"info.max_supply.description" = "これまでに存在するコインの最大数。"; \ No newline at end of file +"info.max_supply.description" = "これまでに存在するコインの最大数。"; +"perpetual.value" = "価値"; +"perpetual.unrealized_pnl" = "未実現損益"; +"perpetual.volume" = "音量"; +"perpetual.all_time_pnl" = "これまでの損益"; +"perpetual.margin_usage" = "マージンの使用"; +"perpetual.account_leverage" = "アカウントレバレッジ"; +"asset.all_time_high" = "史上最高値"; +"asset.all_time_low" = "オールタイムロー"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/ko.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/ko.lproj/Localizable.strings index d5d307454..4b52f6fe3 100644 --- a/Packages/Localization/Sources/Resources/ko.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/ko.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "현재 시장에서 거래되고 있는 코인의 총 수량입니다."; "info.total_supply.description" = "잠겨 있거나 예약된 코인을 포함하여 존재하는 전체 코인 수입니다."; "info.max_supply.title" = "최대 공급"; -"info.max_supply.description" = "세상에 존재할 수 있는 동전의 최대 개수."; \ No newline at end of file +"info.max_supply.description" = "세상에 존재할 수 있는 동전의 최대 개수."; +"perpetual.value" = "값"; +"perpetual.unrealized_pnl" = "미실현 손익"; +"perpetual.volume" = "용량"; +"perpetual.all_time_pnl" = "역대 최고 PnL"; +"perpetual.margin_usage" = "마진 사용"; +"perpetual.account_leverage" = "계정 레버리지"; +"asset.all_time_high" = "역대 최고"; +"asset.all_time_low" = "역대 최저치"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/ms.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/ms.lproj/Localizable.strings index 98b5b8fec..770869e18 100644 --- a/Packages/Localization/Sources/Resources/ms.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/ms.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Bilangan syiling yang kini tersedia dan didagangkan di pasaran.\""; "info.total_supply.description" = "Jumlah syiling yang wujud, termasuk syiling yang dikunci atau ditempah."; "info.max_supply.title" = "Bekalan Maks"; -"info.max_supply.description" = "Bilangan maksimum syiling yang akan wujud."; \ No newline at end of file +"info.max_supply.description" = "Bilangan maksimum syiling yang akan wujud."; +"perpetual.value" = "Nilai"; +"perpetual.unrealized_pnl" = "PnL Tidak Terrealisasi"; +"perpetual.volume" = "Kelantangan"; +"perpetual.all_time_pnl" = "PnL Sepanjang Masa"; +"perpetual.margin_usage" = "Penggunaan Margin"; +"perpetual.account_leverage" = "Leveraj Akaun"; +"asset.all_time_high" = "Tertinggi Sepanjang Masa"; +"asset.all_time_low" = "Rendah Sepanjang Masa"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/nl.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/nl.lproj/Localizable.strings index aaae2a9d8..6833fda18 100644 --- a/Packages/Localization/Sources/Resources/nl.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/nl.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Het aantal munten dat momenteel beschikbaar is en verhandeld wordt op de markt.\""; "info.total_supply.description" = "Het totale aantal munten dat bestaat, inclusief vergrendelde of gereserveerde munten."; "info.max_supply.title" = "Max Supply"; -"info.max_supply.description" = "Het maximale aantal munten dat ooit zal bestaan."; \ No newline at end of file +"info.max_supply.description" = "Het maximale aantal munten dat ooit zal bestaan."; +"perpetual.value" = "Waarde"; +"perpetual.unrealized_pnl" = "Niet-gerealiseerde winst/verlies"; +"perpetual.volume" = "Volume"; +"perpetual.all_time_pnl" = "Winst en verlies over de gehele periode"; +"perpetual.margin_usage" = "Margegebruik"; +"perpetual.account_leverage" = "Accounthefboomwerking"; +"asset.all_time_high" = "Hoogste ooit"; +"asset.all_time_low" = "Laagste ooit"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/pl.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/pl.lproj/Localizable.strings index ade849391..a759f019b 100644 --- a/Packages/Localization/Sources/Resources/pl.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/pl.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Liczba monet aktualnie dostępnych i będących w obrocie na rynku."; "info.total_supply.description" = "Łączna liczba istniejących monet, łącznie z monetami zablokowanymi lub zarezerwowanymi."; "info.max_supply.title" = "Maksymalna podaż"; -"info.max_supply.description" = "Maksymalna liczba monet, która kiedykolwiek będzie istnieć."; \ No newline at end of file +"info.max_supply.description" = "Maksymalna liczba monet, która kiedykolwiek będzie istnieć."; +"perpetual.value" = "Wartość"; +"perpetual.unrealized_pnl" = "Niezrealizowany zysk/strata"; +"perpetual.volume" = "Tom"; +"perpetual.all_time_pnl" = "Całkowity zysk i strata"; +"perpetual.margin_usage" = "Wykorzystanie marży"; +"perpetual.account_leverage" = "Dźwignia konta"; +"asset.all_time_high" = "Najwyższy poziom wszech czasów"; +"asset.all_time_low" = "Najniższy poziom wszech czasów"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/pt-BR.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/pt-BR.lproj/Localizable.strings index 4b12b63be..538aad799 100644 --- a/Packages/Localization/Sources/Resources/pt-BR.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/pt-BR.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "O número de moedas atualmente disponíveis e negociadas no mercado.\""; "info.total_supply.description" = "O número total de moedas existentes, incluindo moedas bloqueadas ou reservadas."; "info.max_supply.title" = "Suprimento máximo"; -"info.max_supply.description" = "O número máximo de moedas que jamais existirão."; \ No newline at end of file +"info.max_supply.description" = "O número máximo de moedas que jamais existirão."; +"perpetual.value" = "Valor"; +"perpetual.unrealized_pnl" = "Lucro e Perda não realizados"; +"perpetual.volume" = "Volume"; +"perpetual.all_time_pnl" = "Todos os tempos PnL"; +"perpetual.margin_usage" = "Utilização da margem"; +"perpetual.account_leverage" = "Alavancagem da conta"; +"asset.all_time_high" = "Maior de todos os tempos"; +"asset.all_time_low" = "Mínima histórica"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/ro.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/ro.lproj/Localizable.strings index 7e5efdd83..4d42d7799 100644 --- a/Packages/Localization/Sources/Resources/ro.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/ro.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Numărul de monede disponibile în prezent și tranzacționate pe piață."; "info.total_supply.description" = "Numărul total de monede existente, inclusiv monedele blocate sau rezervate."; "info.max_supply.title" = "Aprovizionare maximă"; -"info.max_supply.description" = "Numărul maxim de monede care vor exista vreodată."; \ No newline at end of file +"info.max_supply.description" = "Numărul maxim de monede care vor exista vreodată."; +"perpetual.value" = "Valoare"; +"perpetual.unrealized_pnl" = "PnL nerealizat"; +"perpetual.volume" = "Volum"; +"perpetual.all_time_pnl" = "PnL din toate timpurile"; +"perpetual.margin_usage" = "Utilizarea marjei"; +"perpetual.account_leverage" = "Levierul contului"; +"asset.all_time_high" = "Maxim istoric"; +"asset.all_time_low" = "Minim istoric"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/ru.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/ru.lproj/Localizable.strings index 99f3c73aa..fbcd0d32e 100644 --- a/Packages/Localization/Sources/Resources/ru.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/ru.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Количество монет, доступных и торгующихся в настоящее время на рынке."; "info.total_supply.description" = "Общее количество существующих монет, включая заблокированные или зарезервированные монеты."; "info.max_supply.title" = "Максимальное предложение"; -"info.max_supply.description" = "Максимальное количество монет, которое когда-либо будет существовать."; \ No newline at end of file +"info.max_supply.description" = "Максимальное количество монет, которое когда-либо будет существовать."; +"perpetual.value" = "Ценить"; +"perpetual.unrealized_pnl" = "Нереализованный PnL"; +"perpetual.volume" = "Объем"; +"perpetual.all_time_pnl" = "PnL за всё время"; +"perpetual.margin_usage" = "Использование маржи"; +"perpetual.account_leverage" = "Кредитное плечо счета"; +"asset.all_time_high" = "Рекордный максимум за все время"; +"asset.all_time_low" = "Рекордный минимум за все время"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/sw.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/sw.lproj/Localizable.strings index c5485574b..744cc7067 100644 --- a/Packages/Localization/Sources/Resources/sw.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/sw.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Idadi ya sarafu zinazopatikana sasa na zinazouzwa sokoni.\""; "info.total_supply.description" = "Jumla ya sarafu zilizopo, ikijumuisha sarafu zilizofungwa au zilizohifadhiwa."; "info.max_supply.title" = "Ugavi wa Juu Zaidi"; -"info.max_supply.description" = "Idadi ya juu zaidi ya sarafu zitakazokuwepo."; \ No newline at end of file +"info.max_supply.description" = "Idadi ya juu zaidi ya sarafu zitakazokuwepo."; +"perpetual.value" = "Thamani"; +"perpetual.unrealized_pnl" = "PnL Isiyotekelezwa"; +"perpetual.volume" = "Kiasi"; +"perpetual.all_time_pnl" = "PnL ya Wakati Wote"; +"perpetual.margin_usage" = "Matumizi ya Pambizo"; +"perpetual.account_leverage" = "Ufaidi wa Akaunti"; +"asset.all_time_high" = "Wakati Wote Juu"; +"asset.all_time_low" = "Chini Wakati Wote"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/th.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/th.lproj/Localizable.strings index 5e65cec93..68fb2acfc 100644 --- a/Packages/Localization/Sources/Resources/th.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/th.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "จำนวนเหรียญที่มีอยู่และซื้อขายกันในตลาด ณ ปัจจุบัน\""; "info.total_supply.description" = "จำนวนเหรียญทั้งหมดที่มีอยู่ รวมทั้งเหรียญที่ถูกล็อกหรือสงวนไว้"; "info.max_supply.title" = "แม็กซ์ซัพพลาย"; -"info.max_supply.description" = "จำนวนเหรียญสูงสุดที่จะมีอยู่ตลอดไป"; \ No newline at end of file +"info.max_supply.description" = "จำนวนเหรียญสูงสุดที่จะมีอยู่ตลอดไป"; +"perpetual.value" = "ค่า"; +"perpetual.unrealized_pnl" = "กำไรขาดทุนที่ยังไม่เกิดขึ้นจริง"; +"perpetual.volume" = "ปริมาณ"; +"perpetual.all_time_pnl" = "กำไรขาดทุนตลอดกาล"; +"perpetual.margin_usage" = "การใช้มาร์จิน"; +"perpetual.account_leverage" = "การใช้ประโยชน์จากบัญชี"; +"asset.all_time_high" = "สูงสุดตลอดกาล"; +"asset.all_time_low" = "ออลไทม์โลว์"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/tr.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/tr.lproj/Localizable.strings index dac2ff90a..e83b95b58 100644 --- a/Packages/Localization/Sources/Resources/tr.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/tr.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Piyasada şu anda mevcut olan ve işlem gören kripto para birimi sayısı.\""; "info.total_supply.description" = "Kilitli veya rezerve edilmiş paralar da dahil olmak üzere mevcut toplam para sayısı."; "info.max_supply.title" = "Maksimum Tedarik"; -"info.max_supply.description" = "Var olabilecek maksimum madeni para sayısı."; \ No newline at end of file +"info.max_supply.description" = "Var olabilecek maksimum madeni para sayısı."; +"perpetual.value" = "Değer"; +"perpetual.unrealized_pnl" = "Gerçekleşmemiş Kar ve Zarar"; +"perpetual.volume" = "Hacim"; +"perpetual.all_time_pnl" = "Tüm Zamanların Kar ve Zarar Tablosu"; +"perpetual.margin_usage" = "Kenar Boşluğu Kullanımı"; +"perpetual.account_leverage" = "Hesap Kaldıraç"; +"asset.all_time_high" = "Tüm Zamanların En Yüksek Seviyesi"; +"asset.all_time_low" = "Tüm Zamanların En Düşük Seviyesi"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/uk.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/uk.lproj/Localizable.strings index 686165dff..09eb45a4b 100644 --- a/Packages/Localization/Sources/Resources/uk.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/uk.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Кількість монет, що наразі доступні та торгуються на ринку."; "info.total_supply.description" = "Загальна кількість існуючих монет, включаючи заблоковані або зарезервовані монети."; "info.max_supply.title" = "Максимальна пропозиція"; -"info.max_supply.description" = "Максимальна кількість монет, яка коли-небудь існуватиме."; \ No newline at end of file +"info.max_supply.description" = "Максимальна кількість монет, яка коли-небудь існуватиме."; +"perpetual.value" = "Значення"; +"perpetual.unrealized_pnl" = "Нереалізований PnL"; +"perpetual.volume" = "Обсяг"; +"perpetual.all_time_pnl" = "PnL за весь час"; +"perpetual.margin_usage" = "Використання маржі"; +"perpetual.account_leverage" = "Кредитне плече рахунку"; +"asset.all_time_high" = "Рекордний максимум за весь час"; +"asset.all_time_low" = "Найнижчий показник за весь час"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/ur.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/ur.lproj/Localizable.strings index 29cc331c8..c22867e05 100644 --- a/Packages/Localization/Sources/Resources/ur.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/ur.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "سککوں کی تعداد جو فی الحال دستیاب ہے اور مارکیٹ میں تجارت کر رہے ہیں۔\""; "info.total_supply.description" = "موجود سکوں کی کل تعداد، بشمول مقفل یا محفوظ سکے"; "info.max_supply.title" = "زیادہ سے زیادہ سپلائی"; -"info.max_supply.description" = "سکوں کی زیادہ سے زیادہ تعداد جو کبھی موجود ہوں گے۔"; \ No newline at end of file +"info.max_supply.description" = "سکوں کی زیادہ سے زیادہ تعداد جو کبھی موجود ہوں گے۔"; +"perpetual.value" = "قدر"; +"perpetual.unrealized_pnl" = "غیر حقیقی PnL"; +"perpetual.volume" = "حجم"; +"perpetual.all_time_pnl" = "ہر وقت PnL"; +"perpetual.margin_usage" = "مارجن کا استعمال"; +"perpetual.account_leverage" = "اکاؤنٹ لیوریج"; +"asset.all_time_high" = "آل ٹائم ہائی"; +"asset.all_time_low" = "آل ٹائم لو"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/vi.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/vi.lproj/Localizable.strings index d79c5c9c3..70b7d930e 100644 --- a/Packages/Localization/Sources/Resources/vi.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/vi.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "Số lượng tiền điện tử hiện có và đang được giao dịch trên thị trường."; "info.total_supply.description" = "Tổng số lượng tiền xu hiện có, bao gồm cả tiền xu bị khóa hoặc được giữ lại."; "info.max_supply.title" = "Cung cấp tối đa"; -"info.max_supply.description" = "Số lượng tối đa các đồng xu sẽ từng tồn tại."; \ No newline at end of file +"info.max_supply.description" = "Số lượng tối đa các đồng xu sẽ từng tồn tại."; +"perpetual.value" = "Giá trị"; +"perpetual.unrealized_pnl" = "Lợi nhuận và lỗ chưa thực hiện"; +"perpetual.volume" = "Âm lượng"; +"perpetual.all_time_pnl" = "Lợi nhuận và thua lỗ mọi thời đại"; +"perpetual.margin_usage" = "Sử dụng lề"; +"perpetual.account_leverage" = "Tận dụng tài khoản"; +"asset.all_time_high" = "Mức cao nhất mọi thời đại"; +"asset.all_time_low" = "Mức thấp nhất mọi thời đại"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/zh-Hans.lproj/Localizable.strings index 1bb6100be..6adb74097 100644 --- a/Packages/Localization/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "目前市场上可供交易的加密货币数量。"; "info.total_supply.description" = "现存硬币总数,包括已锁定或已预留的硬币。"; "info.max_supply.title" = "最大供应"; -"info.max_supply.description" = "硬币的最大存世量。"; \ No newline at end of file +"info.max_supply.description" = "硬币的最大存世量。"; +"perpetual.value" = "价值"; +"perpetual.unrealized_pnl" = "未实现损益"; +"perpetual.volume" = "体积"; +"perpetual.all_time_pnl" = "总损益"; +"perpetual.margin_usage" = "边际利用率"; +"perpetual.account_leverage" = "账户杠杆"; +"asset.all_time_high" = "历史最高点"; +"asset.all_time_low" = "最低"; \ No newline at end of file diff --git a/Packages/Localization/Sources/Resources/zh-Hant.lproj/Localizable.strings b/Packages/Localization/Sources/Resources/zh-Hant.lproj/Localizable.strings index eb1afc562..d12b25f94 100644 --- a/Packages/Localization/Sources/Resources/zh-Hant.lproj/Localizable.strings +++ b/Packages/Localization/Sources/Resources/zh-Hant.lproj/Localizable.strings @@ -571,4 +571,12 @@ "info.circulating_supply.description" = "目前市場上可供交易的加密貨幣數量。"; "info.total_supply.description" = "現存硬幣總數,包括已鎖定或已預留的硬幣。"; "info.max_supply.title" = "最大供應"; -"info.max_supply.description" = "硬幣的最大存世量。"; \ No newline at end of file +"info.max_supply.description" = "硬幣的最大存世量。"; +"perpetual.value" = "價值"; +"perpetual.unrealized_pnl" = "未實現損益"; +"perpetual.volume" = "體積"; +"perpetual.all_time_pnl" = "總損益"; +"perpetual.margin_usage" = "邊際利用率"; +"perpetual.account_leverage" = "帳戶槓桿"; +"asset.all_time_high" = "歷史最高點"; +"asset.all_time_low" = "最低"; \ No newline at end of file diff --git a/Packages/Primitives/Sources/Chart.swift b/Packages/Primitives/Sources/Chart.swift index 3bad41298..b64bdf809 100644 --- a/Packages/Primitives/Sources/Chart.swift +++ b/Packages/Primitives/Sources/Chart.swift @@ -22,7 +22,7 @@ public struct ChartCandleStick: Codable, Equatable, Sendable { } } -public struct ChartDateValue: Codable, Equatable, Sendable { +public struct ChartDateValue: Codable, Equatable, Hashable, Sendable { public let date: Date public let value: Double diff --git a/Features/MarketInsight/Sources/Types/ChartValues.swift b/Packages/Primitives/Sources/ChartValues.swift similarity index 61% rename from Features/MarketInsight/Sources/Types/ChartValues.swift rename to Packages/Primitives/Sources/ChartValues.swift index a83fc2276..e168e82a7 100644 --- a/Features/MarketInsight/Sources/Types/ChartValues.swift +++ b/Packages/Primitives/Sources/ChartValues.swift @@ -1,7 +1,6 @@ // Copyright (c). Gem Wallet. All rights reserved. import Foundation -import Primitives public struct ChartValues: Sendable { public let charts: [ChartDateValue] @@ -12,7 +11,7 @@ public struct ChartValues: Sendable { public let lowerBoundDate: Date public let upperBoundDate: Date - public init( + init( charts: [ChartDateValue], lowerBoundValue: Double, upperBoundValue: Double, @@ -26,19 +25,7 @@ public struct ChartValues: Sendable { self.upperBoundDate = upperBoundDate } - public var yScale: [Double] { - return [lowerBoundValue, upperBoundValue] - } - - public var xScale: [Date] { - return [lowerBoundDate, upperBoundDate] - } - - public var firstValue: Double { - return charts.first?.value ?? 0 - } - - public static func from(charts: [ChartDateValue]) throws -> ChartValues { + public static func from(charts: [ChartDateValue]) throws -> ChartValues { let values = charts.map { $0.value } let dates = charts.map { $0.date } guard let lowerBoundValue = values.min(), @@ -47,7 +34,7 @@ public struct ChartValues: Sendable { let upperBoundDateIndex = values.firstIndex(of: upperBoundValue) else { throw AnyError("not able to calculate lower and upper bound") } - + return ChartValues( charts: charts, lowerBoundValue: lowerBoundValue, @@ -57,7 +44,24 @@ public struct ChartValues: Sendable { ) } - public func priceChange(base: Double, price: Double) -> (price: Double, priceChange: Double) { - return (price, (price - base) / base * 100) + public var yScale: [Double] { + let range = upperBoundValue - lowerBoundValue + let padding = range * 0.05 + return [lowerBoundValue - padding, upperBoundValue + padding] + } + public var xScale: [Date] { + guard let first = charts.first?.date, let last = charts.last?.date else { return [] } + let padding = last.timeIntervalSince(first) * 0.02 + return [first, last.addingTimeInterval(padding)] + } + public var hasVariation: Bool { lowerBoundValue != upperBoundValue } + + public var firstValue: Double { charts.first?.value ?? 0 } + public var lastValue: Double { charts.last?.value ?? 0 } + public var firstNonZeroValue: Double? { charts.first(where: { $0.value != 0 })?.value } + public var baseValue: Double { firstNonZeroValue ?? firstValue } + + public func percentageChange(from base: Double, to value: Double) -> Double { + base == 0 ? 0 : (value - base) / base * 100 } } diff --git a/Packages/Primitives/Sources/Extensions/PerpetualPortfolio+Primitives.swift b/Packages/Primitives/Sources/Extensions/PerpetualPortfolio+Primitives.swift new file mode 100644 index 000000000..c3750e9a7 --- /dev/null +++ b/Packages/Primitives/Sources/Extensions/PerpetualPortfolio+Primitives.swift @@ -0,0 +1,18 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +extension PerpetualPortfolio { + public var availablePeriods: [ChartPeriod] { + [(day, ChartPeriod.day), (week, .week), (month, .month), (allTime, .all)].compactMap { $0.0 != nil ? $0.1 : nil } + } + + public func timeframeData(for period: ChartPeriod) -> PerpetualPortfolioTimeframeData? { + switch period { + case .hour, .day: day + case .week: week + case .month: month + case .year, .all: allTime + } + } +} diff --git a/Packages/Primitives/Sources/Extensions/PerpetualPortfolioChartType+Primitives.swift b/Packages/Primitives/Sources/Extensions/PerpetualPortfolioChartType+Primitives.swift new file mode 100644 index 000000000..377a8574d --- /dev/null +++ b/Packages/Primitives/Sources/Extensions/PerpetualPortfolioChartType+Primitives.swift @@ -0,0 +1,7 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +extension PerpetualPortfolioChartType { + public var id: String { rawValue } +} diff --git a/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift b/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift index aacf1d80f..90be63fc2 100644 --- a/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift +++ b/Packages/Primitives/Sources/Extensions/Wallet+Primitives.swift @@ -46,6 +46,10 @@ public extension Wallet { } return account } + + var perpetualAddress: String? { + accounts.first { $0.chain == .arbitrum || $0.chain == .hyperCore || $0.chain == .hyperliquid }?.address + } } // factory diff --git a/Packages/Primitives/Sources/Portfolio.swift b/Packages/Primitives/Sources/Portfolio.swift new file mode 100644 index 000000000..e5f1b5878 --- /dev/null +++ b/Packages/Primitives/Sources/Portfolio.swift @@ -0,0 +1,52 @@ +/* + Generated by typeshare 1.13.3 + */ + +import Foundation + +public struct PerpetualAccountSummary: Codable, Equatable, Hashable, Sendable { + public let accountValue: Double + public let accountLeverage: Double + public let marginUsage: Double + public let unrealizedPnl: Double + + public init(accountValue: Double, accountLeverage: Double, marginUsage: Double, unrealizedPnl: Double) { + self.accountValue = accountValue + self.accountLeverage = accountLeverage + self.marginUsage = marginUsage + self.unrealizedPnl = unrealizedPnl + } +} + +public struct PerpetualPortfolioTimeframeData: Codable, Equatable, Hashable, Sendable { + public let accountValueHistory: [ChartDateValue] + public let pnlHistory: [ChartDateValue] + public let volume: Double + + public init(accountValueHistory: [ChartDateValue], pnlHistory: [ChartDateValue], volume: Double) { + self.accountValueHistory = accountValueHistory + self.pnlHistory = pnlHistory + self.volume = volume + } +} + +public struct PerpetualPortfolio: Codable, Equatable, Hashable, Sendable { + public let day: PerpetualPortfolioTimeframeData? + public let week: PerpetualPortfolioTimeframeData? + public let month: PerpetualPortfolioTimeframeData? + public let allTime: PerpetualPortfolioTimeframeData? + public let accountSummary: PerpetualAccountSummary? + + public init(day: PerpetualPortfolioTimeframeData?, week: PerpetualPortfolioTimeframeData?, month: PerpetualPortfolioTimeframeData?, allTime: PerpetualPortfolioTimeframeData?, accountSummary: PerpetualAccountSummary?) { + self.day = day + self.week = week + self.month = month + self.allTime = allTime + self.accountSummary = accountSummary + } +} + +public enum PerpetualPortfolioChartType: String, Codable, CaseIterable, Equatable, Identifiable, Sendable { + case value + case pnl +} diff --git a/Packages/Primitives/TestKit/ChartDateValue+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/ChartDateValue+PrimitivesTestKit.swift new file mode 100644 index 000000000..c21b141f2 --- /dev/null +++ b/Packages/Primitives/TestKit/ChartDateValue+PrimitivesTestKit.swift @@ -0,0 +1,26 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public extension ChartDateValue { + static func mock( + date: Date = Date(), + value: Double = 100.0 + ) -> ChartDateValue { + ChartDateValue(date: date, value: value) + } + + static func mockHistory( + startDate: Date = Date().addingTimeInterval(-86400), + values: [Double] = [100, 105, 102, 108, 110] + ) -> [ChartDateValue] { + let interval = 86400.0 / Double(values.count) + return values.enumerated().map { index, value in + ChartDateValue( + date: startDate.addingTimeInterval(Double(index) * interval), + value: value + ) + } + } +} diff --git a/Packages/Primitives/TestKit/ChartValues+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/ChartValues+PrimitivesTestKit.swift new file mode 100644 index 000000000..073299068 --- /dev/null +++ b/Packages/Primitives/TestKit/ChartValues+PrimitivesTestKit.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public extension ChartValues { + static func mock( + values: [Double] = [100, 150, 80, 120] + ) -> ChartValues { + let charts = values.enumerated().map { + ChartDateValue(date: Date(timeIntervalSince1970: Double($0.offset) * 3600), value: $0.element) + } + return try! ChartValues.from(charts: charts) + } +} diff --git a/Packages/Primitives/TestKit/PerpetualPortfolio+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/PerpetualPortfolio+PrimitivesTestKit.swift new file mode 100644 index 000000000..9602a5f93 --- /dev/null +++ b/Packages/Primitives/TestKit/PerpetualPortfolio+PrimitivesTestKit.swift @@ -0,0 +1,46 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public extension PerpetualPortfolioTimeframeData { + static func mock( + accountValueHistory: [ChartDateValue] = ChartDateValue.mockHistory(values: [100, 105, 102, 108, 110]), + pnlHistory: [ChartDateValue] = ChartDateValue.mockHistory(values: [0, 5, 2, 8, 10]), + volume: Double = 50000 + ) -> PerpetualPortfolioTimeframeData { + PerpetualPortfolioTimeframeData( + accountValueHistory: accountValueHistory, + pnlHistory: pnlHistory, + volume: volume + ) + } +} + +public extension PerpetualPortfolio { + static func mock( + day: PerpetualPortfolioTimeframeData? = .mock(), + week: PerpetualPortfolioTimeframeData? = .mock(), + month: PerpetualPortfolioTimeframeData? = .mock(), + allTime: PerpetualPortfolioTimeframeData? = .mock(), + accountSummary: PerpetualAccountSummary? = nil + ) -> PerpetualPortfolio { + PerpetualPortfolio( + day: day, + week: week, + month: month, + allTime: allTime, + accountSummary: accountSummary + ) + } + + static func mockEmpty() -> PerpetualPortfolio { + PerpetualPortfolio( + day: nil, + week: nil, + month: nil, + allTime: nil, + accountSummary: nil + ) + } +} diff --git a/Packages/Primitives/Tests/PrimitivesTests/ChartValuesTests.swift b/Packages/Primitives/Tests/PrimitivesTests/ChartValuesTests.swift new file mode 100644 index 000000000..8fbd6c08c --- /dev/null +++ b/Packages/Primitives/Tests/PrimitivesTests/ChartValuesTests.swift @@ -0,0 +1,27 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Testing +import Foundation +import PrimitivesTestKit +@testable import Primitives + +struct ChartValuesTests { + + @Test + func chartValues() throws { + let values = ChartValues.mock(values: [100, 150, 80, 120]) + + #expect(values.lowerBoundValue == 80) + #expect(values.upperBoundValue == 150) + #expect(values.yScale == [76.5, 153.5]) + #expect(values.hasVariation == true) + #expect(values.firstValue == 100) + #expect(values.lastValue == 120) + #expect(values.baseValue == 100) + #expect(values.percentageChange(from: 100, to: 150) == 50) + #expect(values.percentageChange(from: 0, to: 100) == 0) + + #expect(throws: Error.self) { _ = try ChartValues.from(charts: []) } + #expect(ChartValues.mock(values: [100, 100]).hasVariation == false) + } +} diff --git a/Packages/PrimitivesComponents/Sources/Components/TransactionHeaderView.swift b/Packages/PrimitivesComponents/Sources/Components/TransactionHeaderView.swift index e03fe3dd8..2e8e63c64 100644 --- a/Packages/PrimitivesComponents/Sources/Components/TransactionHeaderView.swift +++ b/Packages/PrimitivesComponents/Sources/Components/TransactionHeaderView.swift @@ -24,7 +24,8 @@ public struct TransactionHeaderView: View { case .amount(let display): WalletHeaderView( model: TransactionAmountHeaderViewModel(display: display), - isHideBalanceEnalbed: .constant(false), + isPrivacyEnabled: .constant(false), + balanceActionType: .none, onHeaderAction: nil, onInfoAction: nil ) diff --git a/Packages/PrimitivesComponents/Sources/Components/WalletHeaderView.swift b/Packages/PrimitivesComponents/Sources/Components/WalletHeaderView.swift index 2cb91192e..87ac93d09 100644 --- a/Packages/PrimitivesComponents/Sources/Components/WalletHeaderView.swift +++ b/Packages/PrimitivesComponents/Sources/Components/WalletHeaderView.swift @@ -9,19 +9,22 @@ import Primitives public struct WalletHeaderView: View { private let model: any HeaderViewModel - @Binding var isHideBalanceEnalbed: Bool + @Binding var isPrivacyEnabled: Bool + private let balanceActionType: BalanceActionType private let onHeaderAction: HeaderButtonAction? private let onInfoAction: VoidAction public init( model: any HeaderViewModel, - isHideBalanceEnalbed: Binding, + isPrivacyEnabled: Binding, + balanceActionType: BalanceActionType, onHeaderAction: HeaderButtonAction?, onInfoAction: VoidAction ) { self.model = model - _isHideBalanceEnalbed = isHideBalanceEnalbed + _isPrivacyEnabled = isPrivacyEnabled + self.balanceActionType = balanceActionType self.onHeaderAction = onHeaderAction self.onInfoAction = onInfoAction } @@ -35,16 +38,7 @@ public struct WalletHeaderView: View { ) .padding(.bottom, .space12) } - ZStack { - if model.allowHiddenBalance { - PrivacyToggleView( - model.title, - isEnabled: $isHideBalanceEnalbed - ) - } else { - Text(model.title) - } - } + balanceView .numericTransition(for: model.title) .minimumScaleFactor(0.5) .font(.system(size: 42)) @@ -56,7 +50,7 @@ public struct WalletHeaderView: View { if let subtitle = model.subtitle { PrivacyText( subtitle, - isEnabled: isEnabled + isEnabled: $isPrivacyEnabled ) .font(.system(size: 16)) .fontWeight(.medium) @@ -93,8 +87,18 @@ public struct WalletHeaderView: View { } } - private var isEnabled: Binding { - return model.allowHiddenBalance ? $isHideBalanceEnalbed : .constant(false) + @ViewBuilder + private var balanceView: some View { + switch balanceActionType { + case .privacyToggle: + PrivacyToggleView(model.title, isEnabled: $isPrivacyEnabled) + case .action(let action): + Button(action: action) { + PrivacyText(model.title, isEnabled: $isPrivacyEnabled) + } + case .none: + Text(model.title) + } } } @@ -110,7 +114,8 @@ public struct WalletHeaderView: View { WalletHeaderView( model: model, - isHideBalanceEnalbed: .constant(false), + isPrivacyEnabled: .constant(false), + balanceActionType: .privacyToggle, onHeaderAction: .none, onInfoAction: .none ) diff --git a/Packages/PrimitivesComponents/Sources/Protocols/HeaderViewModel.swift b/Packages/PrimitivesComponents/Sources/Protocols/HeaderViewModel.swift index 522e9f16a..ad9985c8c 100644 --- a/Packages/PrimitivesComponents/Sources/Protocols/HeaderViewModel.swift +++ b/Packages/PrimitivesComponents/Sources/Protocols/HeaderViewModel.swift @@ -9,5 +9,4 @@ public protocol HeaderViewModel { var title: String { get } var subtitle: String? { get } var buttons: [HeaderButton] { get } - var allowHiddenBalance: Bool { get } } diff --git a/Packages/PrimitivesComponents/Sources/Types/ChartPriceModel.swift b/Packages/PrimitivesComponents/Sources/Types/ChartPriceViewModel.swift similarity index 56% rename from Packages/PrimitivesComponents/Sources/Types/ChartPriceModel.swift rename to Packages/PrimitivesComponents/Sources/Types/ChartPriceViewModel.swift index c3a545ca5..02d5f4632 100644 --- a/Packages/PrimitivesComponents/Sources/Types/ChartPriceModel.swift +++ b/Packages/PrimitivesComponents/Sources/Types/ChartPriceViewModel.swift @@ -7,33 +7,37 @@ import Style import Formatters import Components -public struct ChartPriceModel { +public struct ChartPriceViewModel { public let period: ChartPeriod public let date: Date? public let price: Double - public let priceChange: Double public let priceChangePercentage: Double - public let currency: Currency - + public let type: ChartValueType + + private let formatter: CurrencyFormatter + public init( period: ChartPeriod, date: Date?, price: Double, - priceChange: Double, priceChangePercentage: Double, - currency: Currency = Currency.default + formatter: CurrencyFormatter, + type: ChartValueType = .price ) { self.period = period self.date = date self.price = price - self.priceChange = priceChange self.priceChangePercentage = priceChangePercentage - self.currency = currency + self.type = type + self.formatter = formatter + } + + private var valueChange: PriceChangeViewModel? { + type == .priceChange ? PriceChangeViewModel(value: price, currencyFormatter: formatter) : nil } - + public var dateText: String? { - guard let date = date else { return nil } - + guard let date else { return nil } switch period { case .hour: return date.formatted(.dateTime.hour().minute()) @@ -45,27 +49,25 @@ public struct ChartPriceModel { return date.formatted(.dateTime.year().month(.abbreviated).day()) } } - + public var priceText: String { - CurrencyFormatter(currencyCode: currency.rawValue).string(price) + valueChange?.text ?? formatter.string(price) } - + + public var priceColor: Color { + valueChange?.color ?? Colors.black + } + public var priceChangeText: String? { - CurrencyFormatter.percent.string(priceChangePercentage) + guard type == .price, price != 0 else { return nil } + return CurrencyFormatter.percent.string(priceChangePercentage) } - + public var priceChangeTextColor: Color { - PriceChangeColor.color(for: priceChange) + PriceChangeColor.color(for: priceChangePercentage) } -} -extension ChartPriceView { - public static func from(model: ChartPriceModel) -> some View { - ChartPriceView( - date: model.dateText, - price: model.priceText, - priceChange: model.priceChangeText, - priceChangeTextColor: model.priceChangeTextColor - ) + public static func priceChangePercentage(close: Double, base: Double) -> Double { + base == 0 ? 0 : ((close - base) / base) * 100 } } diff --git a/Packages/PrimitivesComponents/Sources/Types/ChartValueType.swift b/Packages/PrimitivesComponents/Sources/Types/ChartValueType.swift new file mode 100644 index 000000000..f17e0a926 --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/Types/ChartValueType.swift @@ -0,0 +1,8 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation + +public enum ChartValueType: Sendable { + case price + case priceChange +} diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/ChartValuesViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/ChartValuesViewModel.swift new file mode 100644 index 000000000..1fb2dd1a2 --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/ViewModels/ChartValuesViewModel.swift @@ -0,0 +1,52 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import SwiftUI +import Primitives +import Charts +import Formatters +import Style + +public struct ChartValuesViewModel: Sendable { + public let period: ChartPeriod + public let price: Price? + public let values: ChartValues + public let lineColor: Color + public let formatter: CurrencyFormatter + public let type: ChartValueType + + public static let defaultPeriod = ChartPeriod.day + + public init( + period: ChartPeriod, + price: Price?, + values: ChartValues, + lineColor: Color = Colors.blue, + formatter: CurrencyFormatter, + type: ChartValueType = .price + ) { + self.period = period + self.price = price + self.values = values + self.lineColor = lineColor + self.formatter = formatter + self.type = type + } + + var charts: [ChartDateValue] { values.charts } + var lowerBoundValueText: String { formatter.string(values.lowerBoundValue) } + var upperBoundValueText: String { formatter.string(values.upperBoundValue) } + + var chartPriceViewModel: ChartPriceViewModel? { + guard let price else { return nil } + let priceChangePercentage = period == Self.defaultPeriod + ? price.priceChangePercentage24h + : values.percentageChange(from: values.baseValue, to: price.price) + return ChartPriceViewModel(period: period, date: nil, price: price.price, priceChangePercentage: priceChangePercentage, formatter: formatter, type: type) + } + + func priceViewModel(for element: ChartDateValue) -> ChartPriceViewModel { + let priceChangePercentage = values.percentageChange(from: values.baseValue, to: element.value) + return ChartPriceViewModel(period: period, date: element.date, price: element.value, priceChangePercentage: priceChangePercentage, formatter: formatter, type: type) + } +} diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/PnLViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/PnLViewModel.swift index 7476cf90a..ebd311cbf 100644 --- a/Packages/PrimitivesComponents/Sources/ViewModels/PnLViewModel.swift +++ b/Packages/PrimitivesComponents/Sources/ViewModels/PnLViewModel.swift @@ -27,16 +27,13 @@ public struct PnLViewModel { public var title: String { Localized.Perpetual.pnl } + private var valueChange: PriceChangeViewModel { + PriceChangeViewModel(value: pnl, currencyFormatter: currencyFormatter) + } + public var text: String? { - guard let pnl else { return nil } - let pnlAmount = currencyFormatter.string(abs(pnl)) - let percentText = percentFormatter.string(percent) - - if pnl >= 0 { - return "+\(pnlAmount) (\(percentText))" - } else { - return "-\(pnlAmount) (\(percentText))" - } + guard let amountText = valueChange.text else { return nil } + return "\(amountText) (\(percentFormatter.string(percent)))" } public var percent: Double { @@ -44,12 +41,7 @@ public struct PnLViewModel { return (pnl / marginAmount) * 100 } - public var color: Color { - guard let pnl else { return .secondary } - return PriceChangeColor.color(for: pnl) - } + public var color: Color { valueChange.color } - public var textStyle: TextStyle { - TextStyle(font: .callout, color: color) - } + public var textStyle: TextStyle { valueChange.textStyle } } diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/PriceChangeViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/PriceChangeViewModel.swift new file mode 100644 index 000000000..b657aca6f --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/ViewModels/PriceChangeViewModel.swift @@ -0,0 +1,32 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import SwiftUI +import Formatters +import Style +import Components + +public struct PriceChangeViewModel { + private let value: Double? + private let currencyFormatter: CurrencyFormatter + + public init(value: Double?, currencyFormatter: CurrencyFormatter) { + self.value = value + self.currencyFormatter = currencyFormatter + } + + public var text: String? { + guard let value else { return nil } + let formatted = currencyFormatter.string(abs(value)) + return value >= 0 ? "+\(formatted)" : "-\(formatted)" + } + + public var color: Color { + guard let value else { return .secondary } + return PriceChangeColor.color(for: value) + } + + public var textStyle: TextStyle { + TextStyle(font: .callout, color: color) + } +} diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/TransactionAmountHeaderViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/TransactionAmountHeaderViewModel.swift index 5e3b37b37..61ffbd6ff 100644 --- a/Packages/PrimitivesComponents/Sources/ViewModels/TransactionAmountHeaderViewModel.swift +++ b/Packages/PrimitivesComponents/Sources/ViewModels/TransactionAmountHeaderViewModel.swift @@ -10,7 +10,6 @@ struct TransactionAmountHeaderViewModel: HeaderViewModel { let isWatchWallet: Bool = false let buttons: [HeaderButton] = [] - let allowHiddenBalance: Bool = false var assetImage: AssetImage? { display.assetImage diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/WalletHeaderViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/WalletHeaderViewModel.swift index 5d0b6eef8..fde37151c 100644 --- a/Packages/PrimitivesComponents/Sources/ViewModels/WalletHeaderViewModel.swift +++ b/Packages/PrimitivesComponents/Sources/ViewModels/WalletHeaderViewModel.swift @@ -36,7 +36,6 @@ public struct WalletHeaderViewModel { // MARK: - HeaderViewModel extension WalletHeaderViewModel: HeaderViewModel { - public var allowHiddenBalance: Bool { true } public var isWatchWallet: Bool { walletType == .view } public var title: String { totalValueText } public var assetImage: AssetImage? { .none } diff --git a/Packages/PrimitivesComponents/Sources/Views/ChartPriceView.swift b/Packages/PrimitivesComponents/Sources/Views/ChartPriceView.swift index ba48582a5..42f849c20 100644 --- a/Packages/PrimitivesComponents/Sources/Views/ChartPriceView.swift +++ b/Packages/PrimitivesComponents/Sources/Views/ChartPriceView.swift @@ -4,39 +4,28 @@ import SwiftUI import Style public struct ChartPriceView: View { - let date: String? - let price: String - let priceChange: String? - let priceChangeTextColor: Color + let model: ChartPriceViewModel - public init( - date: String?, - price: String, - priceChange: String?, - priceChangeTextColor: Color - ) { - self.date = date - self.price = price - self.priceChange = priceChange - self.priceChangeTextColor = priceChangeTextColor + public init(model: ChartPriceViewModel) { + self.model = model } public var body: some View { VStack(spacing: Spacing.tiny) { HStack(alignment: .center, spacing: Spacing.tiny) { - Text(price) + Text(model.priceText) .font(.title2) - .foregroundColor(Colors.black) + .foregroundColor(model.priceColor) - if let priceChange { + if let priceChange = model.priceChangeText { Text(priceChange) .font(.callout) - .foregroundColor(priceChangeTextColor) + .foregroundColor(model.priceChangeTextColor) } } HStack { - if let date { + if let date = model.dateText { Text(date) .font(.footnote) .foregroundColor(Colors.gray) diff --git a/Packages/PrimitivesComponents/Sources/Views/ChartStateView.swift b/Packages/PrimitivesComponents/Sources/Views/ChartStateView.swift new file mode 100644 index 000000000..aa037ba2d --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/Views/ChartStateView.swift @@ -0,0 +1,48 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SwiftUI +import Primitives +import Components +import Style +import Localization + +public struct ChartStateView: View { + private let state: StateViewType + private let periods: [ChartPeriod] + + @Binding private var selectedPeriod: ChartPeriod + + public init( + state: StateViewType, + selectedPeriod: Binding, + periods: [ChartPeriod] = [.hour, .day, .week, .month, .year, .all] + ) { + self.state = state + self._selectedPeriod = selectedPeriod + self.periods = periods + } + + public var body: some View { + VStack { + VStack { + switch state { + case .noData: + StateEmptyView(title: Localized.Common.notAvailable , image: Images.EmptyContent.activity) + case .loading: + LoadingView() + case .data(let model): + ChartView(model: model) + case .error(let error): + StateEmptyView( + title: Localized.Errors.errorOccured, + description: error.networkOrNoDataDescription, + image: Images.ErrorConent.error + ) + } + } + .frame(height: 320) + + PeriodSelectorView(selectedPeriod: $selectedPeriod, periods: periods) + } + } +} diff --git a/Packages/PrimitivesComponents/Sources/Views/ChartView.swift b/Packages/PrimitivesComponents/Sources/Views/ChartView.swift new file mode 100644 index 000000000..4d60f7d7b --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/Views/ChartView.swift @@ -0,0 +1,194 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SwiftUI +import Style +import Charts +import Primitives + +public struct ChartView: View { + private enum ChartKey { + static let date = "Date" + static let value = "Value" + } + + private enum Metrics { + static let lineWidth: CGFloat = 2.5 + static let selectionDotSize: CGFloat = 12 + static let labelWidth: CGFloat = 88 + } + + private let model: ChartValuesViewModel + + @State private var selectedValue: ChartPriceViewModel? + + public init(model: ChartValuesViewModel) { + self.model = model + } + + public var body: some View { + VStack(spacing: 0) { + priceHeader + chart + } + } +} + +// MARK: - UI + +extension ChartView { + private var priceHeader: some View { + Group { + if let selectedValue { + ChartPriceView(model: selectedValue) + } else if let chartPriceViewModel = model.chartPriceViewModel { + ChartPriceView(model: chartPriceViewModel) + } + } + .padding(.top, Spacing.small) + .padding(.bottom, Spacing.tiny) + } + + private var chart: some View { + Chart { + ForEach(model.charts, id: \.date) { item in + AreaMark( + x: .value(ChartKey.date, item.date), + y: .value(ChartKey.value, item.value) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(areaGradient) + .alignsMarkStylesWithPlotArea() + + LineMark( + x: .value(ChartKey.date, item.date), + y: .value(ChartKey.value, item.value) + ) + .lineStyle(StrokeStyle(lineWidth: Metrics.lineWidth, lineCap: .round, lineJoin: .round)) + .foregroundStyle(model.lineColor) + .interpolationMethod(.catmullRom) + } + + if let selectedValue, let date = selectedValue.date { + RuleMark(x: .value(ChartKey.date, date)) + .foregroundStyle(model.lineColor.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + + PointMark(x: .value(ChartKey.date, date), y: .value(ChartKey.value, selectedValue.price)) + .symbol { + Circle() + .fill( + RadialGradient( + colors: [Colors.white, model.lineColor.opacity(0.8)], + center: .center, + startRadius: 0, + endRadius: Metrics.selectionDotSize / 2 + ) + ) + .frame(width: Metrics.selectionDotSize, height: Metrics.selectionDotSize) + .shadow(color: model.lineColor.opacity(0.6), radius: 6) + .overlay(Circle().strokeBorder(model.lineColor, lineWidth: Metrics.lineWidth)) + } + } + } + .chartOverlay { proxy in + GeometryReader { geometry in + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + onDragChange(location: value.location, proxy: proxy, geometry: geometry) + } + .onEnded { _ in + onDragEnd() + } + ) + + if let lastPoint = model.charts.last, + let plotFrame = proxy.plotFrame, + let xPos = proxy.position(forX: lastPoint.date), + let yPos = proxy.position(forY: lastPoint.value) { + let origin = geometry[plotFrame].origin + PulsingDotView(color: model.lineColor) + .position(x: origin.x + xPos, y: origin.y + yPos) + .opacity(selectedValue == nil ? 1 : 0) + .animation(.easeInOut(duration: 0.2), value: selectedValue == nil) + } + } + } + .padding(.vertical, Spacing.large) + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .chartYScale(domain: model.values.yScale) + .chartXScale(domain: model.values.xScale) + .chartBackground { proxy in + GeometryReader { geometry in + if let plotFrame = proxy.plotFrame { + let chartBounds = geometry[plotFrame] + + if let lowerBoundX = proxy.position(forX: model.values.lowerBoundDate) { + boundLabel(model.lowerBoundValueText) + .offset(x: labelX(lowerBoundX, geoWidth: geometry.size.width), y: chartBounds.maxY + Spacing.small) + } + + if let upperBoundX = proxy.position(forX: model.values.upperBoundDate) { + boundLabel(model.upperBoundValueText) + .offset(x: labelX(upperBoundX, geoWidth: geometry.size.width), y: chartBounds.minY - Spacing.large) + } + } + } + } + } + + private var areaGradient: LinearGradient { + .linearGradient( + stops: [ + .init(color: model.lineColor.opacity(0.45), location: 0), + .init(color: model.lineColor.opacity(0.38), location: 0.25), + .init(color: model.lineColor.opacity(0.28), location: 0.5), + .init(color: model.lineColor.opacity(0.15), location: 0.75), + .init(color: model.lineColor.opacity(0.05), location: 0.92), + .init(color: model.lineColor.opacity(0), location: 1.0) + ], + startPoint: .top, + endPoint: .bottom + ) + } + + private func boundLabel(_ text: String) -> some View { + Text(text) + .font(.caption2) + .foregroundStyle(Colors.gray) + .frame(width: Metrics.labelWidth) + } + + private func labelX(_ x: CGFloat, geoWidth: CGFloat) -> CGFloat { + let half = Metrics.labelWidth / 2 + return x < half ? x - half / 2 : min(x - half, geoWidth - Metrics.labelWidth) + } +} + +// MARK: - Actions + +extension ChartView { + private func onDragChange(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) { + guard let plotFrame = proxy.plotFrame else { return } + + let relativeX = location.x - geometry[plotFrame].origin.x + guard let targetDate = proxy.value(atX: relativeX) as Date?, + let element = model.charts.min(by: { abs($0.date.distance(to: targetDate)) < abs($1.date.distance(to: targetDate)) }) else { + return + } + + let newValue = model.priceViewModel(for: element) + if newValue.date != selectedValue?.date { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + selectedValue = newValue + } + + private func onDragEnd() { + selectedValue = nil + } +} diff --git a/Packages/PrimitivesComponents/Sources/Views/PulsingDotView.swift b/Packages/PrimitivesComponents/Sources/Views/PulsingDotView.swift new file mode 100644 index 000000000..9acc4e1eb --- /dev/null +++ b/Packages/PrimitivesComponents/Sources/Views/PulsingDotView.swift @@ -0,0 +1,105 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SwiftUI +import Style + +public struct PulsingDotView: View { + private let color: Color + private let dotSize: CGFloat + private let isAnimated: Bool + + @State private var pulsePhase: CGFloat = 0 + + public init( + color: Color, + dotSize: CGFloat = 8, + isAnimated: Bool = true + ) { + self.color = color + self.dotSize = dotSize + self.isAnimated = isAnimated + } + + public var body: some View { + ZStack { + pulseRing(scale: 3.0, delay: 0) + pulseRing(scale: 2.5, delay: 0.4) + centerDot + } + .onAppear { + guard isAnimated else { return } + startAnimation() + } + } +} + +// MARK: - Components + +extension PulsingDotView { + private var centerDot: some View { + Circle() + .fill( + RadialGradient( + colors: [Colors.white, color], + center: .center, + startRadius: 0, + endRadius: dotSize / 2 + ) + ) + .frame(width: dotSize, height: dotSize) + .shadow(color: color.opacity(0.8), radius: 6) + .shadow(color: color.opacity(0.4), radius: 12) + } + + private func pulseRing(scale: CGFloat, delay: Double) -> some View { + PulseRingView( + color: color, + size: dotSize, + maxScale: scale, + delay: delay, + isAnimated: isAnimated + ) + } +} + +// MARK: - Actions + +extension PulsingDotView { + private func startAnimation() { + withAnimation(.linear(duration: 0.01)) { + pulsePhase = 1 + } + } +} + +// MARK: - PulseRingView + +private struct PulseRingView: View { + let color: Color + let size: CGFloat + let maxScale: CGFloat + let delay: Double + let isAnimated: Bool + + @State private var isExpanded = false + + private let animationDuration: Double = 1.8 + + var body: some View { + Circle() + .stroke(color.opacity(0.4), lineWidth: 1.5) + .frame(width: size, height: size) + .scaleEffect(isExpanded ? maxScale : 1.0) + .opacity(isExpanded ? 0 : 0.8) + .onAppear { + guard isAnimated else { return } + withAnimation( + .easeOut(duration: animationDuration) + .delay(delay) + .repeatForever(autoreverses: false) + ) { + isExpanded = true + } + } + } +} diff --git a/Packages/PrimitivesComponents/TestKit/ChartPriceViewModel+PrimitivesComponentsTestKit.swift b/Packages/PrimitivesComponents/TestKit/ChartPriceViewModel+PrimitivesComponentsTestKit.swift new file mode 100644 index 000000000..f79804195 --- /dev/null +++ b/Packages/PrimitivesComponents/TestKit/ChartPriceViewModel+PrimitivesComponentsTestKit.swift @@ -0,0 +1,25 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Formatters +import Primitives +@testable import PrimitivesComponents + +public extension ChartPriceViewModel { + static func mock( + period: ChartPeriod = .day, + date: Date? = nil, + price: Double = 100, + priceChangePercentage: Double = 5, + type: ChartValueType = .price + ) -> ChartPriceViewModel { + ChartPriceViewModel( + period: period, + date: date, + price: price, + priceChangePercentage: priceChangePercentage, + formatter: CurrencyFormatter(type: .currency, currencyCode: "USD"), + type: type + ) + } +} diff --git a/Packages/PrimitivesComponents/TestKit/ChartValuesViewModel+PrimitivesComponentsTestKit.swift b/Packages/PrimitivesComponents/TestKit/ChartValuesViewModel+PrimitivesComponentsTestKit.swift new file mode 100644 index 000000000..3f8e86049 --- /dev/null +++ b/Packages/PrimitivesComponents/TestKit/ChartValuesViewModel+PrimitivesComponentsTestKit.swift @@ -0,0 +1,25 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import SwiftUI +import Formatters +import Primitives +import PrimitivesTestKit +@testable import PrimitivesComponents + +public extension ChartValuesViewModel { + static func mock( + period: ChartPeriod = .day, + price: Price? = .mock(), + values: ChartValues = .mock(), + type: ChartValueType = .price + ) -> ChartValuesViewModel { + ChartValuesViewModel( + period: period, + price: price, + values: values, + formatter: CurrencyFormatter(type: .currency, currencyCode: "USD"), + type: type + ) + } +} diff --git a/Packages/PrimitivesComponents/TestKit/PriceChangeViewModel+PrimitivesComponentsTestKit.swift b/Packages/PrimitivesComponents/TestKit/PriceChangeViewModel+PrimitivesComponentsTestKit.swift new file mode 100644 index 000000000..ccba09f25 --- /dev/null +++ b/Packages/PrimitivesComponents/TestKit/PriceChangeViewModel+PrimitivesComponentsTestKit.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Formatters +import Primitives +import PrimitivesComponents + +public extension PriceChangeViewModel { + static func mock(value: Double?) -> PriceChangeViewModel { + PriceChangeViewModel( + value: value, + currencyFormatter: CurrencyFormatter(type: .currency, currencyCode: Currency.usd.rawValue) + ) + } +} diff --git a/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ChartPriceViewModelTests.swift b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ChartPriceViewModelTests.swift new file mode 100644 index 000000000..f0db2cf18 --- /dev/null +++ b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ChartPriceViewModelTests.swift @@ -0,0 +1,39 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Testing +import Foundation +import Primitives +import Style +import PrimitivesComponentsTestKit +@testable import PrimitivesComponents + +struct ChartPriceViewModelTests { + + @Test + func chartPriceViewModel() { + #expect(ChartPriceViewModel.mock(price: 100).priceText == "$100.00") + #expect(ChartPriceViewModel.mock(price: 100, type: .priceChange).priceText == "+$100.00") + #expect(ChartPriceViewModel.mock(price: -50, type: .priceChange).priceText == "-$50.00") + + #expect(ChartPriceViewModel.mock(price: 100).priceColor == Colors.black) + #expect(ChartPriceViewModel.mock(price: 100, type: .priceChange).priceColor == Colors.green) + + #expect(ChartPriceViewModel.mock(price: 100, priceChangePercentage: 5.5).priceChangeText == "+5.50%") + #expect(ChartPriceViewModel.mock(price: 100, type: .priceChange).priceChangeText == nil) + #expect(ChartPriceViewModel.mock(price: 0).priceChangeText == nil) + + #expect(ChartPriceViewModel.mock(priceChangePercentage: 10).priceChangeTextColor == Colors.green) + #expect(ChartPriceViewModel.mock(priceChangePercentage: -10).priceChangeTextColor == Colors.red) + + #expect(ChartPriceViewModel.mock(date: nil).dateText == nil) + #expect(ChartPriceViewModel.mock(date: Date()).dateText != nil) + } + + @Test + func priceChangePercentage() { + #expect(ChartPriceViewModel.priceChangePercentage(close: 110, base: 100) == 10) + #expect(ChartPriceViewModel.priceChangePercentage(close: 90, base: 100) == -10) + #expect(ChartPriceViewModel.priceChangePercentage(close: 100, base: 100) == 0) + #expect(ChartPriceViewModel.priceChangePercentage(close: 100, base: 0) == 0) + } +} diff --git a/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/PriceChangeViewModelTests.swift b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/PriceChangeViewModelTests.swift new file mode 100644 index 000000000..bf761999a --- /dev/null +++ b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/PriceChangeViewModelTests.swift @@ -0,0 +1,33 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Testing +import Style +import PrimitivesComponentsTestKit +@testable import PrimitivesComponents + +struct PriceChangeViewModelTests { + + @Test + func textPositive() { + #expect(PriceChangeViewModel.mock(value: 100).text == "+$100.00") + #expect(PriceChangeViewModel.mock(value: 0).text == "+$0.00") + } + + @Test + func textNegative() { + #expect(PriceChangeViewModel.mock(value: -50).text == "-$50.00") + } + + @Test + func textNil() { + #expect(PriceChangeViewModel.mock(value: nil).text == nil) + } + + @Test + func color() { + #expect(PriceChangeViewModel.mock(value: 100).color == Colors.green) + #expect(PriceChangeViewModel.mock(value: -50).color == Colors.red) + #expect(PriceChangeViewModel.mock(value: 0).color == Colors.gray) + #expect(PriceChangeViewModel.mock(value: nil).color == .secondary) + } +} diff --git a/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/ChartValuesViewModelTests.swift b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/ChartValuesViewModelTests.swift new file mode 100644 index 000000000..d9c72a8eb --- /dev/null +++ b/Packages/PrimitivesComponents/Tests/PrimitivesComponentsTests/ViewModels/ChartValuesViewModelTests.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Testing +import Foundation +import Primitives +import PrimitivesTestKit +import PrimitivesComponentsTestKit +@testable import PrimitivesComponents + +struct ChartValuesViewModelTests { + + @Test + func chartValuesViewModel() { + let model = ChartValuesViewModel.mock(price: .mock(price: 150), values: .mock(values: [100, 200])) + + #expect(model.lowerBoundValueText == "$100.00") + #expect(model.upperBoundValueText == "$200.00") + #expect(model.chartPriceViewModel?.price == 150) + #expect(model.priceViewModel(for: ChartDateValue(date: Date(), value: 150)).priceChangePercentage == 50) + + #expect(ChartValuesViewModel.mock(price: nil).chartPriceViewModel == nil) + #expect(ChartValuesViewModel.mock(period: .week, price: .mock(price: 150), values: .mock(values: [100, 200])).chartPriceViewModel?.priceChangePercentage == 50) + } +} diff --git a/core b/core index 7cc3801f1..9693266d4 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 7cc3801f1e45995d5dbbda3d92bdbdc4d404ab9a +Subproject commit 9693266d4d28ab1913bd2b77bc4996d77c4ad58e