From 62ffc4fc650be3ef4faed1403acace6e5b02ee59 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 20:25:31 -0400 Subject: [PATCH 1/5] feat(currency-core): model the market cap explainer on the bonding curve MarketCapExplainer maps a reserve in USD to supply, price and the fee-free sell value of a held balance at that supply. Slider stops are $5K, $100K, $1M and $10M, with a 10x stop appended while the top stop is at most twice today's reserve, capped at the reserve where all 21M tokens are sold. --- .../Models/DiscreteBondingCurve.swift | 2 +- .../Models/MarketCapExplainer.swift | 229 ++++++++++++++++++ .../MarketCapExplainerTests.swift | 195 +++++++++++++++ 3 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/MarketCapExplainer.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/MarketCapExplainerTests.swift diff --git a/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift b/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift index be74fba66..a448e2600 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/DiscreteBondingCurve.swift @@ -143,7 +143,7 @@ public struct DiscreteBondingCurve: Sendable { /// /// - Parameter value: Total value in USDC (not quarks) /// - Returns: Supply as BigDecimal with fractional tokens - private func preciseSupplyFromValue(_ value: BigDecimal) -> BigDecimal { + func preciseSupplyFromValue(_ value: BigDecimal) -> BigDecimal { _ = Self.tablesLoaded return BigDecimal(SharedBondingCurve.preciseSupplyFromValue(value: value.asString(.plain))) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/MarketCapExplainer.swift b/FlipcashCore/Sources/FlipcashCore/Models/MarketCapExplainer.swift new file mode 100644 index 000000000..f932b5918 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/MarketCapExplainer.swift @@ -0,0 +1,229 @@ +// +// MarketCapExplainer.swift +// FlipcashCore +// + +import Foundation +// @preconcurrency: BigDecimal.Rounding not Sendable upstream. +@preconcurrency import BigDecimal + +/// The numbers behind "How Market Cap Works": a continuous reserve axis along +/// the bonding curve with labeled ticks, the price and the holder's worth at any +/// point on it, the curve samples for the chart, and the ownership rows. +/// +/// The slider axis is the reserve (dollars in the curve), `tokensToValue(0, S)`. +/// Supply at a stop comes from the curve's own inverse, never a closed form. +public struct MarketCapExplainer: Sendable { + + /// A position on the reserve axis. + public struct Stop: Sendable, Equatable, Identifiable { + /// Dollars in the curve at this supply. + public let reserve: Foundation.Decimal + /// Circulating supply in whole tokens at this reserve. + public let supply: Foundation.Decimal + /// Spot price per token at this supply, in USD. + public let price: Foundation.Decimal + /// Whether this is the currency's live position. + public let isToday: Bool + + public var id: Foundation.Decimal { reserve } + } + + /// A point on the curve, for the chart. + public struct CurvePoint: Sendable, Equatable { + public let supply: Double + public let price: Double + + public init(supply: Double, price: Double) { + self.supply = supply + self.price = price + } + } + + /// The "Your Ownership" rows. Every holding-dependent value is nil without holdings. + public struct Ownership: Sendable, Equatable { + /// Whole tokens held. + public let tokens: Foundation.Decimal? + /// Today's spot price per token, in USD. + public let price: Foundation.Decimal + /// Held / circulating, as a fraction (0.01 = 1%). + public let shareOfCirculating: Foundation.Decimal? + /// Held / 21M max supply, as a fraction. + public let shareOfMax: Foundation.Decimal? + } + + /// The base reserve stops, in dollars. + public static let fixedReserves: [Foundation.Decimal] = [5_000, 100_000, 1_000_000, 10_000_000] + + /// The chart reaches this many tokens past the selected supply. + public static let chartLookahead: Int = 750_000 + + /// Spacing of the chart's curve samples, in tokens. + public static let sampleStride: Int = 25_000 + + /// Lowest price the chart's y axis shows, in USD. + public static let chartFloorPrice: Double = 0.01 + + /// The labeled ticks sorted by reserve; exactly one has `isToday`. + public let stops: [Stop] + + /// How close to Today's track position (as a fraction of the track) a drag + /// reads as Today. + public static let todayTrackTolerance: Double = 0.002 + + /// The holder's balance in quarks, if known. + public let heldQuarks: UInt64? + + private let curve: DiscreteBondingCurve + + /// Returns nil when the curve cannot price today's supply. + public init?(todaySupplyQuarks: UInt64, heldQuarks: UInt64?, curve: DiscreteBondingCurve = DiscreteBondingCurve()) { + self.curve = curve + self.heldQuarks = heldQuarks + + let todaySupply = Foundation.Decimal(todaySupplyQuarks) / Foundation.Decimal(DiscreteBondingCurve.quarksPerToken) + guard + let todayReserve = curve.tokensToValue(currentSupply: BigDecimal.zero, tokens: BigDecimal(todaySupply.description))?.asDecimal(), + let todayPrice = curve.spotPrice(at: Self.wholeTokens(todaySupply))?.asDecimal() + else { return nil } + + var built = [Stop(reserve: todayReserve, supply: todaySupply, price: todayPrice, isToday: true)] + let maxReserve = curve.tokensToValue( + currentSupply: BigDecimal.zero, + tokens: BigDecimal(DiscreteBondingCurve.maxSupply) + )?.asDecimal() ?? todayReserve + for reserve in Self.stopReserves(todayReserve: todayReserve, maxReserve: maxReserve) + where (reserve - todayReserve).magnitude > todayReserve * Self.todayStopMergeFraction { + guard let stop = Self.makeStop(curve: curve, reserve: reserve, isToday: false) else { continue } + built.append(stop) + } + self.stops = built.sorted { $0.reserve < $1.reserve } + } + + // MARK: - Stops + + /// A fixed stop this close to Today's reserve (as a fraction) is replaced by Today. + private static let todayStopMergeFraction = Foundation.Decimal(string: "0.01")! + + /// The fixed reserves for a currency: the base stops, then ×10 stops while the + /// largest is at most twice Today's reserve, never past the curve's own maximum. + static func stopReserves(todayReserve: Foundation.Decimal, maxReserve: Foundation.Decimal) -> [Foundation.Decimal] { + var reserves = fixedReserves + while let largest = reserves.last, largest <= todayReserve * 2, largest * 10 <= maxReserve { + reserves.append(largest * 10) + } + return reserves + } + + public var todayStop: Stop { stops.first(where: \.isToday)! } + + public var todayIndex: Int { stops.firstIndex(where: \.isToday)! } + + /// Position on the track in 0...1, by log of the reserve. + public func trackPosition(of stop: Stop) -> Double { + guard let lo = stops.first, let hi = stops.last, lo.reserve < hi.reserve else { return 0 } + let logLo = log(lo.reserve.doubleValue) + let logHi = log(hi.reserve.doubleValue) + return (log(stop.reserve.doubleValue) - logLo) / (logHi - logLo) + } + + /// The reserve at the track's ends: min($5K, Today) and max($10M, Today). + public var trackBounds: ClosedRange { + stops.first!.reserve...stops.last!.reserve + } + + /// The point at a track position in 0...1, log-spaced by reserve. Near + /// Today's own position it returns Today exactly. + public func stop(atTrackPosition position: Double) -> Stop { + let t = min(max(position, 0), 1) + let today = todayStop + if abs(t - trackPosition(of: today)) < Self.todayTrackTolerance { return today } + if t == 0 { return stops.first! } + if t == 1 { return stops.last! } + let bounds = trackBounds + let logLo = log(bounds.lowerBound.doubleValue) + let logHi = log(bounds.upperBound.doubleValue) + let reserve = Foundation.Decimal(exp(logLo + t * (logHi - logLo))).rounded(to: 2) + return makeStop(reserve: reserve, isToday: false) ?? today + } + + private func makeStop(reserve: Foundation.Decimal, isToday: Bool) -> Stop? { + Self.makeStop(curve: curve, reserve: reserve, isToday: isToday) + } + + private static func makeStop(curve: DiscreteBondingCurve, reserve: Foundation.Decimal, isToday: Bool) -> Stop? { + let supply = curve.preciseSupplyFromValue(BigDecimal(reserve.description)).asDecimal() + guard let price = curve.spotPrice(at: wholeTokens(supply))?.asDecimal() else { return nil } + return Stop(reserve: reserve, supply: supply, price: price, isToday: isToday) + } + + // MARK: - Valuation + + /// The held balance's fee-free sell value in USD if the supply were `stop.supply`. + /// Nil when holdings are unknown or the curve can't value them. + public func worth(at stop: Stop) -> Foundation.Decimal? { + guard let heldQuarks else { return nil } + let supplyQuarks = (stop.supply * Foundation.Decimal(DiscreteBondingCurve.quarksPerToken)).rounded(to: 0) + return curve.sell( + tokenQuarks: Int(heldQuarks), + feeBps: 0, + supplyQuarks: NSDecimalNumber(decimal: supplyQuarks).intValue + )?.netUSDF.asDecimal() + } + + // MARK: - Chart + + /// The chart's right edge, in whole tokens, for a selection. + public func chartSupplyMax(for stop: Stop) -> Int { + min(Self.wholeTokens(stop.supply) + Self.chartLookahead, DiscreteBondingCurve.maxSupply) + } + + /// Fraction of the chart's width kept clear to the right of the selection, + /// so its marker is never cut off at the edge. + public static let chartEdgeMargin: Double = 0.04 + + /// The chart's x domain for a selection: the curve's right edge, widened so + /// the selected supply sits at least ``chartEdgeMargin`` inside it. + public func chartXDomain(for stop: Stop) -> ClosedRange { + let edge = Double(chartSupplyMax(for: stop)) + let supply = stop.supply.doubleValue + return 0...max(edge, supply / (1 - Self.chartEdgeMargin)) + } + + /// Curve samples from zero to the chart's right edge, from the curve's spot price. + public func curvePoints(for stop: Stop) -> [CurvePoint] { + let xMax = chartSupplyMax(for: stop) + var xs = Array(stride(from: 0, to: xMax, by: Self.sampleStride)) + xs.append(xMax) + return xs.compactMap { x in + guard let price = curve.spotPrice(at: x)?.asDecimal().doubleValue else { return nil } + return CurvePoint(supply: Double(x), price: price) + } + } + + /// The chart's y range: `$0.01` to the price at the right edge. + public func chartPriceRange(for stop: Stop) -> ClosedRange { + let top = curve.spotPrice(at: chartSupplyMax(for: stop))?.asDecimal().doubleValue ?? Self.chartFloorPrice + return Self.chartFloorPrice...max(top, Self.chartFloorPrice) + } + + // MARK: - Ownership + + public var ownership: Ownership { + let today = todayStop + guard let heldQuarks else { + return Ownership(tokens: nil, price: today.price, shareOfCirculating: nil, shareOfMax: nil) + } + let held = Foundation.Decimal(heldQuarks) / Foundation.Decimal(DiscreteBondingCurve.quarksPerToken) + return Ownership( + tokens: held, + price: today.price, + shareOfCirculating: today.supply > 0 ? held / today.supply : nil, + shareOfMax: held / Foundation.Decimal(DiscreteBondingCurve.maxSupply) + ) + } + + private static func wholeTokens(_ supply: Foundation.Decimal) -> Int { + NSDecimalNumber(decimal: supply.roundedDown(to: 0)).intValue + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MarketCapExplainerTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MarketCapExplainerTests.swift new file mode 100644 index 000000000..206401502 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/MarketCapExplainerTests.swift @@ -0,0 +1,195 @@ +// +// MarketCapExplainerTests.swift +// FlipcashCoreTests +// + +import Testing +import Foundation +@testable import FlipcashCore +@preconcurrency import BigDecimal + +@Suite("MarketCapExplainer") +struct MarketCapExplainerTests { + + static let quarksPerToken: UInt64 = 10_000_000_000 + + /// 1.25M tokens in circulation, 12,400 held. + let explainer = MarketCapExplainer( + todaySupplyQuarks: 1_250_000 * quarksPerToken, + heldQuarks: 12_400 * quarksPerToken + )! + + private func stop(reserve: Foundation.Decimal) -> MarketCapExplainer.Stop { + explainer.stops.first { $0.reserve == reserve }! + } + + private func rounded(_ value: Foundation.Decimal, _ places: Int) -> Foundation.Decimal { + value.rounded(to: places) + } + + // MARK: - Acceptance vectors + + @Test("Today: price $0.02994, worth $369.18, reserve about $22.7K") + func today() throws { + let today = explainer.todayStop + #expect(today.isToday) + #expect(rounded(today.price, 5) == Foundation.Decimal(string: "0.02994")!) + #expect(rounded(try #require(explainer.worth(at: today)), 2) == Foundation.Decimal(string: "369.18")!) + #expect(rounded(today.reserve, -2) == 22_700) + } + + @Test("Fixed stops: reserve to price to worth of 12,400 tokens", arguments: [ + (Foundation.Decimal(5_000), "0.0144", Foundation.Decimal(177)), + (Foundation.Decimal(100_000), "0.0977", Foundation.Decimal(1_205)), + (Foundation.Decimal(1_000_000), "0.887", Foundation.Decimal(10_941)), + (Foundation.Decimal(10_000_000), "8.78", Foundation.Decimal(108_304)), + ]) + func fixedStops(reserve: Foundation.Decimal, price: String, worth: Foundation.Decimal) throws { + let stop = stop(reserve: reserve) + #expect(!stop.isToday) + let places = price.split(separator: ".")[1].count + #expect(rounded(stop.price, places) == Foundation.Decimal(string: price)!) + #expect(rounded(try #require(explainer.worth(at: stop)), 0) == worth) + } + + // MARK: - Axis + + @Test("Ticks are the four fixed reserves plus Today, sorted") + func ticks() { + let reserves = explainer.stops.map(\.reserve) + #expect(reserves == reserves.sorted()) + #expect(reserves.count == 5) + #expect(explainer.stops.filter(\.isToday).count == 1) + } + + @Test("Track runs min($5K, Today) to max($10M, Today), log-spaced") + func track() { + #expect(explainer.trackPosition(of: stop(reserve: 5_000)) == 0) + #expect(explainer.trackPosition(of: stop(reserve: 10_000_000)) == 1) + let today = explainer.trackPosition(of: explainer.todayStop) + #expect(today > 0 && today < 1) + } + + @Test("A Today above $10M gets a higher stop to its right") + func todayBeyondFixedRange() { + let big = MarketCapExplainer(todaySupplyQuarks: 8_000_000 * Self.quarksPerToken, heldQuarks: nil)! + #expect(big.todayStop.reserve > 10_000_000) + #expect(big.stops.last?.isToday == false) + #expect(big.trackPosition(of: big.todayStop) < 1) + } + + @Test("Dragging near Today reads as Today; elsewhere it does not") + func dragSnapsToTodayOnly() { + let todayPosition = explainer.trackPosition(of: explainer.todayStop) + #expect(explainer.stop(atTrackPosition: todayPosition + 0.001).isToday) + let away = explainer.stop(atTrackPosition: 0.9) + #expect(!away.isToday) + #expect(away.reserve > explainer.todayStop.reserve) + #expect(explainer.stop(atTrackPosition: 0).reserve == 5_000) + #expect(explainer.stop(atTrackPosition: 1).reserve == 10_000_000) + } + + @Test("A mid-track position yields a supply and price strictly between its neighbours") + func continuousSelection() { + let low = explainer.stop(atTrackPosition: 0.6) + let high = explainer.stop(atTrackPosition: 0.8) + #expect(low.supply < high.supply) + #expect(low.price <= high.price) + } + + // MARK: - Chart + + @Test("Chart spans 0 to selected supply + 750K tokens, $0.01 to the price at the edge") + func chartDomain() { + let today = explainer.todayStop + #expect(explainer.chartSupplyMax(for: today) == 2_000_000) + let points = explainer.curvePoints(for: today) + #expect(points.first?.supply == 0) + #expect(points.last?.supply == 2_000_000) + let range = explainer.chartPriceRange(for: today) + #expect(range.lowerBound == 0.01) + #expect(range.upperBound == points.last?.price) + } + + @Test("The selected supply stays inside the chart's x domain at every slider position", + arguments: [10_000, 1_250_000, 12_000_000, 20_900_000, 21_000_000] as [UInt64]) + func selectionStaysInChart(todayTokens: UInt64) throws { + let e = try #require(MarketCapExplainer(todaySupplyQuarks: todayTokens * Self.quarksPerToken, heldQuarks: nil)) + for i in 0...100 { + let stop = e.stop(atTrackPosition: Double(i) / 100) + let domain = e.chartXDomain(for: stop) + let fraction = stop.supply.doubleValue / domain.upperBound + #expect(domain.lowerBound == 0) + #expect(fraction <= 1 - MarketCapExplainer.chartEdgeMargin + 1e-9) + } + } + + // MARK: - Extended stops + + private func explainer(reserve: Int) throws -> MarketCapExplainer { + let supply = DiscreteBondingCurve().preciseSupplyFromValue(BigDecimal(reserve)).asDecimal() + let quarks = UInt64(NSDecimalNumber(decimal: (supply * Foundation.Decimal(Self.quarksPerToken)).rounded(to: 0)).doubleValue) + return try #require(MarketCapExplainer(todaySupplyQuarks: quarks, heldQuarks: nil)) + } + + @Test("Stops extend ×10 while the top stop is within 2× Today's reserve", arguments: [ + (22_700, [5_000, 100_000, 1_000_000, 10_000_000]), + (171_000, [5_000, 100_000, 1_000_000, 10_000_000]), + (6_000_000, [5_000, 100_000, 1_000_000, 10_000_000, 100_000_000]), + (10_000_000, [5_000, 100_000, 1_000_000, 10_000_000, 100_000_000]), + (60_000_000, [5_000, 100_000, 1_000_000, 10_000_000, 100_000_000, 1_000_000_000]), + ] as [(Int, [Int])]) + func extendedStops(reserve: Int, fixed: [Int]) throws { + let e = try explainer(reserve: reserve) + let expected = Set(fixed.map { Foundation.Decimal($0) }) + let fixedStops = e.stops.filter { !$0.isToday } + #expect(Set(fixedStops.map(\.reserve)).isSubset(of: expected)) + // Today replaces a fixed stop only when it lands on it. + #expect(expected.count - fixedStops.count <= 1) + #expect(e.stops.last?.reserve == Foundation.Decimal(fixed.last!)) + let hasToday = e.stops.contains { $0.isToday } + #expect(hasToday) + #expect(e.trackPosition(of: e.todayStop) < 1) + let reserves = e.stops.map(\.reserve) + #expect(reserves == reserves.sorted()) + } + + @Test("Stops never pass the reserve at max supply") + func stopsAreCapped() { + let reserves = MarketCapExplainer.stopReserves( + todayReserve: 900_000_000_000, + maxReserve: 1_139_973_004_315 + ) + #expect(reserves.last == 1_000_000_000_000) + let capped = MarketCapExplainer.stopReserves(todayReserve: 900_000_000_000, maxReserve: 500_000_000_000) + #expect(capped.allSatisfy { $0 <= 500_000_000_000 }) + } + + @Test("At $60M the selection stays inside the chart at every slider position") + func selectionInChartAtSixtyMillion() throws { + let e = try explainer(reserve: 60_000_000) + for i in 0...100 { + let stop = e.stop(atTrackPosition: Double(i) / 100) + let domain = e.chartXDomain(for: stop) + #expect(stop.supply.doubleValue / domain.upperBound <= 1 - MarketCapExplainer.chartEdgeMargin + 1e-9) + } + } + + // MARK: - Ownership + + @Test("Ownership rows from held quarks") + func ownership() { + let ownership = explainer.ownership + #expect(ownership.tokens == 12_400) + #expect(ownership.shareOfCirculating == Foundation.Decimal(12_400) / 1_250_000) + #expect(ownership.shareOfMax == Foundation.Decimal(12_400) / 21_000_000) + } + + @Test("Without holdings the dependent values are nil and nothing is worth anything") + func noHoldings() { + let none = MarketCapExplainer(todaySupplyQuarks: 1_250_000 * Self.quarksPerToken, heldQuarks: nil)! + #expect(none.ownership.tokens == nil) + #expect(none.ownership.shareOfCirculating == nil) + #expect(none.worth(at: none.todayStop) == nil) + } +} From 160eda0cf464a8701d73fdd01c2a2c77c358b85c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 20:25:32 -0400 Subject: [PATCH 2/5] feat(currency-info): add How Market Cap Works explainer Opens from the Market Cap header on the currency info chart and from a new row under the range chips (spec section 10776:1757). A slider over reserve shows what the balance would be worth, springing back to Today on release, with the curve redrawn from the slider position on every frame. --- .../Navigation/AppRouter+Destination.swift | 6 +- .../AppRouter+DestinationView.swift | 7 + .../Currency Info/CurrencyInfoContentV2.swift | 16 +- .../CurrencyInfoMarketCapSection.swift | 12 +- .../Currency Info/CurrencyInfoScreen.swift | 1 + .../MarketCapCurveChart.swift | 138 +++++++++++++ .../MarketCapExplainerPalette.swift | 15 ++ .../MarketCapExplainerRow.swift | 72 +++++++ .../MarketCapExplainerScreen.swift | 181 ++++++++++++++++++ .../MarketCapExplainerViewModel.swift | 141 ++++++++++++++ .../MarketCapSlider.swift | 169 ++++++++++++++++ .../FlipcashUI/Views/Chart/StockChart.swift | 31 ++- 12 files changed, 775 insertions(+), 14 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift create mode 100644 Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerPalette.swift create mode 100644 Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerRow.swift create mode 100644 Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerScreen.swift create mode 100644 Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerViewModel.swift create mode 100644 Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index 355949997..911e5f1ee 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -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 @@ -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 @@ -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" @@ -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): diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 0e118ec5b..cbbe886d1 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -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() diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index 331e8d32c..31fb5e7ac 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -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 @@ -151,12 +153,20 @@ 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 + isReady: !defersHeavyContent, + onShowExplainer: onShowMarketCapExplainer ) + + MarketCapExplainerRow(action: onShowMarketCapExplainer) + .padding(.horizontal, 20) + } } CurrencyInfoAboutSection( @@ -265,9 +275,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, diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift index 95dc01469..bb9623bec 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift @@ -23,25 +23,23 @@ struct CurrencyInfoMarketCapSection: View { /// layout. Drawing an actual populated Swift Charts plot is the expensive /// part, and the host holds that back until the opening animation is done. var isReady: Bool = true + /// The "Market Cap" label, value and change pill open the explainer. + let onShowExplainer: () -> Void 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", + onHeaderTap: onShowExplainer ) } } .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 diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 07c65b23e..312dec741 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -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)) { diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift new file mode 100644 index 000000000..b9fbdb28d --- /dev/null +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift @@ -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. + private struct Frame { + let selection: MarketCapExplainer.Stop + let today: MarketCapExplainer.Stop + let points: [MarketCapExplainer.CurvePoint] + let xDomain: ClosedRange + let yRange: ClosedRange + 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: [MarketCapExplainerPalette.green.opacity(0.35), MarketCapExplainerPalette.green.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + ) + LineMark( + x: .value("Supply", point.supply), + y: .value("Price", point.price), + series: .value("Series", "solid") + ) + .foregroundStyle(MarketCapExplainerPalette.green) + .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(MarketCapExplainerPalette.green.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(MarketCapExplainerPalette.green) + } + .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) + } +} diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerPalette.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerPalette.swift new file mode 100644 index 000000000..963abf5ce --- /dev/null +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerPalette.swift @@ -0,0 +1,15 @@ +// +// MarketCapExplainerPalette.swift +// Flipcash +// + +import SwiftUI + +/// Colors shared across the market cap explainer. +enum MarketCapExplainerPalette { + /// `#009F50`, the green of the curve, slider fill, icon and positive appreciation. + static let green = Color(red: 0, green: 159 / 255, blue: 80 / 255) + + /// `#FF8383`, negative appreciation. Matches Android's `errorText`. + static let red = Color(red: 1, green: 131 / 255, blue: 131 / 255) +} diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerRow.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerRow.swift new file mode 100644 index 000000000..e67016bcd --- /dev/null +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerRow.swift @@ -0,0 +1,72 @@ +// +// 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 + + private static let cardColor = Color(red: 48 / 255, green: 48 / 255, blue: 49 / 255) + private static let iconBackground = Color(red: 71 / 255, green: 71 / 255, blue: 72 / 255) + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + MarketCapCurveIcon() + .frame(width: 22, height: 22) + .frame(width: 40, height: 40) + .background(Circle().fill(Self.iconBackground)) + + 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(Self.cardColor) + .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(MarketCapExplainerPalette.green), + 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(MarketCapExplainerPalette.green) + ) + } + } +} diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerScreen.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerScreen.swift new file mode 100644 index 000000000..6092de3e8 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerScreen.swift @@ -0,0 +1,181 @@ +// +// MarketCapExplainerScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// "How Market Cap Works": drag the community's purchases along the curve and +/// see what the balance would be worth. +struct MarketCapExplainerScreen: View { + + @State private var viewModel: MarketCapExplainerViewModel + + init(mint: PublicKey, session: Session, ratesController: RatesController) { + _viewModel = State(initialValue: MarketCapExplainerViewModel( + mint: mint, + session: session, + ratesController: ratesController + )) + } + + var body: some View { + Background(color: .backgroundMain) { + switch viewModel.loadingState { + case .loading: + ProgressView() + .progressViewStyle(.circular) + .tint(.textSecondary) + case .unavailable: + Text("Unable to load") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + case .loaded(let name, let explainer): + MarketCapExplainerContent( + name: name, + explainer: explainer, + viewModel: viewModel + ) + } + } + .navigationBarTitleDisplayMode(.inline) + .task { await viewModel.load() } + } +} + +// MARK: - Content - + +struct MarketCapExplainerContent: View { + let name: String + let explainer: MarketCapExplainer + @Bindable var viewModel: MarketCapExplainerViewModel + + private var selection: MarketCapExplainer.Stop { + viewModel.selection ?? explainer.todayStop + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Text("The more people buy \(name), the more valuable \(name) becomes.") + .font(.appDisplaySmall) + .foregroundStyle(Color.textMain) + .accessibilityAddTraits(.isHeader) + + heroCard + + if explainer.heldQuarks != nil { + ownershipCard + } + } + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 40) + } + .scrollIndicators(.hidden) + } + + // MARK: - Hero + + private var heroCard: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 4) { + Text("Amount of \(name) purchased by the community") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + Text(viewModel.reserveText(selection.reserve)) + .font(.appDisplayCompact) + .foregroundStyle(Color.textMain) + .contentTransition(.numericText()) + .animation(.default, value: viewModel.reserveText(selection.reserve)) + } + + MarketCapCurveChart( + explainer: explainer, + position: viewModel.trackPosition, + reserveText: viewModel.reserveText + ) + + MarketCapSlider(position: $viewModel.trackPosition, ticks: ticks) + + if explainer.heldQuarks != nil { + VStack(alignment: .leading, spacing: 4) { + Text("Your \(name) balance would be worth") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + Text(viewModel.worthText(at: selection)) + .font(.appDisplayMedium) + .foregroundStyle(Color.textMain) + .contentTransition(.numericText()) + .animation(.default, value: viewModel.worthText(at: selection)) + } + } + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.backgroundRow) + .clipShape(RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous)) + } + + private var ticks: [MarketCapSlider.Tick] { + explainer.stops.map { stop in + MarketCapSlider.Tick( + id: stop.reserve, + position: explainer.trackPosition(of: stop), + label: stop.isToday + ? "Today" + : viewModel.reserveText(stop.reserve), + isToday: stop.isToday + ) + } + } + + // MARK: - Ownership + + private var ownershipCard: some View { + let ownership = explainer.ownership + return VStack(alignment: .leading, spacing: 0) { + Text("Your Ownership") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + .padding(.bottom, 12) + + row("Underlying tokens you own", viewModel.tokensText(ownership.tokens)) + row("Current price per token", viewModel.priceText(ownership.price)) + row("Share of circulating supply", viewModel.percentText(ownership.shareOfCirculating)) + row("Share of max supply", viewModel.percentText(ownership.shareOfMax)) + row( + "Your current appreciation", + viewModel.appreciationText, + valueColor: appreciationColor + ) + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.backgroundRow) + .clipShape(RoundedRectangle(cornerRadius: Metrics.buttonRadius, style: .continuous)) + } + + private var appreciationColor: Color { + switch viewModel.appreciationSign { + case .positive: MarketCapExplainerPalette.green + case .negative: MarketCapExplainerPalette.red + case .zero: Color.textMain + } + } + + private func row(_ title: String, _ value: String, valueColor: Color = .textMain) -> some View { + HStack { + Text(title) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + Spacer(minLength: 12) + Text(value) + .font(.appTextMedium) + .foregroundStyle(valueColor) + } + .padding(.vertical, 10) + } +} diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerViewModel.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerViewModel.swift new file mode 100644 index 000000000..25b73d0f4 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerViewModel.swift @@ -0,0 +1,141 @@ +// +// MarketCapExplainerViewModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashStore + +/// Drives "How Market Cap Works": loads the currency's supply and holdings, +/// builds the pure ``MarketCapExplainer`` calculator, and formats its output. +@Observable +final class MarketCapExplainerViewModel { + + enum LoadingState { + case loading + case loaded(name: String, MarketCapExplainer) + case unavailable + } + + private(set) var loadingState: LoadingState = .loading + + /// Slider position in 0...1 along the log-reserve track. + var trackPosition: Double = 0 { + didSet { selection = explainer?.stop(atTrackPosition: trackPosition) ?? selection } + } + + /// The point on the curve the slider is on. + private(set) var selection: MarketCapExplainer.Stop? + + @ObservationIgnored private let mint: PublicKey + @ObservationIgnored private let session: Session + @ObservationIgnored private let ratesController: RatesController + + init(mint: PublicKey, session: Session, ratesController: RatesController) { + self.mint = mint + self.session = session + self.ratesController = ratesController + } + + var explainer: MarketCapExplainer? { + if case .loaded(_, let explainer) = loadingState { return explainer } + return nil + } + + var name: String { + if case .loaded(let name, _) = loadingState { return name } + return "" + } + + func load() async { + guard case .loading = loadingState else { return } + do { + let metadata = try await session.fetchMintMetadata(mint: mint) + guard let supply = metadata.supplyFromBonding else { + loadingState = .unavailable + return + } + let held = session.balance(for: mint)?.quarks + guard let explainer = MarketCapExplainer( + todaySupplyQuarks: supply, + heldQuarks: (held ?? 0) > 0 ? held : nil + ) else { + loadingState = .unavailable + return + } + loadingState = .loaded(name: metadata.name, explainer) + let today = explainer.todayStop + selection = today + trackPosition = explainer.trackPosition(of: today) + } catch { + loadingState = .unavailable + } + } + + // MARK: - Formatting + + /// The user's preferred in-app currency, the same rate the balance and + /// appreciation pills use. The curve math stays in USD; only display converts. + private var displayRate: Rate { ratesController.rateForBalanceCurrency() } + + private func display(_ usd: Decimal) -> FiatAmount { + FiatAmount.usd(usd).converting(to: displayRate) + } + + /// A reserve, compact, in the display currency: `$22.7K`, `$10M`. + func reserveText(_ reserve: Decimal) -> String { + display(reserve).formattedAbbreviated() + } + + /// A per-token price: more decimals the smaller it is. + func priceText(_ price: Decimal) -> String { + let digits = price < Decimal(string: "0.1")! ? 5 : (price < 1 ? 4 : 2) + let converted = display(price) + return NumberFormatter.fiat(currency: converted.currency, minimumFractionDigits: 2, maximumFractionDigits: digits) + .string(from: converted.value as NSDecimalNumber) ?? "" + } + + func worthText(at stop: MarketCapExplainer.Stop) -> String { + guard let worth = explainer?.worth(at: stop) else { return "–" } + return display(worth).formatted() + } + + func percentText(_ fraction: Decimal?) -> String { + guard let fraction else { return "–" } + // A small non-zero share must not read as "0%". Exact zero still does. + if fraction > 0, fraction < Decimal(string: "0.0001")! { return "<0.01%" } + return fraction.formatted(.percent.precision(.fractionLength(0...2))) + } + + func tokensText(_ tokens: Decimal?) -> String { + guard let tokens else { return "–" } + return tokens.formatted(.number.precision(.fractionLength(0))) + } + + /// The existing balance-card pill text (`+$12.34`), in the display currency. + var appreciationText: String { + guard let balance = session.balance(for: mint) else { return "–" } + let (value, isPositive) = balance.computeAppreciation(with: ratesController.rateForBalanceCurrency()) + return value.nativeAmount.signedAppreciationText(isPositive: isPositive) + } + + enum AppreciationSign { case positive, negative, zero } + + /// Zero when the delta is too small to display, so it never reads as a gain or loss. + var appreciationSign: AppreciationSign { + guard let balance = session.balance(for: mint) else { return .zero } + let (value, isPositive) = balance.computeAppreciation(with: ratesController.rateForBalanceCurrency()) + guard value.nativeAmount.hasDisplayableValue else { return .zero } + return isPositive ? .positive : .negative + } +} + +extension FiatAmount { + /// The signed appreciation pill text. A sub-cent delta reads as positive so + /// it never renders "-$0.00". + func signedAppreciationText(isPositive: Bool) -> String { + let positive = isPositive || !hasDisplayableValue + return (positive ? "+" : "-") + formatted() + } +} diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift new file mode 100644 index 000000000..a267a3676 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift @@ -0,0 +1,169 @@ +// +// MarketCapSlider.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// A continuous slider over the log-reserve track with labeled tick marks. +/// Ticks are reference marks, not snap points. +struct MarketCapSlider: View { + @Binding var position: Double + let ticks: [Tick] + + struct Tick: Identifiable { + let id: Decimal + let position: Double + let label: String? + var isToday: Bool = false + } + + // Slider v2 (node 10761:2119): 10pt track, thumb centered on it, + // ticks 5pt below the track, labels under the ticks. + private let thumbHeight: CGFloat = 24 + private let trackHeight: CGFloat = 10 + private let trackTop: CGFloat = 15 + private let areaHeight: CGFloat = 60 + + private static let trackColor = Color(red: 71 / 255, green: 71 / 255, blue: 72 / 255) + private static let fillColor = MarketCapExplainerPalette.green + private static let labelGap: CGFloat = 6 + + /// True while a finger is down; drives the thumb's pressed state. + @GestureState private var isPressed = false + + /// Measured width of each label, keyed by tick. + @State private var labelWidths: [Decimal: CGFloat] = [:] + + var body: some View { + GeometryReader { proxy in + let width = max(proxy.size.width, 1) + ZStack(alignment: .topLeading) { + Capsule() + .fill(Self.trackColor) + .frame(width: width, height: trackHeight) + .offset(y: trackTop) + + Capsule() + .fill(Self.fillColor) + .frame(width: width * position, height: trackHeight) + .offset(y: trackTop) + + let labels = labelLayout(width: width) + ForEach(ticks) { tick in + tickView(tick) + .position( + x: width * tick.position, + y: (tick.isToday ? 28 : 30) + (tick.isToday ? 10 : 6) / 2 + ) + if let label = tick.label { + labelText(label, tick: tick) + .hidden() + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { measured in + if labelWidths[tick.id] != measured { labelWidths[tick.id] = measured } + } + .position(x: 0, y: 44 + 7) + if let x = labels[tick.id] { + labelText(label, tick: tick) + .position(x: x, y: 44 + 7) + } + } + } + + thumb + .position(x: width * position, y: trackTop + trackHeight / 2) + } + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .updating($isPressed) { _, pressed, _ in pressed = true } + .onChanged { drag in + position = min(max(drag.location.x / width, 0), 1) + } + .onEnded { _ in + guard let today = ticks.first(where: \.isToday) else { return } + withAnimation(.spring(duration: 0.4)) { position = today.position } + } + ) + } + .frame(height: areaHeight) + .sensoryFeedback(.impact(weight: .light), trigger: crossedTickCount) + .accessibilityElement() + .accessibilityLabel("Amount purchased by the community") + .accessibilityValue("\(Int(position * 100)) percent") + .accessibilityAdjustableAction { direction in + switch direction { + case .increment: position = min(position + 0.05, 1) + case .decrement: position = max(position - 0.05, 0) + @unknown default: break + } + } + } + + /// Mirrors the system slider thumb: on iOS 26 a white capsule that turns to + /// Liquid Glass and grows while pressed, before that the classic 28pt disc. + @ViewBuilder + private var thumb: some View { + if #available(iOS 26, *) { + ZStack { + if isPressed { + Capsule().fill(Color.clear) + .glassEffect(.regular.interactive(), in: .capsule) + } else { + Capsule().fill(Color.white) + .shadow(color: .black.opacity(0.2), radius: 4, y: 2) + } + } + .frame(width: 38, height: thumbHeight) + .scaleEffect(isPressed ? 1.5 : 1) + .animation(.spring(duration: 0.3, bounce: 0.3), value: isPressed) + } else { + Circle().fill(Color.white) + .frame(width: 28, height: 28) + .shadow(color: .black.opacity(0.25), radius: 3, y: 2) + } + } + + private func labelText(_ label: String, tick: Tick) -> some View { + Text(label) + .font(.appTextCaption) + .fontWeight(tick.isToday ? .bold : .medium) + .foregroundStyle(tick.isToday ? Color.textMain : Color.textSecondary) + .fixedSize() + } + + /// Center x for each label that gets drawn, from measured widths. Labels are + /// clamped inside the track, Today always draws, and a fixed label whose frame + /// would come within `labelGap` of one already drawn is dropped (its tick stays). + private func labelLayout(width: CGFloat) -> [Decimal: CGFloat] { + func frame(_ tick: Tick) -> (center: CGFloat, half: CGFloat)? { + guard let measured = labelWidths[tick.id] else { return nil } + let half = measured / 2 + return (min(max(width * tick.position, half), max(width - half, half)), half) + } + var drawn: [(center: CGFloat, half: CGFloat)] = [] + var result: [Decimal: CGFloat] = [:] + let ordered = ticks.filter { $0.label != nil }.sorted { $0.isToday && !$1.isToday } + for tick in ordered { + guard let f = frame(tick) else { continue } + let collides = drawn.contains { abs($0.center - f.center) < $0.half + f.half + Self.labelGap } + if collides && !tick.isToday { continue } + drawn.append(f) + result[tick.id] = f.center + } + return result + } + + /// Changes each time the thumb crosses a tick, which fires the haptic. + private var crossedTickCount: Int { + ticks.filter { $0.position < position }.count + } + + private func tickView(_ tick: Tick) -> some View { + RoundedRectangle(cornerRadius: 1) + .fill(tick.isToday ? Color.textMain : Color.textSecondary.opacity(0.6)) + .frame(width: 2, height: tick.isToday ? 10 : 6) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Chart/StockChart.swift b/FlipcashUI/Sources/FlipcashUI/Views/Chart/StockChart.swift index 65e4e5bc1..c66938bb1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Chart/StockChart.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Chart/StockChart.swift @@ -10,6 +10,8 @@ public struct StockChart: View { private let negativeColor: Color private let valueFormatter: NumberFormatter private let dateFormatter: (Date) -> String + private let headerTitle: String? + private let onHeaderTap: (() -> Void)? /// Creates a stock chart with externally-provided data via a view model /// - Parameters: @@ -17,13 +19,19 @@ public struct StockChart: View { /// - positiveColor: The chart line color on positive values /// - negativeColor: The chart line color on negative values /// - dateFormatter: Optional custom formatter for displaying dates + /// - headerTitle: A label drawn above the value + /// - onHeaderTap: Makes the label, value and change pill one tappable group; the plot and range picker are unaffected public init( viewModel: ChartViewModel, currencyCode: CurrencyCode, positiveColor: Color = .green, negativeColor: Color = .red, - dateFormatter: ((Date) -> String)? = nil + dateFormatter: ((Date) -> String)? = nil, + headerTitle: String? = nil, + onHeaderTap: (() -> Void)? = nil ) { + self.headerTitle = headerTitle + self.onHeaderTap = onHeaderTap self.viewModel = viewModel self.currencyCode = currencyCode self.positiveColor = positiveColor @@ -45,7 +53,7 @@ public struct StockChart: View { public var body: some View { VStack(spacing: 16) { - headerView + header .padding(.horizontal, 20) chartView @@ -68,8 +76,27 @@ public struct StockChart: View { // MARK: - Subviews + @ViewBuilder + private var header: some View { + if let onHeaderTap { + Button(action: onHeaderTap) { + headerView + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } else { + headerView + } + } + private var headerView: some View { VStack(alignment: .leading, spacing: 4) { + if let headerTitle { + Text(headerTitle) + .foregroundStyle(Color.textSecondary) + .font(.appTextMedium) + } + Text(valueFormatter.string(for: viewModel.displayValue) ?? "") .foregroundStyle(Color.textMain) .font(.appDisplayMedium) From 8eee97cd9b38ebf1573af5d77ebcf9436fb97841 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 20:41:26 -0400 Subject: [PATCH 3/5] test(currency-info): cover the market cap explainer view model, labels and chart frame --- .../MarketCapCurveChart.swift | 2 +- .../MarketCapSlider.swift | 27 +- .../Models/MarketCapAxisLabels.swift | 51 ++++ .../MarketCapAxisLabelsTests.swift | 101 ++++++++ .../MarketCapExplainerTests.swift | 14 + .../MarketCapCurveChartFrameTests.swift | 112 ++++++++ .../MarketCapExplainerViewModelTests.swift | 244 ++++++++++++++++++ 7 files changed, 530 insertions(+), 21 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/MarketCapAxisLabels.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/MarketCapAxisLabelsTests.swift create mode 100644 FlipcashTests/CurrencyInfo/MarketCapCurveChartFrameTests.swift create mode 100644 FlipcashTests/CurrencyInfo/MarketCapExplainerViewModelTests.swift diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift index b9fbdb28d..e032e3921 100644 --- a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift @@ -24,7 +24,7 @@ struct MarketCapCurveChart: View, Animatable { } /// Everything derived from one position, computed once per render. - private struct Frame { + struct Frame { let selection: MarketCapExplainer.Stop let today: MarketCapExplainer.Stop let points: [MarketCapExplainer.CurvePoint] diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift index a267a3676..26d3353aa 100644 --- a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift @@ -29,7 +29,6 @@ struct MarketCapSlider: View { private static let trackColor = Color(red: 71 / 255, green: 71 / 255, blue: 72 / 255) private static let fillColor = MarketCapExplainerPalette.green - private static let labelGap: CGFloat = 6 /// True while a finger is down; drives the thumb's pressed state. @GestureState private var isPressed = false @@ -134,26 +133,14 @@ struct MarketCapSlider: View { .fixedSize() } - /// Center x for each label that gets drawn, from measured widths. Labels are - /// clamped inside the track, Today always draws, and a fixed label whose frame - /// would come within `labelGap` of one already drawn is dropped (its tick stays). + /// Center x for each label that gets drawn; see ``MarketCapAxisLabels/layout(_:trackWidth:)``. private func labelLayout(width: CGFloat) -> [Decimal: CGFloat] { - func frame(_ tick: Tick) -> (center: CGFloat, half: CGFloat)? { - guard let measured = labelWidths[tick.id] else { return nil } - let half = measured / 2 - return (min(max(width * tick.position, half), max(width - half, half)), half) - } - var drawn: [(center: CGFloat, half: CGFloat)] = [] - var result: [Decimal: CGFloat] = [:] - let ordered = ticks.filter { $0.label != nil }.sorted { $0.isToday && !$1.isToday } - for tick in ordered { - guard let f = frame(tick) else { continue } - let collides = drawn.contains { abs($0.center - f.center) < $0.half + f.half + Self.labelGap } - if collides && !tick.isToday { continue } - drawn.append(f) - result[tick.id] = f.center - } - return result + MarketCapAxisLabels.layout( + ticks.filter { $0.label != nil }.map { + .init(id: $0.id, position: $0.position, width: labelWidths[$0.id], isToday: $0.isToday) + }, + trackWidth: width + ) } /// Changes each time the thumb crosses a tick, which fires the haptic. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/MarketCapAxisLabels.swift b/FlipcashCore/Sources/FlipcashCore/Models/MarketCapAxisLabels.swift new file mode 100644 index 000000000..a5b01e5c6 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/MarketCapAxisLabels.swift @@ -0,0 +1,51 @@ +// +// MarketCapAxisLabels.swift +// FlipcashCore +// + +import Foundation + +/// Which axis labels the Market Cap slider draws, and where. +public enum MarketCapAxisLabels { + + /// The minimum clear space between two drawn labels. + public static let labelGap: CGFloat = 6 + + /// A labeled tick, with its label's measured width (nil until measured). + public struct Item: Equatable { + public let id: Foundation.Decimal + /// The tick's position along the track, 0...1. + public let position: Double + public let width: CGFloat? + public let isToday: Bool + + public init(id: Foundation.Decimal, position: Double, width: CGFloat?, isToday: Bool) { + self.id = id + self.position = position + self.width = width + self.isToday = isToday + } + } + + /// Center x for each label that gets drawn, from measured widths. Labels are + /// clamped inside the track, Today always draws, and a fixed label whose frame + /// would come within ``labelGap`` of one already drawn is dropped (its tick stays). + public static func layout(_ items: [Item], trackWidth: CGFloat) -> [Foundation.Decimal: CGFloat] { + func frame(_ item: Item) -> (center: CGFloat, half: CGFloat)? { + guard let measured = item.width else { return nil } + let half = measured / 2 + return (min(max(trackWidth * item.position, half), max(trackWidth - half, half)), half) + } + var drawn: [(center: CGFloat, half: CGFloat)] = [] + var result: [Foundation.Decimal: CGFloat] = [:] + let ordered = items.sorted { $0.isToday && !$1.isToday } + for item in ordered { + guard let f = frame(item) else { continue } + let collides = drawn.contains { abs($0.center - f.center) < $0.half + f.half + labelGap } + if collides && !item.isToday { continue } + drawn.append(f) + result[item.id] = f.center + } + return result + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MarketCapAxisLabelsTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MarketCapAxisLabelsTests.swift new file mode 100644 index 000000000..4e0ae5276 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/MarketCapAxisLabelsTests.swift @@ -0,0 +1,101 @@ +// +// MarketCapAxisLabelsTests.swift +// FlipcashCoreTests +// + +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("MarketCapAxisLabels") +struct MarketCapAxisLabelsTests { + + private typealias Item = MarketCapAxisLabels.Item + + private func item(_ id: Int, at position: Double, width: CGFloat? = 40, today: Bool = false) -> Item { + Item(id: Foundation.Decimal(id), position: position, width: width, isToday: today) + } + + private func id(_ value: Int) -> Foundation.Decimal { Foundation.Decimal(value) } + + @Test("Well-separated labels all draw, centered on their ticks") + func separatedLabelsDraw() { + let layout = MarketCapAxisLabels.layout( + [item(1, at: 0.2), item(2, at: 0.5, today: true), item(3, at: 0.8)], + trackWidth: 300 + ) + #expect(layout == [id(1): 60, id(2): 150, id(3): 240]) + } + + @Test("Labels are clamped to stay inside the track") + func clampedInsideTrack() { + let layout = MarketCapAxisLabels.layout( + [item(1, at: 0, width: 50), item(2, at: 1, width: 50)], + trackWidth: 300 + ) + #expect(layout[id(1)] == 25) + #expect(layout[id(2)] == 275) + } + + @Test("A label wider than the track is centered at half its width, never negative") + func widerThanTrack() { + let layout = MarketCapAxisLabels.layout([item(1, at: 0.5, width: 80)], trackWidth: 60) + #expect(layout[id(1)] == 40) + } + + @Test("Today always draws, and a fixed label colliding with it is the one dropped") + func todayWinsCollision() { + // Fixed label listed first, so only the Today-first ordering can save Today. + let layout = MarketCapAxisLabels.layout( + [item(1, at: 0.5), item(2, at: 0.52, today: true)], + trackWidth: 300 + ) + #expect(layout[id(2)] != nil) + #expect(layout[id(1)] == nil) + } + + @Test("A fixed label within the gap of a drawn one is dropped; exactly the gap away is kept") + func gapBoundary() { + // Two 40pt labels: centers must be at least 40 + 6 = 46pt apart. + let tooClose = MarketCapAxisLabels.layout( + [item(1, at: 0.5, today: true), item(2, at: (150 + 45.9) / 300)], + trackWidth: 300 + ) + #expect(tooClose[id(2)] == nil) + + let farEnough = MarketCapAxisLabels.layout( + [item(1, at: 0.5, today: true), item(2, at: (150 + 46) / 300)], + trackWidth: 300 + ) + #expect(farEnough[id(2)] != nil) + } + + @Test("A dropped label does not block the next one it clears") + func droppedLabelDoesNotBlock() { + let layout = MarketCapAxisLabels.layout( + [item(1, at: 0.5, today: true), item(2, at: 0.55), item(3, at: 0.8)], + trackWidth: 300 + ) + #expect(layout[id(2)] == nil) + #expect(layout[id(3)] == 240) + } + + @Test("Labels not yet measured are skipped") + func unmeasuredSkipped() { + let layout = MarketCapAxisLabels.layout( + [item(1, at: 0.2, width: nil), item(2, at: 0.8)], + trackWidth: 300 + ) + #expect(layout[id(1)] == nil) + #expect(layout[id(2)] == 240) + } + + @Test("Today is drawn even when the track is too narrow for any other label") + func todayAlwaysDrawn() { + let layout = MarketCapAxisLabels.layout( + [item(1, at: 0.0), item(2, at: 0.1, today: true), item(3, at: 0.2)], + trackWidth: 100 + ) + #expect(layout[id(2)] != nil) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MarketCapExplainerTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MarketCapExplainerTests.swift index 206401502..2205a3ed3 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MarketCapExplainerTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MarketCapExplainerTests.swift @@ -70,6 +70,20 @@ struct MarketCapExplainerTests { #expect(today > 0 && today < 1) } + @Test("A fixed stop within 1% of Today is replaced by Today") + func fixedStopNearTodayIsMerged() throws { + let fixed = explainer.stops.first { $0.reserve == 100_000 }! + // Supply 0.1% above the $100K stop puts Today within 1% of that reserve. + let quarks = UInt64(NSDecimalNumber(decimal: (fixed.supply * 1.001 * Foundation.Decimal(Self.quarksPerToken)).rounded(to: 0)).uint64Value) + let near = try #require(MarketCapExplainer(todaySupplyQuarks: quarks, heldQuarks: nil)) + + let today = near.todayStop + #expect(abs(today.reserve - 100_000) <= 1_000) + #expect(near.stops.filter { abs($0.reserve - today.reserve) <= today.reserve / 100 }.count == 1) + #expect(!near.stops.contains { $0.reserve == 100_000 }) + #expect(near.stops.count == 4) + } + @Test("A Today above $10M gets a higher stop to its right") func todayBeyondFixedRange() { let big = MarketCapExplainer(todaySupplyQuarks: 8_000_000 * Self.quarksPerToken, heldQuarks: nil)! diff --git a/FlipcashTests/CurrencyInfo/MarketCapCurveChartFrameTests.swift b/FlipcashTests/CurrencyInfo/MarketCapCurveChartFrameTests.swift new file mode 100644 index 000000000..20d04987d --- /dev/null +++ b/FlipcashTests/CurrencyInfo/MarketCapCurveChartFrameTests.swift @@ -0,0 +1,112 @@ +// +// MarketCapCurveChartFrameTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("MarketCapCurveChart.Frame") +struct MarketCapCurveChartFrameTests { + + private static let quarksPerToken: UInt64 = 10_000_000_000 + + private let explainer = MarketCapExplainer( + todaySupplyQuarks: 1_250_000 * quarksPerToken, + heldQuarks: 12_400 * quarksPerToken + )! + + private func frame(at position: Double) -> MarketCapCurveChart.Frame { + MarketCapCurveChart.Frame(explainer: explainer, position: position, reserveText: { "R\($0)" }) + } + + private static let positions: [Double] = [0, 0.1, 0.25, 0.4, 0.5, 0.6, 0.75, 0.9, 1] + + @Test("The frame selects the stop at the slider position and always carries Today") + func selectionAndToday() { + for position in Self.positions { + let f = frame(at: position) + #expect(f.selection == explainer.stop(atTrackPosition: position)) + #expect(f.today == explainer.todayStop) + #expect(f.selectedX == f.selection.supply.doubleValue) + #expect(f.selectedY == f.selection.price.doubleValue) + #expect(f.todayX == explainer.todayStop.supply.doubleValue) + #expect(f.todayY == explainer.todayStop.price.doubleValue) + } + } + + @Test("The domain covers the selection at every position") + func domainCoversSelection() { + for position in Self.positions { + let f = frame(at: position) + #expect(f.xDomain.contains(f.selectedX)) + #expect(f.yRange.contains(f.selectedY)) + } + } + + @Test("Today is inside the domain when the selection is at or past it, and may fall outside when far below") + func domainAndToday() { + let atToday = frame(at: explainer.trackPosition(of: explainer.todayStop)) + #expect(atToday.xDomain.contains(atToday.todayX)) + #expect(atToday.yRange.contains(atToday.todayY)) + + let top = frame(at: 1) + #expect(top.xDomain.contains(top.todayX)) + + // Far below Today, the right edge (selection + lookahead) is left of Today's supply. + let bottom = frame(at: 0) + #expect(!bottom.xDomain.contains(bottom.todayX)) + } + + @Test("The solid and faded halves of the curve meet at the selection") + func solidAndFadedMeetAtSelection() { + for position in Self.positions { + let f = frame(at: position) + let marker = MarketCapExplainer.CurvePoint(supply: f.selectedX, price: f.selectedY) + #expect(f.solid.last == marker) + #expect(f.faded.first == marker) + #expect(f.solid.dropLast().allSatisfy { $0.supply < f.selectedX }) + #expect(f.faded.dropFirst().allSatisfy { $0.supply > f.selectedX }) + } + } + + @Test("The sampled curve spans 0 to the chart's right edge, increasing in supply, at every position") + func curveSpansToTheEdge() { + for position in Self.positions { + let f = frame(at: position) + #expect(f.points.first?.supply == 0) + #expect(f.points.last?.supply == Double(explainer.chartSupplyMax(for: f.selection))) + #expect(zip(f.points, f.points.dropFirst()).allSatisfy { $0.supply < $1.supply }) + } + } + + @Test("The sample count only grows as the selection moves right, and is never empty") + func curvePointCountGrowsWithSelection() { + // The right edge is selection + a fixed lookahead, so the window slides + // right and the sample count tracks it; it never drops or collapses. + let counts = Self.positions.map { frame(at: $0).points.count } + #expect(counts.min()! > 0) + #expect(counts == counts.sorted()) + } + + @Test("Solid plus faded is the sampled curve with the selection marker joined to both halves") + func solidAndFadedCoverTheCurve() { + for position in Self.positions { + let f = frame(at: position) + let others = f.points.filter { $0.supply != f.selectedX }.count + #expect(f.solid.count + f.faded.count == others + 2) + } + } + + @Test("The selection label names Today only at Today") + func selectionLabel() { + let atToday = frame(at: explainer.trackPosition(of: explainer.todayStop)) + #expect(atToday.selectionLabel == "R\(explainer.todayStop.reserve) Today") + + let other = frame(at: 0.9) + #expect(other.selectionLabel == "R\(other.selection.reserve)") + } +} diff --git a/FlipcashTests/CurrencyInfo/MarketCapExplainerViewModelTests.swift b/FlipcashTests/CurrencyInfo/MarketCapExplainerViewModelTests.swift new file mode 100644 index 000000000..90925a7f3 --- /dev/null +++ b/FlipcashTests/CurrencyInfo/MarketCapExplainerViewModelTests.swift @@ -0,0 +1,244 @@ +// +// MarketCapExplainerViewModelTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@MainActor +@Suite("MarketCapExplainerViewModel") +struct MarketCapExplainerViewModelTests { + + private static let supply: UInt64 = 50_000 * 10_000_000_000 + private static let heldQuarks: UInt64 = 2_000 * 10_000_000_000 + private static let mint = PublicKey.jeffy + + /// The USDF the curve pays for `heldQuarks` at `supply`, which is what the + /// session's balance will carry as its value. + private static func heldValue() throws -> Double { + let stored = try StoredBalance( + quarks: heldQuarks, symbol: "TEST", name: "Test Token", + supplyFromBonding: supply, sellFeeBps: 0, mint: mint, + vmAuthority: nil, updatedAt: .now, imageURL: nil, costBasis: 0 + ) + return NSDecimalNumber(decimal: stored.usdf.value).doubleValue + } + + private func makeViewModel( + metadata: MintMetadata = .makeLaunchpad(address: mint, supplyFromBonding: supply), + heldQuarks: UInt64? = heldQuarks, + costBasis: Double = 0, + balanceCurrency: CurrencyCode = .usd, + rates: [Rate] = [] + ) throws -> MarketCapExplainerViewModel { + let database = Database.mock + try database.insert(mints: [metadata], date: .now) + if let heldQuarks { + try database.insertBalance(quarks: heldQuarks, mint: Self.mint, costBasis: costBasis, date: .now) + } + let ratesController = RatesController(container: .mock, database: database) + ratesController.configureTestRates(balanceCurrency: balanceCurrency, rates: rates) + let session = Session.makeMock(database: database, ratesController: ratesController) + return MarketCapExplainerViewModel(mint: Self.mint, session: session, ratesController: ratesController) + } + + private func loadedViewModel( + heldQuarks: UInt64? = heldQuarks, + costBasis: Double = 0, + balanceCurrency: CurrencyCode = .usd, + rates: [Rate] = [] + ) async throws -> MarketCapExplainerViewModel { + let viewModel = try makeViewModel( + heldQuarks: heldQuarks, costBasis: costBasis, + balanceCurrency: balanceCurrency, rates: rates + ) + await viewModel.load() + return viewModel + } + + // MARK: - Loading + + @Test("Before load there is no explainer and no selection") + func beforeLoad() throws { + let viewModel = try makeViewModel() + #expect(viewModel.explainer == nil) + #expect(viewModel.selection == nil) + #expect(viewModel.name == "") + } + + @Test("A mint with no bonding-curve supply is unavailable") + func noSupplyIsUnavailable() async throws { + let viewModel = try makeViewModel(metadata: .makeBasic(address: Self.mint)) + await viewModel.load() + #expect(viewModel.explainer == nil) + guard case .unavailable = viewModel.loadingState else { + Issue.record("expected .unavailable, got \(viewModel.loadingState)") + return + } + } + + // MARK: - Today + + @Test("After load the selection is Today and the slider sits on Today") + func loadSelectsToday() async throws { + let viewModel = try await loadedViewModel() + let explainer = try #require(viewModel.explainer) + + #expect(viewModel.name == "Test Token") + #expect(viewModel.selection == explainer.todayStop) + #expect(viewModel.selection?.isToday == true) + #expect(viewModel.trackPosition == explainer.trackPosition(of: explainer.todayStop)) + } + + @Test("Moving the slider moves the selection; releasing to Today's position returns it to Today") + func resetToToday() async throws { + let viewModel = try await loadedViewModel() + let explainer = try #require(viewModel.explainer) + let todayPosition = explainer.trackPosition(of: explainer.todayStop) + + viewModel.trackPosition = 0.95 + #expect(viewModel.selection?.isToday == false) + #expect(viewModel.selection == explainer.stop(atTrackPosition: 0.95)) + + viewModel.trackPosition = todayPosition + #expect(viewModel.selection == explainer.todayStop) + } + + // MARK: - Display currency + + @Test("USD display leaves values in dollars") + func usdDisplay() async throws { + let viewModel = try await loadedViewModel() + #expect(viewModel.reserveText(5_000) == "$5K") + #expect(viewModel.reserveText(10_000_000) == "$10M") + #expect(viewModel.priceText(Decimal(string: "0.02994")!) == "$0.02994") + #expect(viewModel.priceText(Decimal(string: "0.887")!) == "$0.887") + #expect(viewModel.priceText(8.78) == "$8.78") + } + + @Test("Reserve, price and worth convert to the balance currency; the curve math stays USD") + func convertsToBalanceCurrency() async throws { + let rate = Rate(fx: 2, currency: .cad) + let viewModel = try await loadedViewModel(balanceCurrency: .cad, rates: [rate]) + let explainer = try #require(viewModel.explainer) + let today = explainer.todayStop + + #expect(viewModel.reserveText(5_000) == FiatAmount(value: 10_000, currency: .cad).formattedAbbreviated()) + #expect(viewModel.reserveText(5_000) != FiatAmount.usd(5_000).formattedAbbreviated()) + + let worthUSD = try #require(explainer.worth(at: today)) + #expect(viewModel.worthText(at: today) == FiatAmount(value: worthUSD * 2, currency: .cad).formatted()) + + // Price 0.8 USD -> 1.6 CAD, four fractional digits max for a sub-$1 price. + let priceText = viewModel.priceText(Decimal(string: "0.8")!) + #expect(priceText == NumberFormatter.fiat(currency: .cad, minimumFractionDigits: 2, maximumFractionDigits: 4) + .string(from: NSDecimalNumber(string: "1.6"))) + + // The explainer itself never converts. + #expect(viewModel.selection == today) + #expect(explainer.todayStop.reserve == today.reserve) + } + + @Test("Without a cached rate the balance currency falls back to one-to-one") + func missingRateIsOneToOne() async throws { + let viewModel = try await loadedViewModel(balanceCurrency: .cad, rates: []) + #expect(viewModel.reserveText(5_000) == FiatAmount.usd(5_000).formattedAbbreviated()) + } + + @Test("Worth is a dash when holdings are unknown") + func worthWithoutHoldings() async throws { + let viewModel = try await loadedViewModel(heldQuarks: nil) + let today = try #require(viewModel.explainer?.todayStop) + #expect(viewModel.worthText(at: today) == "–") + } + + // MARK: - Appreciation + + @Test("A balance worth more than its cost basis is a positive appreciation") + func appreciationPositive() async throws { + let viewModel = try await loadedViewModel(costBasis: try Self.heldValue() - 5) + #expect(viewModel.appreciationSign == .positive) + #expect(viewModel.appreciationText == "+$5.00") + } + + @Test("A balance worth less than its cost basis is a negative appreciation") + func appreciationNegative() async throws { + let viewModel = try await loadedViewModel(costBasis: try Self.heldValue() + 5) + #expect(viewModel.appreciationSign == .negative) + #expect(viewModel.appreciationText == "-$5.00") + } + + @Test("A balance exactly at its cost basis is zero, rendered as a gain") + func appreciationZero() async throws { + let viewModel = try await loadedViewModel(costBasis: try Self.heldValue()) + #expect(viewModel.appreciationSign == .zero) + #expect(viewModel.appreciationText == "+$0.00") + } + + @Test("A sub-cent loss is zero, never a '-$0.00'") + func appreciationSubCentLoss() async throws { + let viewModel = try await loadedViewModel(costBasis: try Self.heldValue() + 0.001) + #expect(viewModel.appreciationSign == .zero) + #expect(viewModel.appreciationText == "+$0.00") + } + + @Test("With no balance the appreciation is a dash and zero") + func appreciationUnknownBalance() async throws { + let viewModel = try await loadedViewModel(heldQuarks: nil) + #expect(viewModel.appreciationSign == .zero) + #expect(viewModel.appreciationText == "–") + } + + @Test("A balance with no recorded cost basis counts its whole value as gain") + func appreciationUnknownCostBasis() async throws { + let viewModel = try await loadedViewModel(costBasis: 0) + #expect(viewModel.appreciationSign == .positive) + #expect(viewModel.appreciationText.hasPrefix("+")) + } + + @Test("Appreciation text converts to the balance currency") + func appreciationConverts() async throws { + let rate = Rate(fx: 2, currency: .cad) + let viewModel = try await loadedViewModel( + costBasis: try Self.heldValue() - 5, balanceCurrency: .cad, rates: [rate] + ) + #expect(viewModel.appreciationText == "+" + FiatAmount(value: 10, currency: .cad).formatted()) + } + + // MARK: - Share of supply + + @Test("A missing fraction is a dash") + func percentNil() throws { + #expect(try makeViewModel().percentText(nil) == "–") + } + + @Test("Exact zero reads 0%, not '<0.01%'") + func percentExactZero() throws { + #expect(try makeViewModel().percentText(0) == "0%") + } + + @Test("A non-zero share under 0.01% reads '<0.01%'", arguments: ["0.00001", "0.00005", "0.00009999"]) + func percentTiny(fraction: String) throws { + #expect(try makeViewModel().percentText(Decimal(string: fraction)!) == "<0.01%") + } + + @Test("At and above 0.01% the percent is shown, up to two decimals") + func percentShown() throws { + let viewModel = try makeViewModel() + #expect(viewModel.percentText(Decimal(string: "0.0001")!) == "0.01%") + #expect(viewModel.percentText(Decimal(string: "0.0123")!) == "1.23%") + #expect(viewModel.percentText(Decimal(string: "0.5")!) == "50%") + } + + @Test("Tokens format as whole numbers, a dash when unknown") + func tokens() throws { + let viewModel = try makeViewModel() + #expect(viewModel.tokensText(nil) == "–") + #expect(viewModel.tokensText(12_400) == "12,400") + #expect(viewModel.tokensText(Decimal(string: "12400.6")!) == "12,401") + } +} From f6a9c92b3439c1d56ad3dc9cae9f2371c774f66d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 1 Oct 2026 20:55:13 -0400 Subject: [PATCH 4/5] refactor(currency-info): use theme colors in the market cap explainer Replace the explainer's local palette and hex constants with existing tokens: Sentiment.positive, textError, backgroundRow and rowSeparator. --- .../MarketCapCurveChart.swift | 8 ++++---- .../MarketCapExplainerPalette.swift | 15 --------------- .../MarketCapExplainerRow.swift | 10 ++++------ .../MarketCapExplainerScreen.swift | 4 ++-- .../Market Cap Explainer/MarketCapSlider.swift | 5 ++--- 5 files changed, 12 insertions(+), 30 deletions(-) delete mode 100644 Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerPalette.swift diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift index e032e3921..7e7be36bc 100644 --- a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapCurveChart.swift @@ -72,7 +72,7 @@ struct MarketCapCurveChart: View, Animatable { ) .foregroundStyle( LinearGradient( - colors: [MarketCapExplainerPalette.green.opacity(0.35), MarketCapExplainerPalette.green.opacity(0)], + colors: [Color.Sentiment.positive.opacity(0.35), Color.Sentiment.positive.opacity(0)], startPoint: .top, endPoint: .bottom ) @@ -82,7 +82,7 @@ struct MarketCapCurveChart: View, Animatable { y: .value("Price", point.price), series: .value("Series", "solid") ) - .foregroundStyle(MarketCapExplainerPalette.green) + .foregroundStyle(Color.Sentiment.positive) .lineStyle(StrokeStyle(lineWidth: 2.5)) } @@ -92,7 +92,7 @@ struct MarketCapCurveChart: View, Animatable { y: .value("Price", point.price), series: .value("Series", "faded") ) - .foregroundStyle(MarketCapExplainerPalette.green.opacity(0.45)) + .foregroundStyle(Color.Sentiment.positive.opacity(0.45)) .lineStyle(StrokeStyle(lineWidth: 2, dash: [4, 4])) } @@ -124,7 +124,7 @@ struct MarketCapCurveChart: View, Animatable { PointMark(x: .value("Selected supply", f.selectedX), y: .value("Price", f.selectedY)) .symbolSize(180) - .foregroundStyle(MarketCapExplainerPalette.green) + .foregroundStyle(Color.Sentiment.positive) } .chartXScale(domain: f.xDomain) .chartYScale(domain: f.yRange) diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerPalette.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerPalette.swift deleted file mode 100644 index 963abf5ce..000000000 --- a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerPalette.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// MarketCapExplainerPalette.swift -// Flipcash -// - -import SwiftUI - -/// Colors shared across the market cap explainer. -enum MarketCapExplainerPalette { - /// `#009F50`, the green of the curve, slider fill, icon and positive appreciation. - static let green = Color(red: 0, green: 159 / 255, blue: 80 / 255) - - /// `#FF8383`, negative appreciation. Matches Android's `errorText`. - static let red = Color(red: 1, green: 131 / 255, blue: 131 / 255) -} diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerRow.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerRow.swift index e67016bcd..0df732c4e 100644 --- a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerRow.swift +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerRow.swift @@ -10,8 +10,6 @@ import FlipcashUI struct MarketCapExplainerRow: View { let action: () -> Void - private static let cardColor = Color(red: 48 / 255, green: 48 / 255, blue: 49 / 255) - private static let iconBackground = Color(red: 71 / 255, green: 71 / 255, blue: 72 / 255) var body: some View { Button(action: action) { @@ -19,7 +17,7 @@ struct MarketCapExplainerRow: View { MarketCapCurveIcon() .frame(width: 22, height: 22) .frame(width: 40, height: 40) - .background(Circle().fill(Self.iconBackground)) + .background(Circle().fill(Color.rowSeparator)) VStack(alignment: .leading, spacing: 2) { Text("How Market Cap Works") @@ -38,7 +36,7 @@ struct MarketCapExplainerRow: View { } .padding(14) .frame(maxWidth: .infinity) - .background(Self.cardColor) + .background(Color.backgroundRow) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } .buttonStyle(.plain) @@ -59,13 +57,13 @@ private struct MarketCapCurveIcon: View { ) context.stroke( line, - with: .color(MarketCapExplainerPalette.green), + 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(MarketCapExplainerPalette.green) + with: .color(Color.Sentiment.positive) ) } } diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerScreen.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerScreen.swift index 6092de3e8..62a4a8e56 100644 --- a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerScreen.swift +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapExplainerScreen.swift @@ -160,8 +160,8 @@ struct MarketCapExplainerContent: View { private var appreciationColor: Color { switch viewModel.appreciationSign { - case .positive: MarketCapExplainerPalette.green - case .negative: MarketCapExplainerPalette.red + case .positive: Color.Sentiment.positive + case .negative: Color.textError case .zero: Color.textMain } } diff --git a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift index 26d3353aa..fe5a8102f 100644 --- a/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift +++ b/Flipcash/Core/Screens/Main/Market Cap Explainer/MarketCapSlider.swift @@ -27,8 +27,7 @@ struct MarketCapSlider: View { private let trackTop: CGFloat = 15 private let areaHeight: CGFloat = 60 - private static let trackColor = Color(red: 71 / 255, green: 71 / 255, blue: 72 / 255) - private static let fillColor = MarketCapExplainerPalette.green + private static let fillColor = Color.Sentiment.positive /// True while a finger is down; drives the thumb's pressed state. @GestureState private var isPressed = false @@ -41,7 +40,7 @@ struct MarketCapSlider: View { let width = max(proxy.size.width, 1) ZStack(alignment: .topLeading) { Capsule() - .fill(Self.trackColor) + .fill(Color.rowSeparator) .frame(width: width, height: trackHeight) .offset(y: trackTop) From 6274bcb9d2c160e75f9ea9e9510afa410a362247 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 2 Oct 2026 08:46:53 -0400 Subject: [PATCH 5/5] fix(currency-info): drop the market cap header tap --- .../Currency Info/CurrencyInfoContentV2.swift | 3 +-- .../CurrencyInfoMarketCapSection.swift | 5 +---- .../FlipcashUI/Views/Chart/StockChart.swift | 21 ++----------------- 3 files changed, 4 insertions(+), 25 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index 31fb5e7ac..a3cc33485 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -160,8 +160,7 @@ struct CurrencyInfoContentV2: View { marketCap: viewModel.marketCap, currencyCode: ratesController.balanceCurrency, marketCapController: marketCapController, - isReady: !defersHeavyContent, - onShowExplainer: onShowMarketCapExplainer + isReady: !defersHeavyContent ) MarketCapExplainerRow(action: onShowMarketCapExplainer) diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift index bb9623bec..7b086fb1c 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift @@ -23,8 +23,6 @@ struct CurrencyInfoMarketCapSection: View { /// layout. Drawing an actual populated Swift Charts plot is the expensive /// part, and the host holds that back until the opening animation is done. var isReady: Bool = true - /// The "Market Cap" label, value and change pill open the explainer. - let onShowExplainer: () -> Void var body: some View { VStack(alignment: .leading) { @@ -34,8 +32,7 @@ struct CurrencyInfoMarketCapSection: View { currencyCode: currencyCode, positiveColor: .Sentiment.positive, negativeColor: .Sentiment.negative, - headerTitle: "Market Cap", - onHeaderTap: onShowExplainer + headerTitle: "Market Cap" ) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Chart/StockChart.swift b/FlipcashUI/Sources/FlipcashUI/Views/Chart/StockChart.swift index c66938bb1..66372d587 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Chart/StockChart.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Chart/StockChart.swift @@ -11,7 +11,6 @@ public struct StockChart: View { private let valueFormatter: NumberFormatter private let dateFormatter: (Date) -> String private let headerTitle: String? - private let onHeaderTap: (() -> Void)? /// Creates a stock chart with externally-provided data via a view model /// - Parameters: @@ -20,18 +19,15 @@ public struct StockChart: View { /// - negativeColor: The chart line color on negative values /// - dateFormatter: Optional custom formatter for displaying dates /// - headerTitle: A label drawn above the value - /// - onHeaderTap: Makes the label, value and change pill one tappable group; the plot and range picker are unaffected public init( viewModel: ChartViewModel, currencyCode: CurrencyCode, positiveColor: Color = .green, negativeColor: Color = .red, dateFormatter: ((Date) -> String)? = nil, - headerTitle: String? = nil, - onHeaderTap: (() -> Void)? = nil + headerTitle: String? = nil ) { self.headerTitle = headerTitle - self.onHeaderTap = onHeaderTap self.viewModel = viewModel self.currencyCode = currencyCode self.positiveColor = positiveColor @@ -53,7 +49,7 @@ public struct StockChart: View { public var body: some View { VStack(spacing: 16) { - header + headerView .padding(.horizontal, 20) chartView @@ -76,19 +72,6 @@ public struct StockChart: View { // MARK: - Subviews - @ViewBuilder - private var header: some View { - if let onHeaderTap { - Button(action: onHeaderTap) { - headerView - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } else { - headerView - } - } - private var headerView: some View { VStack(alignment: .leading, spacing: 4) { if let headerTitle {