Skip to content
Open
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
@@ -0,0 +1,33 @@
//
// CurrencyInfoAboutSection.swift
// Flipcash
//

import SwiftUI
import FlipcashCore
import FlipcashUI

/// The "About" block on the currency info screen: an expand/collapse description
/// and, for community tokens, the social link chips. Reused across the legacy and
/// new-UI layouts so the copy + expand/collapse behaviour stays in one place.
struct CurrencyInfoAboutSection: View {
let description: String
let socialLinks: [SocialLink]

var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("About")
.font(.appTextLarge)
.foregroundStyle(Color.textMain)

ExpandableText(description)
.foregroundStyle(Color.textSecondary)
.font(.appTextSmall)

if !socialLinks.isEmpty {
CurrencyInfoSocialLinksSection(socialLinks: socialLinks)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
187 changes: 187 additions & 0 deletions Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
//
// CurrencyInfoContentV2.swift
// Flipcash
//
// The new-UI (BetaFlags.newUI) currency info layout: a hero bill card, inline
// Give / Convert / Withdraw (or Get) tiles, a per-token Recent preview, the
// reused market-cap chart, the About block, and a created-at footer. Gated
// behind the new UI; the legacy `LoadedContent` stays for the old shell.
//

import SwiftUI
import FlipcashCore
import FlipcashUI

/// Softens the top scroll edge on iOS 26+ so content fades under the toolbar
/// rather than being clipped by the system's hard edge line.
private struct SoftTopScrollEdge: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26.0, *) {
content.scrollEdgeEffectStyle(.soft, for: .top)
} else {
content
}
}
}

struct CurrencyInfoContentV2: View {
let metadata: StoredMintMetadata
let decodedMetadata: MintMetadata
let viewModel: CurrencyInfoViewModel
let ratesController: RatesController
let marketCapController: MarketCapController
let session: Session

/// Give — owned community tokens only.
let onGive: () -> Void
/// Convert / Get — routes to the buy flow for now.
let onBuy: () -> Void
/// Withdraw — the existing flow, pre-selected to this currency.
let onWithdraw: () -> Void
let onShowTransactionHistory: () -> 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

private static let recentPreviewCount = 3
/// Scroll distance that puts the hero card's title row behind the toolbar.
private static let titleHandoffOffset: CGFloat = 52

private var isUSDF: Bool { metadata.mint == .usdf }
private var isOwned: Bool { viewModel.balance.hasDisplayableValue }

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
.padding(.horizontal, 20)

if isOwned && !viewModel.recentActivities.isEmpty {
recentSection
.padding(.horizontal, 20)
}

// The market-cap value, monthly delta, chart, and range picker
// all live inside this reused, self-contained section, which
// manages its own insets.
if !isUSDF {
CurrencyInfoMarketCapSection(
marketCap: viewModel.marketCap,
currencyCode: ratesController.balanceCurrency,
marketCapController: marketCapController
)
}

CurrencyInfoAboutSection(
description: currencyDescription,
socialLinks: isUSDF ? [] : decodedMetadata.socialLinks
)
.padding(.horizontal, 20)

if !isUSDF, let createdAt = metadata.createdAt {
Text("Created \(createdAt.formatted(date: .abbreviated, time: .omitted))".uppercased())
.font(.appTextSmall)
.foregroundStyle(Color.textSecondary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 8)
.padding(.horizontal, 20)
}
}
.padding(.top, 8)
.padding(.bottom, 40)
}
// Content fades out under the toolbar instead of meeting the system's
// hard scroll-edge line.
.modifier(SoftTopScrollEdge())
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.contentOffset.y + geometry.contentInsets.top > Self.titleHandoffOffset
} action: { _, scrolledPast in
onScrolledPastTitle(scrolledPast)
}
.task {
await viewModel.loadRecentActivities(limit: Self.recentPreviewCount)
}
}

// MARK: - Hero

private var heroCard: some View {
TokenCardView(data: heroData, height: 224)
}

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()

return TokenCardData(
mint: metadata.mint,
name: metadata.name,
imageURL: metadata.imageURL,
balanceText: isOwned ? viewModel.balance.formatted() : "",
appreciationText: isOwned ? appreciationText : nil,
colors: session.billColors(for: metadata.mint),
isUSDF: isUSDF
)
}

// MARK: - Actions

@ViewBuilder private var actionTiles: some View {
HStack(spacing: 12) {
if isOwned {
// Give ships for community tokens only; USDF give comes later.
if !isUSDF {
actionTile("Give", icon: "banknote", action: onGive)
}
actionTile("Convert", icon: "arrow.up.arrow.down", action: onBuy)
actionTile("Withdraw", icon: "arrow.up", action: onWithdraw)
} else {
actionTile("Get", icon: "arrow.down", action: onBuy)
}
}
}

private func actionTile(_ title: String, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
VStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 22, weight: .regular))
.frame(height: 28)
Text(title)
.font(.appTextSmall)
}
.foregroundStyle(Color.textMain)
.frame(maxWidth: .infinity)
.frame(height: 88)
.background(Color.white.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous))
}
.buttonStyle(.plain)
}

// MARK: - Recent

private var recentSection: some View {
RecentActivitySection(
activities: viewModel.recentActivities,
onShowAll: onShowTransactionHistory
)
}

// MARK: - Copy

