Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 5 additions & 1 deletion Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ extension AppRouter {
case currencyCreationSummary
case currencyCreationWizard
case transactionHistory(PublicKey)
/// The "How Market Cap Works" explainer for one currency.
case marketCapExplainer(PublicKey)
/// The unified, cross-token activity history — the "dive in" from the
/// Wallet's Recent section. `transactionHistory` is the per-token slice.
case activity
Expand Down Expand Up @@ -133,7 +135,7 @@ extension AppRouter {
switch self {
case .currencyInfo, .currencyInfoForDeposit, .discoverCurrencies,
.currencyCreationSummary, .currencyCreationWizard,
.transactionHistory, .activity, .transactionDetails, .give,
.transactionHistory, .marketCapExplainer, .activity, .transactionDetails, .give,
.buyCurrency, .convertCurrency,
.withdrawCurrency, .usdcDepositEducation, .usdcDepositAddress:
return .balance
Expand Down Expand Up @@ -163,6 +165,7 @@ extension AppRouter {
case .currencyCreationSummary: "currencyCreationSummary"
case .currencyCreationWizard: "currencyCreationWizard"
case .transactionHistory: "transactionHistory"
case .marketCapExplainer: "marketCapExplainer"
case .activity: "activity"
case .transactionDetails: "transactionDetails"
case .give: "give"
Expand Down Expand Up @@ -210,6 +213,7 @@ extension AppRouter {
case .currencyInfo(let mint),
.currencyInfoForDeposit(let mint),
.transactionHistory(let mint),
.marketCapExplainer(let mint),
.give(let mint),
.buyCurrency(let mint),
.convertCurrency(let mint):
Expand Down
7 changes: 7 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ struct DestinationView: View {
case .transactionHistory(let mint):
TransactionHistoryScreen(mint: mint)

case .marketCapExplainer(let mint):
MarketCapExplainerScreen(
mint: mint,
session: sessionContainer.session,
ratesController: sessionContainer.ratesController
)

case .activity:
ActivityHistoryScreen()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ struct CurrencyInfoContentV2: View {
/// Dollars row carries the tile; anything else withdraws from Settings.
let onWithdraw: () -> Void
let onShowTransactionHistory: () -> Void
/// Opens "How Market Cap Works".
let onShowMarketCapExplainer: () -> Void
/// Fires when the hero card's title scrolls out from under the toolbar, so
/// the screen can reveal its own title.
let onScrolledPastTitle: (Bool) -> Void
Expand Down Expand Up @@ -151,12 +153,19 @@ struct CurrencyInfoContentV2: View {
// all live inside this reused, self-contained section, which
// manages its own insets.
if !isUSDF {
// The row sits 12pt under the range picker, as on Android;
// the section carries no bottom inset of its own.
VStack(spacing: 12) {
CurrencyInfoMarketCapSection(
marketCap: viewModel.marketCap,
currencyCode: ratesController.balanceCurrency,
marketCapController: marketCapController,
isReady: !defersHeavyContent
)

MarketCapExplainerRow(action: onShowMarketCapExplainer)
.padding(.horizontal, 20)
}
}

CurrencyInfoAboutSection(
Expand Down Expand Up @@ -265,9 +274,7 @@ struct CurrencyInfoContentV2: View {

private var heroData: TokenCardData {
let appreciation = viewModel.appreciation
// Never render "-$0.00": a sub-cent delta reads as positive.
let positive = appreciation.isPositive || !appreciation.amount.hasDisplayableValue
let appreciationText = (positive ? "+" : "-") + appreciation.amount.formatted()
let appreciationText = appreciation.amount.signedAppreciationText(isPositive: appreciation.isPositive)

return TokenCardData(
mint: metadata.mint,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,17 @@ struct CurrencyInfoMarketCapSection: View {

var body: some View {
VStack(alignment: .leading) {
Text("Market Cap")
.foregroundStyle(Color.textSecondary)
.font(.appTextMedium)
.padding(.horizontal, 20)

if let viewModel = chartViewModel {
StockChart(
viewModel: viewModel,
currencyCode: currencyCode,
positiveColor: .Sentiment.positive,
negativeColor: .Sentiment.negative
negativeColor: .Sentiment.negative,
headerTitle: "Market Cap"
)
}
}
.padding(.top, 20)
.padding(.bottom, 20)
// The view model is cheap and gives the section its full height right
// away: value, change, a placeholder plot, and the range picker. The
// fetch starts here too — it is network-bound, so it costs an opening
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ private struct CurrencyInfoScreenContent: View {
onBuy: { router.push(.buyCurrency(mint)) },
onWithdraw: { router.push(.withdrawCurrency(mint)) },
onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) },
onShowMarketCapExplainer: { router.push(.marketCapExplainer(metadata.mint)) },
onScrolledPastTitle: { scrolledPast in
guard showsToolbarTitle != scrolledPast else { return }
withAnimation(.easeInOut(duration: 0.2)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//
// MarketCapCurveChart.swift
// Flipcash
//

import SwiftUI
import Charts
import FlipcashCore
import FlipcashUI

/// The bonding curve: solid with a fade beneath it up to the selected supply,
/// dashed beyond, a reference line at Today's price, and a marker at the selection.
struct MarketCapCurveChart: View, Animatable {
let explainer: MarketCapExplainer
/// The slider position in 0...1. Animatable, so a spring on the slider
/// re-renders the chart from the real selection each frame instead of
/// Charts interpolating between two data sets of different sizes.
var position: Double
let reserveText: (Decimal) -> String

var animatableData: Double {
get { position }
set { position = newValue }
}

/// Everything derived from one position, computed once per render.
struct Frame {
let selection: MarketCapExplainer.Stop
let today: MarketCapExplainer.Stop
let points: [MarketCapExplainer.CurvePoint]
let xDomain: ClosedRange<Double>
let yRange: ClosedRange<Double>
let selectionLabel: String

init(explainer: MarketCapExplainer, position: Double, reserveText: (Decimal) -> String) {
selection = explainer.stop(atTrackPosition: position)
today = explainer.todayStop
points = explainer.curvePoints(for: selection)
xDomain = explainer.chartXDomain(for: selection)
yRange = explainer.chartPriceRange(for: selection)
selectionLabel = selection.isToday
? "\(reserveText(selection.reserve)) Today"
: reserveText(selection.reserve)
}

var selectedX: Double { selection.supply.doubleValue }
var selectedY: Double { selection.price.doubleValue }
var todayX: Double { today.supply.doubleValue }
var todayY: Double { today.price.doubleValue }

var solid: [MarketCapExplainer.CurvePoint] {
points.filter { $0.supply < selectedX } + [.init(supply: selectedX, price: selectedY)]
}

var faded: [MarketCapExplainer.CurvePoint] {
[.init(supply: selectedX, price: selectedY)] + points.filter { $0.supply > selectedX }
}
}

var body: some View {
chart(Frame(explainer: explainer, position: position, reserveText: reserveText))
}

private func chart(_ f: Frame) -> some View {
Chart {
ForEach(Array(f.solid.enumerated()), id: \.offset) { _, point in
AreaMark(
x: .value("Supply", point.supply),
yStart: .value("Floor", f.yRange.lowerBound),
yEnd: .value("Price", point.price),
series: .value("Series", "area")
)
.foregroundStyle(
LinearGradient(
colors: [Color.Sentiment.positive.opacity(0.35), Color.Sentiment.positive.opacity(0)],
startPoint: .top,
endPoint: .bottom
)
)
LineMark(
x: .value("Supply", point.supply),
y: .value("Price", point.price),
series: .value("Series", "solid")
)
.foregroundStyle(Color.Sentiment.positive)
.lineStyle(StrokeStyle(lineWidth: 2.5))
}

ForEach(Array(f.faded.enumerated()), id: \.offset) { _, point in
LineMark(
x: .value("Supply", point.supply),
y: .value("Price", point.price),
series: .value("Series", "faded")
)
.foregroundStyle(Color.Sentiment.positive.opacity(0.45))
.lineStyle(StrokeStyle(lineWidth: 2, dash: [4, 4]))
}

RuleMark(y: .value("Today's price", f.todayY))
.foregroundStyle(Color.textSecondary.opacity(0.6))
.lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3]))

RuleMark(x: .value("Selected supply", f.selectedX))
.foregroundStyle(Color.white.opacity(0.5))
.lineStyle(StrokeStyle(lineWidth: 1))
.annotation(
position: .top,
alignment: .center,
spacing: 4,
overflowResolution: .init(x: .fit(to: .chart), y: .disabled)
) {
Text(f.selectionLabel)
.font(.appTextSmall)
.foregroundStyle(Color.textMain)
.fixedSize()
}

// Today sits past the right edge when the selection is far below it.
if f.xDomain.contains(f.todayX) {
PointMark(x: .value("Today", f.todayX), y: .value("Today's price", f.todayY))
.symbolSize(30)
.foregroundStyle(Color.textSecondary)
}

PointMark(x: .value("Selected supply", f.selectedX), y: .value("Price", f.selectedY))
.symbolSize(180)
.foregroundStyle(Color.Sentiment.positive)
}
.chartXScale(domain: f.xDomain)
.chartYScale(domain: f.yRange)
.chartXAxis(.hidden)
.chartYAxis(.hidden)
.chartPlotStyle { $0.padding(.top, 28).padding(.horizontal, 8) }
.frame(height: 220)
.transaction { $0.animation = nil }
.accessibilityHidden(true)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// MarketCapExplainerRow.swift
// Flipcash
//

import SwiftUI
import FlipcashUI

/// The entry row under the market cap chart (Figma node 10761:1879).
struct MarketCapExplainerRow: View {
let action: () -> Void


var body: some View {
Button(action: action) {
HStack(spacing: 12) {
MarketCapCurveIcon()
.frame(width: 22, height: 22)
.frame(width: 40, height: 40)
.background(Circle().fill(Color.rowSeparator))

VStack(alignment: .leading, spacing: 2) {
Text("How Market Cap Works")
.font(.appTextMedium)
.foregroundStyle(Color.textMain)
Text("See what moves market cap · 1 min")
.font(.default(size: 14, weight: .medium))
.foregroundStyle(Color.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)

Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color.textSecondary)
.frame(width: 12, height: 12)
}
.padding(14)
.frame(maxWidth: .infinity)
.background(Color.backgroundRow)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
}
.buttonStyle(.plain)
}
}

/// The rising-curve glyph, drawn on a 22×22 grid: a stroked line ending in a dot.
private struct MarketCapCurveIcon: View {
var body: some View {
Canvas { context, size in
let scale = size.width / 22
var line = Path()
line.move(to: CGPoint(x: 2.75 * scale, y: 17.4167 * scale))
line.addCurve(
to: CGPoint(x: 19.25 * scale, y: 4.58333 * scale),
control1: CGPoint(x: 9.16667 * scale, y: 17.4167 * scale),
control2: CGPoint(x: 13.75 * scale, y: 14.6667 * scale)
)
context.stroke(
line,
with: .color(Color.Sentiment.positive),
style: StrokeStyle(lineWidth: 2.2 * scale, lineCap: .round, lineJoin: .round)
)
let r = 1.65 * scale
context.fill(
Path(ellipseIn: CGRect(x: 19.25 * scale - r, y: 4.58333 * scale - r, width: r * 2, height: r * 2)),
with: .color(Color.Sentiment.positive)
)
}
}
}
Loading
Loading