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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@ private struct SoftTopScrollEdge: ViewModifier {
}
}

/// Marks the hero card as the morph destination for the wallet's tapped card.
/// Without a namespace (pushed hosting, deep links) the card renders plainly.
private struct HeroCardMatch: ViewModifier {
let mint: PublicKey
let namespace: Namespace.ID?

func body(content: Content) -> some View {
if let namespace {
content.matchedGeometryEffect(id: mint, in: namespace)
} else {
content
}
}
}

struct CurrencyInfoContentV2: View {
let metadata: StoredMintMetadata
let decodedMetadata: MintMetadata
Expand All @@ -42,6 +57,19 @@ struct CurrencyInfoContentV2: View {
/// 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
/// False when the host already shows the card (the wallet keeps it on screen
/// while the detail rises beneath it), so it is not drawn twice.
var showsHeroCard: Bool = true
/// Displaces the hero card from its resting slot while the host animates the
/// screen in — the wallet starts it over the deck and drives it to zero.
var heroOffset: CGFloat = 0
/// Everything except the hero card. The card is the one element carried over
/// from the wallet, so it stays at full strength while the screen behind it
/// arrives.
var contentOpacity: Double = 1
/// True while the page is animating in: the chart build and the activity
/// read are held back so they cannot stutter the transition.
var defersHeavyContent: Bool = false

private static let recentPreviewCount = 3
/// Scroll distance that puts the hero card's title row behind the toolbar.
Expand All @@ -50,18 +78,46 @@ struct CurrencyInfoContentV2: View {
private var isUSDF: Bool { metadata.mint == .usdf }
private var isOwned: Bool { viewModel.balance.hasDisplayableValue }


/// Shared with the wallet card stack when hosted as an overlay, so the
/// tapped card morphs into the hero card above.
@Environment(\.walletCardNamespace) private var cardNamespace

var body: some View {
ScrollView {
// Horizontal insets are applied per section rather than to the whole
// stack: the market-cap chart bleeds edge to edge, and cancelling an
// outer inset with negative padding makes that row wider than the
// viewport, which turns the scroll view horizontally scrollable.
VStack(spacing: 24) {
heroCard
.padding(.horizontal, 20)
actionTiles
// The slot is always reserved: while the wallet's card is still
// in the air this is empty, but dropping it from the layout
// shifts everything below up and then jumps it back down when
// the card lands.
Group {
if showsHeroCard {
heroCard
} else {
Color.clear.frame(height: WalletCardGeometry.cardHeight)
}
}
.padding(.horizontal, 20)
// Deliberately outside the fade below: this is the card the
// wallet was already showing, so it is continuous rather than
// something that arrives.
.offset(y: heroOffset)

Group {
actionTiles
.padding(.horizontal, 20)

// Everything below is laid out and shown from the first frame,
// so the page arrives complete rather than in stages. Nothing
// here is gated on loading: the activity read is quick and runs
// off the main thread, and the market-cap section reserves its
// full height around a placeholder plot. Revealing these later —
// even all at once — reads as the tiles landing and the rest of
// the screen following a beat behind.
if isOwned && !viewModel.recentActivities.isEmpty {
recentSection
.padding(.horizontal, 20)
Expand All @@ -74,7 +130,8 @@ struct CurrencyInfoContentV2: View {
CurrencyInfoMarketCapSection(
marketCap: viewModel.marketCap,
currencyCode: ratesController.balanceCurrency,
marketCapController: marketCapController
marketCapController: marketCapController,
isReady: !defersHeavyContent
)
}

Expand All @@ -92,6 +149,8 @@ struct CurrencyInfoContentV2: View {
.padding(.top, 8)
.padding(.horizontal, 20)
}
}
.opacity(contentOpacity)
}
.padding(.top, 8)
.padding(.bottom, 40)
Expand All @@ -104,6 +163,10 @@ struct CurrencyInfoContentV2: View {
} action: { _, scrolledPast in
onScrolledPastTitle(scrolledPast)
}
// Started immediately rather than after the transition: the read itself
// runs off the main thread, so it costs the animation nothing, and it
// lands well before the page finishes fading in — which is what keeps
// Recent from arriving as a second stage.
.task {
await viewModel.loadRecentActivities(limit: Self.recentPreviewCount)
}
Expand All @@ -113,6 +176,9 @@ struct CurrencyInfoContentV2: View {

private var heroCard: some View {
TokenCardView(data: heroData, height: 224)
// Destination of the wallet card's morph when hosted as an overlay,
// so the tapped card becomes this one rather than cross-fading.
.modifier(HeroCardMatch(mint: metadata.mint, namespace: cardNamespace))
}

private var heroData: TokenCardData {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,18 @@ import FlipcashCore

struct CurrencyInfoMarketCapSection: View {
@State private var chartViewModel: ChartViewModel?
/// Points that arrived before the host was ready to draw them.
@State private var pendingPoints: [ChartDataPoint]?

let marketCap: FiatAmount
let currencyCode: CurrencyCode
let marketCapController: MarketCapController
/// Gates the chart's *data* only. The section itself — its value, the
/// all-time change, and the range picker — renders immediately around a
/// reserved 200pt plot area, so the page arrives complete and in its final
/// 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

var body: some View {
VStack(alignment: .leading) {
Expand All @@ -34,8 +42,22 @@ struct CurrencyInfoMarketCapSection: View {
}
.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
// animation nothing, and starting it only once the transition had
// finished left the section sitting visibly empty afterwards while it
// ran. `isReady` gates the *drawing* of the result instead.
.task {
guard chartViewModel == nil else { return }
setupChart()
await loadInitialChartData()
}
// Draw whatever the fetch already returned, now that it is safe to.
.task(id: isReady) {
guard isReady, let points = pendingPoints, let viewModel = chartViewModel else { return }
pendingPoints = nil
viewModel.setDataPoints(points, appendingCurrentValue: marketCap.doubleValue)
}
.onChange(of: marketCap) { _, newMarketCap in
// Live ticks only move the appended "current" point — history
Expand All @@ -49,12 +71,31 @@ struct CurrencyInfoMarketCapSection: View {
}
}

/// The opening fetch. Runs immediately, but holds its points back until the
/// host is ready — populating a Swift Charts plot is the expensive part of
/// this screen, and doing it mid-animation drops frames.
private func loadInitialChartData() async {
guard let viewModel = chartViewModel else { return }
do {
let points = try await marketCapController.fetchChartData(for: .all)
if isReady {
viewModel.setDataPoints(points, appendingCurrentValue: marketCap.doubleValue)
} else {
pendingPoints = points
}
} catch let error as ChartError {
viewModel.setError(error)
} catch {
viewModel.setError(.networkError)
}
}

private func setupChart() {
let viewModel = ChartViewModel(currentValue: marketCap.doubleValue, selectedRange: .all)
viewModel.setLoading()
chartViewModel = viewModel

updateRangeChangeCallback(for: viewModel)
loadChartData(for: .all, into: viewModel)
}

private func updateRangeChangeCallback(for viewModel: ChartViewModel) {
Expand Down
Loading