private var currencyDescription: String {
if isUSDF {
return "Dollars are a 1:1 USD stablecoin managed by Coinbase."
}
return metadata.bio ?? "No information"
}
}
121 changes: 107 additions & 14 deletions Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ private struct CurrencyInfoScreenContent: View {

@State private var presentedSellViewModel: CurrencySellViewModel?
@State private var isShowingCurrencySelection: Bool = false
/// New UI: the toolbar title only appears once the hero card's own title has
/// scrolled out of view (Apple Wallet / App Store behaviour).
@State private var showsToolbarTitle: Bool = false

let session: Session

Expand All @@ -60,6 +63,8 @@ private struct CurrencyInfoScreenContent: View {
private let marketCapController: MarketCapController
private let showBuyOnAppear: Bool

private var isNewUI: Bool { BetaFlags.shared.hasEnabled(.newUI) }

// MARK: - Init -

private init(
Expand Down Expand Up @@ -107,7 +112,32 @@ private struct CurrencyInfoScreenContent: View {
case .loading:
CurrencyInfoLoadingView()
case .loaded(let metadata, let decodedMetadata):
LoadedContent(
if isNewUI {
CurrencyInfoContentV2(
metadata: metadata,
decodedMetadata: decodedMetadata,
viewModel: viewModel,
ratesController: ratesController,
marketCapController: marketCapController,
session: session,
onGive: {
Analytics.buttonTapped(name: .give)
router.push(.give(mint))
},
// New UI pushes the buy flow onto the current stack rather
// than presenting it as a sheet.
onBuy: { router.push(.buyCurrency(mint)) },
onWithdraw: { router.push(.withdrawCurrency(mint)) },
onShowTransactionHistory: { router.push(.transactionHistory(metadata.mint)) },
onScrolledPastTitle: { scrolledPast in
guard showsToolbarTitle != scrolledPast else { return }
withAnimation(.easeInOut(duration: 0.2)) {
showsToolbarTitle = scrolledPast
}
}
)
} else {
LoadedContent(
metadata: metadata,
decodedMetadata: decodedMetadata,
viewModel: viewModel,
Expand All @@ -130,17 +160,33 @@ private struct CurrencyInfoScreenContent: View {
},
onDeposit: { router.push(.usdcDepositEducation) },
onWithdraw: { router.push(.withdrawCurrency(mint)) }
)
)
}
case .error(let error):
CurrencyInfoErrorView(error: error) {
dismiss()
}
}
}
.toolbarTitleDisplayMode(.inline)
// The bar background is deliberately left in place: it renders the
// scroll edge effect, and hiding it removes the soft fade the content
// scrolls under (see CurrencyInfoContentV2's scrollEdgeEffectStyle).
.toolbar {
ToolbarItem(placement: .principal) {
toolbarContent()
// Kept mounted and faded rather than inserted/removed — churning
// toolbar items mid-transition wedges nav-bar layout. In the new UI
// the item draws its own capsule, so the system platter stays off.
if #available(iOS 26.0, *) {
ToolbarItem(placement: isNewUI ? .topBarLeading : .principal) {
toolbarContent()
.opacity(isNewUI && !showsToolbarTitle ? 0 : 1)
}
.sharedBackgroundVisibility(isNewUI ? .hidden : .automatic)
} else {
ToolbarItem(placement: isNewUI ? .topBarLeading : .principal) {
toolbarContent()
.opacity(isNewUI && !showsToolbarTitle ? 0 : 1)
}
}
if !isUSDF {
ToolbarItem(placement: .topBarTrailing) {
Expand Down Expand Up @@ -175,16 +221,63 @@ private struct CurrencyInfoScreenContent: View {
}

@ViewBuilder private func toolbarContent() -> some View {
if isUSDF {
Text("USDF")
.font(.appBarButton)
.foregroundStyle(Color.textMain)
} else if let metadata = mintMetadata {
CurrencyLabel(
imageURL: metadata.imageURL,
name: metadata.name,
amount: nil
)
// USDF's name is already "Dollars", so no special-case is needed.
if let metadata = mintMetadata {
if isNewUI {
// Compact leading label — the system supplies the Liquid Glass
// platter around it on iOS 26. `.fixedSize()` is required: the
// toolbar compresses the item to its icon otherwise, dropping
// the text. `CurrencyLabel` is row-shaped (it spaces name and
// amount apart with a Spacer), so it can't be reused here.
HStack(spacing: 8) {
RemoteImage(url: metadata.imageURL)
.frame(width: 24, height: 24)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 0) {
// Semantic styles rather than fixed white/grey: inside the
// glass they pick up the system's vibrancy, so the label
// stays legible over a bright bill card scrolling beneath.
Text(metadata.name)
.font(.appTextSmall)
.foregroundStyle(.primary)
// USDF has no market cap (no bonding curve), so it stays
// a single-line pill.
if !isUSDF {
Text(viewModel.marketCap.formatted())
.font(.appTextCaption)
.foregroundStyle(.secondary)
}
}
}
.lineLimit(1)
.fixedSize()
// The capsule is drawn here rather than by the toolbar: the
// system platter sizes itself to the content with a fixed inset
// and swallows most of the trailing padding the spec calls for.
.padding(.leading, 8)
.padding(.trailing, 20)
.padding(.vertical, 4)
.modifier(CapsuleGlass())
} else {
CurrencyLabel(
imageURL: metadata.imageURL,
name: metadata.name,
amount: nil
)
}
}
}
}

/// The title pill's Liquid Glass capsule. Drawn by the label itself so its
/// padding is honoured — the toolbar's own platter hugs the content and clips
/// most of the trailing inset away.
private struct CapsuleGlass: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26.0, *) {
content.glassEffect(.regular, in: .capsule)
} else {
content.background(.ultraThinMaterial, in: Capsule())
}
}
}
Expand Down
Loading