diff --git a/Features/Perpetuals/Sources/ViewModels/CandleTooltipViewModel.swift b/Features/Perpetuals/Sources/ViewModels/CandleTooltipViewModel.swift new file mode 100644 index 000000000..498eddd43 --- /dev/null +++ b/Features/Perpetuals/Sources/ViewModels/CandleTooltipViewModel.swift @@ -0,0 +1,43 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SwiftUI +import Primitives +import Formatters +import Style +import Components +import Localization + +public struct CandleTooltipViewModel { + private static let titleStyle = TextStyle(font: .caption2, color: Colors.secondaryText, fontWeight: .medium) + private static let subtitleStyle = TextStyle(font: .caption2.monospacedDigit(), color: Colors.black, fontWeight: .semibold) + private static let volumeFormatter = CurrencyFormatter(type: .abbreviated, currencyCode: Currency.usd.rawValue) + + private let candle: ChartCandleStick + private let formatter: CurrencyFormatter + + public init(candle: ChartCandleStick, formatter: CurrencyFormatter) { + self.candle = candle + self.formatter = formatter + } + + var openTitle: TextValue { TextValue(text: Localized.Charts.Price.open, style: Self.titleStyle, lineLimit: 1) } + var openValue: TextValue { TextValue(text: formatter.string(double: candle.open), style: Self.subtitleStyle, lineLimit: 1) } + + var closeTitle: TextValue { TextValue(text: Localized.Charts.Price.close, style: Self.titleStyle, lineLimit: 1) } + var closeValue: TextValue { TextValue(text: formatter.string(double: candle.close), style: Self.subtitleStyle, lineLimit: 1) } + + var highTitle: TextValue { TextValue(text: Localized.Charts.Price.high, style: Self.titleStyle, lineLimit: 1) } + var highValue: TextValue { TextValue(text: formatter.string(double: candle.high), style: Self.subtitleStyle, lineLimit: 1) } + + var lowTitle: TextValue { TextValue(text: Localized.Charts.Price.low, style: Self.titleStyle, lineLimit: 1) } + var lowValue: TextValue { TextValue(text: formatter.string(double: candle.low), style: Self.subtitleStyle, lineLimit: 1) } + + var changeTitle: TextValue { TextValue(text: Localized.Charts.Price.change, style: Self.titleStyle, lineLimit: 1) } + var changeValue: TextValue { + let change = PriceChangeCalculator.calculate(.percentage(from: candle.open, to: candle.close)) + return TextValue(text: CurrencyFormatter.percent.string(change), style: TextStyle(font: .caption2.monospacedDigit(), color: PriceChangeColor.color(for: change), fontWeight: .semibold), lineLimit: 1) + } + + var volumeTitle: TextValue { TextValue(text: Localized.Perpetual.volume, style: Self.titleStyle, lineLimit: 1) } + var volumeValue: TextValue { TextValue(text: Self.volumeFormatter.string(candle.volume * candle.close), style: Self.subtitleStyle, lineLimit: 1) } +} diff --git a/Features/Perpetuals/Sources/Views/CandleTooltipView.swift b/Features/Perpetuals/Sources/Views/CandleTooltipView.swift new file mode 100644 index 000000000..55782bd69 --- /dev/null +++ b/Features/Perpetuals/Sources/Views/CandleTooltipView.swift @@ -0,0 +1,34 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import SwiftUI +import Style +import Components + +struct CandleTooltipView: View { + let model: CandleTooltipViewModel + + var body: some View { + VStack(spacing: Spacing.small) { + VStack(spacing: Spacing.extraSmall) { + ListItemView(title: model.openTitle, subtitle: model.openValue) + ListItemView(title: model.highTitle, subtitle: model.highValue) + ListItemView(title: model.lowTitle, subtitle: model.lowValue) + ListItemView(title: model.closeTitle, subtitle: model.closeValue) + } + Divider() + VStack(spacing: Spacing.extraSmall) { + ListItemView(title: model.changeTitle, subtitle: model.changeValue) + ListItemView(title: model.volumeTitle, subtitle: model.volumeValue) + } + } + .padding(Spacing.small) + .background(.thickMaterial) + .clipShape(RoundedRectangle(cornerRadius: Spacing.small)) + .overlay( + RoundedRectangle(cornerRadius: Spacing.small) + .stroke(Colors.black.opacity(0.08), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.12), radius: Spacing.small, y: Spacing.tiny) + .fixedSize() + } +} diff --git a/Features/Perpetuals/Sources/Views/CandlestickChartView.swift b/Features/Perpetuals/Sources/Views/CandlestickChartView.swift index b529dd74c..1431f3016 100644 --- a/Features/Perpetuals/Sources/Views/CandlestickChartView.swift +++ b/Features/Perpetuals/Sources/Views/CandlestickChartView.swift @@ -6,6 +6,7 @@ import Style import Primitives import PrimitivesComponents import Formatters +import Components private struct ChartKey { static let date = "Date" @@ -23,7 +24,7 @@ struct CandlestickChartView: View { private let lineModels: [ChartLineViewModel] private let formatter: CurrencyFormatter - @State private var selectedCandle: ChartPriceViewModel? { + @State private var selectedCandle: ChartCandleStick? { didSet { if let selectedCandle, selectedCandle.date != oldValue?.date { vibrate() @@ -44,26 +45,26 @@ struct CandlestickChartView: View { self.lineModels = lineModels self.formatter = formatter } - + var body: some View { VStack { priceHeader chartView(bounds: ChartBounds(candles: data, lines: lineModels)) } } - + private var priceHeader: some View { VStack { - if let selectedCandle { - ChartPriceView(model: selectedCandle) - } else if let currentPrice = currentPriceModel { - ChartPriceView(model: currentPrice) + if let selectedPriceModel { + ChartPriceView(model: selectedPriceModel) + } else if let currentPriceModel { + ChartPriceView(model: currentPriceModel) } } .padding(.top, Spacing.small) .padding(.bottom, Spacing.tiny) } - + private func chartView(bounds: ChartBounds) -> some View { let dateRange = (data.first?.date ?? Date())...(data.last?.date ?? Date()) @@ -81,13 +82,17 @@ struct CandlestickChartView: View { DragGesture(minimumDistance: 0) .onChanged { value in if let candle = findCandle(location: value.location, proxy: proxy, geometry: geometry) { - selectedCandle = createPriceModel(for: candle) + selectedCandle = candle } } .onEnded { _ in selectedCandle = nil } ) + + if let selectedCandle { + tooltipOverlay(for: selectedCandle, proxy: proxy, geometry: geometry) + } } } .chartXAxis { @@ -119,7 +124,7 @@ struct CandlestickChartView: View { .font(.caption2) .foregroundStyle(Colors.whiteSolid) .padding(.horizontal, .extraSmall) - .padding(.vertical, 1) + .padding(.vertical, .space1) .background(currentPriceColor) .clipShape(RoundedRectangle(cornerRadius: Spacing.tiny)) } @@ -133,7 +138,7 @@ struct CandlestickChartView: View { private var currentPriceColor: Color { guard let lastCandle = data.last else { return Colors.gray } - return lastCandle.close >= lastCandle.open ? Colors.green : Colors.red + return PriceChangeColor.color(for: lastCandle.close - lastCandle.open) } @ChartContentBuilder @@ -144,16 +149,16 @@ struct CandlestickChartView: View { yStart: .value(ChartKey.low, candle.low), yEnd: .value(ChartKey.high, candle.high) ) - .lineStyle(StrokeStyle(lineWidth: 1)) - .foregroundStyle(candle.close >= candle.open ? Colors.green : Colors.red) + .lineStyle(StrokeStyle(lineWidth: .space1)) + .foregroundStyle(PriceChangeColor.color(for: candle.close - candle.open)) RectangleMark( x: .value(ChartKey.date, candle.date), yStart: .value(ChartKey.open, candle.open), yEnd: .value(ChartKey.close, candle.close), - width: .fixed(4) + width: .fixed(.space4) ) - .foregroundStyle(candle.close >= candle.open ? Colors.green : Colors.red) + .foregroundStyle(PriceChangeColor.color(for: candle.close - candle.open)) } } @@ -192,59 +197,73 @@ struct CandlestickChartView: View { @ChartContentBuilder private var selectionMarks: some ChartContent { - if let selectedCandle, - let selectedDate = selectedCandle.date, - let selectedCandleData = data.first(where: { abs($0.date.timeIntervalSince(selectedDate)) < 1 }) { + if let selectedCandle { PointMark( - x: .value(ChartKey.date, selectedDate), - y: .value(ChartKey.price, selectedCandleData.close) + x: .value(ChartKey.date, selectedCandle.date), + y: .value(ChartKey.price, selectedCandle.close) ) .symbol { Circle() - .strokeBorder(Colors.blue, lineWidth: 2) + .strokeBorder(Colors.blue, lineWidth: .space2) .background(Circle().foregroundStyle(Colors.white)) - .frame(width: 12) + .frame(width: .space12) } - RuleMark(x: .value(ChartKey.date, selectedDate)) + RuleMark(x: .value(ChartKey.date, selectedCandle.date)) .foregroundStyle(Colors.blue) - .lineStyle(StrokeStyle(lineWidth: 1, dash: [5])) + .lineStyle(StrokeStyle(lineWidth: .space1, dash: [5])) } } - + private var currentPriceModel: ChartPriceViewModel? { - guard let lastCandle = data.last, - let base = basePrice else { return nil } - return ChartPriceViewModel( - period: period, - date: nil, - price: lastCandle.close, - priceChangePercentage: PriceChangeCalculator.calculate(.percentage(from: base, to: lastCandle.close)), - formatter: formatter - ) + guard let lastCandle = data.last, let base = basePrice else { return nil } + return priceModel(for: lastCandle, base: base) + } + + private var selectedPriceModel: ChartPriceViewModel? { + guard let selectedCandle else { return nil } + let base = basePrice ?? data.first?.close ?? selectedCandle.close + return priceModel(for: selectedCandle, base: base, date: selectedCandle.date) } - private func createPriceModel(for candle: ChartCandleStick) -> ChartPriceViewModel { - let base = basePrice ?? data.first?.close ?? candle.close - return ChartPriceViewModel( + private func priceModel(for candle: ChartCandleStick, base: Double, date: Date? = nil) -> ChartPriceViewModel { + ChartPriceViewModel( period: period, - date: candle.date, + date: date, price: candle.close, priceChangePercentage: PriceChangeCalculator.calculate(.percentage(from: base, to: candle.close)), formatter: formatter ) } - + + @ViewBuilder + private func tooltipOverlay(for candle: ChartCandleStick, proxy: ChartProxy, geometry: GeometryProxy) -> some View { + let isRightHalf: Bool = { + guard let plotFrame = proxy.plotFrame, + let xPosition = proxy.position(forX: candle.date) else { return false } + return xPosition > geometry[plotFrame].size.width / 2 + }() + + CandleTooltipView(model: CandleTooltipViewModel(candle: candle, formatter: formatter)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRightHalf ? .topLeading : .topTrailing) + .padding(.leading, Spacing.small) + .padding(.top, Spacing.small) + .padding(.trailing, Spacing.extraLarge + Spacing.medium) + .transition(.opacity) + .animation(.easeInOut(duration: Interval.AnimationDuration.fast), value: isRightHalf) + .allowsHitTesting(false) + } + private func findCandle(location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> ChartCandleStick? { guard let plotFrame = proxy.plotFrame else { return nil } - + let relativeXPosition = location.x - geometry[plotFrame].origin.x - + if let date = proxy.value(atX: relativeXPosition) as Date? { // Find the closest candle var minDistance: TimeInterval = .infinity var closestCandle: ChartCandleStick? - + for candle in data { let distance = abs(candle.date.timeIntervalSince(date)) if distance < minDistance { @@ -252,13 +271,13 @@ struct CandlestickChartView: View { closestCandle = candle } } - + return closestCandle } - + return nil } - + private func vibrate() { UIImpactFeedbackGenerator(style: .light).impactOccurred() } diff --git a/Features/Perpetuals/TestKit/CandleTooltipViewModel+TestKit.swift b/Features/Perpetuals/TestKit/CandleTooltipViewModel+TestKit.swift new file mode 100644 index 000000000..66a4eabad --- /dev/null +++ b/Features/Perpetuals/TestKit/CandleTooltipViewModel+TestKit.swift @@ -0,0 +1,15 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives +import Formatters +@testable import Perpetuals + +public extension CandleTooltipViewModel { + static func mock( + candle: ChartCandleStick = .mock(), + formatter: CurrencyFormatter = CurrencyFormatter(type: .currency, locale: Locale(identifier: "en_US"), currencyCode: "USD") + ) -> CandleTooltipViewModel { + CandleTooltipViewModel(candle: candle, formatter: formatter) + } +} diff --git a/Features/Perpetuals/TestKit/ChartCandleStick+TestKit.swift b/Features/Perpetuals/TestKit/ChartCandleStick+TestKit.swift new file mode 100644 index 000000000..e7422678d --- /dev/null +++ b/Features/Perpetuals/TestKit/ChartCandleStick+TestKit.swift @@ -0,0 +1,24 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import Primitives + +public extension ChartCandleStick { + static func mock( + date: Date = Date(timeIntervalSince1970: 0), + open: Double = 100, + high: Double = 110, + low: Double = 90, + close: Double = 105, + volume: Double = 1000 + ) -> ChartCandleStick { + ChartCandleStick( + date: date, + open: open, + high: high, + low: low, + close: close, + volume: volume + ) + } +} diff --git a/Features/Perpetuals/Tests/CandleTooltipViewModelTests.swift b/Features/Perpetuals/Tests/CandleTooltipViewModelTests.swift new file mode 100644 index 000000000..8be002228 --- /dev/null +++ b/Features/Perpetuals/Tests/CandleTooltipViewModelTests.swift @@ -0,0 +1,42 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Testing +import Primitives +import PrimitivesTestKit +import PerpetualsTestKit +import Formatters +import Localization +@testable import Perpetuals + +struct CandleTooltipViewModelTests { + + @Test + func tooltipContent() { + let model = CandleTooltipViewModel.mock(candle: .mock(open: 67_715, high: 68_181, low: 67_714, close: 68_087, volume: 500)) + + #expect(model.openTitle.text == Localized.Charts.Price.open) + #expect(model.openValue.text == "67,715.00") + + #expect(model.highTitle.text == Localized.Charts.Price.high) + #expect(model.highValue.text == "68,181.00") + + #expect(model.lowTitle.text == Localized.Charts.Price.low) + #expect(model.lowValue.text == "67,714.00") + + #expect(model.closeTitle.text == Localized.Charts.Price.close) + #expect(model.closeValue.text == "68,087.00") + + #expect(model.changeTitle.text == Localized.Charts.Price.change) + #expect(model.changeValue.text == "+0.55%") + + #expect(model.volumeTitle.text == Localized.Perpetual.volume) + #expect(model.volumeValue.text == "$34.04M") + } + + @Test + func changeSign() { + #expect(CandleTooltipViewModel.mock(candle: .mock(open: 100, close: 105)).changeValue.text == "+5.00%") + #expect(CandleTooltipViewModel.mock(candle: .mock(open: 100, close: 95)).changeValue.text == "-5.00%") + #expect(CandleTooltipViewModel.mock(candle: .mock(open: 100, close: 100)).changeValue.text == "+0.00%") + } +} diff --git a/Packages/Style/Sources/Spacing.swift b/Packages/Style/Sources/Spacing.swift index ce29c9aa8..c0f89bca0 100644 --- a/Packages/Style/Sources/Spacing.swift +++ b/Packages/Style/Sources/Spacing.swift @@ -5,6 +5,8 @@ public typealias Spacing = CGFloat public typealias Sizing = CGFloat public extension Spacing { + /// 1 + static let space1: Spacing = 1 /// 2 static let space2: Spacing = 2 /// 4