Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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) }
}
33 changes: 33 additions & 0 deletions Features/Perpetuals/Sources/Views/CandleTooltipView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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)
}
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()
}
}
97 changes: 58 additions & 39 deletions Features/Perpetuals/Sources/Views/CandlestickChartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Style
import Primitives
import PrimitivesComponents
import Formatters
import Components

private struct ChartKey {
static let date = "Date"
Expand All @@ -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()
Expand All @@ -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())

Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -145,15 +150,15 @@ struct CandlestickChartView: View {
yEnd: .value(ChartKey.high, candle.high)
)
.lineStyle(StrokeStyle(lineWidth: 1))
.foregroundStyle(candle.close >= candle.open ? Colors.green : Colors.red)
.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)
)
.foregroundStyle(candle.close >= candle.open ? Colors.green : Colors.red)
.foregroundStyle(PriceChangeColor.color(for: candle.close - candle.open))
}
}

Expand Down Expand Up @@ -192,12 +197,10 @@ 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()
Expand All @@ -206,59 +209,75 @@ struct CandlestickChartView: View {
.frame(width: 12)
}

RuleMark(x: .value(ChartKey.date, selectedDate))
RuleMark(x: .value(ChartKey.date, selectedCandle.date))
.foregroundStyle(Colors.blue)
.lineStyle(StrokeStyle(lineWidth: 1, 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)
Comment on lines +248 to +251
Copy link
Contributor

Choose a reason for hiding this comment

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

high

The padding applied to the CandleTooltipView is unconditional. Specifically, padding(.trailing, Spacing.extraLarge + Spacing.medium) is applied even when the tooltip is aligned to the trailing edge (isRightHalf is false). This will cause the tooltip to be pushed off-screen to the right, leading to a poor user experience. The padding should be conditional based on the isRightHalf flag to ensure the tooltip remains within the visible chart bounds.

Consider applying leading padding when isRightHalf is false (aligned trailing) and trailing padding when isRightHalf is true (aligned leading).

Suggested change
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRightHalf ? .topLeading : .topTrailing)
.padding(.leading, Spacing.small)
.padding(.top, Spacing.small)
.padding(.trailing, Spacing.extraLarge + Spacing.medium)
CandleTooltipView(model: CandleTooltipViewModel(candle: candle, formatter: formatter))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: isRightHalf ? .topLeading : .topTrailing)
.padding(.top, Spacing.small)
.padding(isRightHalf ? .leading : .trailing, Spacing.small)
.padding(isRightHalf ? .trailing : .leading, Spacing.extraLarge + Spacing.medium)
.transition(.opacity)

.transition(.opacity)
.animation(.easeInOut(duration: 0.15), value: isRightHalf)
Copy link
Contributor

Choose a reason for hiding this comment

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

use AnimationDuration

.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 {
minDistance = distance
closestCandle = candle
}
}

return closestCandle
}

return nil
}

private func vibrate() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
Expand Down
15 changes: 15 additions & 0 deletions Features/Perpetuals/TestKit/CandleTooltipViewModel+TestKit.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 24 additions & 0 deletions Features/Perpetuals/TestKit/ChartCandleStick+TestKit.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
42 changes: 42 additions & 0 deletions Features/Perpetuals/Tests/CandleTooltipViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -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%")
}
}