Skip to content
Closed
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)
}
}
195 changes: 195 additions & 0 deletions Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
//
// 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 {
VStack(spacing: 24) {
heroCard
actionTiles

if isOwned && !viewModel.recentActivities.isEmpty {
recentSection
}

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

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

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 {
VStack(alignment: .leading, spacing: 0) {
Button(action: onShowTransactionHistory) {
HStack(spacing: 8) {
Text("Recent")
.font(.appTextLarge)
.foregroundStyle(Color.textMain)
Image(systemName: "chevron.right")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Color.textSecondary)
Spacer()
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.padding(.bottom, 4)

ForEach(viewModel.recentActivities) { activity in
WalletActivityRow(activity: activity)
}
}
}

// MARK: - Copy

private var currencyDescription: String {
if isUSDF {
return "Dollars are a 1:1 USD stablecoin managed by Coinbase."
}
return metadata.bio ?? "No information"
}
}
120 changes: 106 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,34 @@ private struct CurrencyInfoScreenContent: View {
},
onDeposit: { router.push(.usdcDepositEducation) },
onWithdraw: { router.push(.withdrawCurrency(mint)) }
)
)
}
case .error(let error):
CurrencyInfoErrorView(error: error) {
dismiss()
}
}
}
.toolbarTitleDisplayMode(.inline)
// New UI: no bar background, so content scrolls under the (glass) items
// rather than being clipped by an opaque bar.
.modifier(TransparentNavigationBar(enabled: isNewUI))
.toolbar {
ToolbarItem(placement: .principal) {
toolbarContent()
// Kept mounted and faded rather than inserted/removed — churning
// toolbar items mid-transition wedges nav-bar layout. On iOS 26 the
// item's glass platter also has to be suppressed, or an empty
// capsule sits next to the back button while the title is hidden.
if #available(iOS 26.0, *) {
ToolbarItem(placement: isNewUI ? .topBarLeading : .principal) {
toolbarContent()
.opacity(isNewUI && !showsToolbarTitle ? 0 : 1)
}
.sharedBackgroundVisibility(isNewUI && !showsToolbarTitle ? .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 +222,61 @@ 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) {
Text(metadata.name)
.font(.appTextSmall)
.foregroundStyle(Color.textMain)
// USDF has no market cap (no bonding curve), so it stays
// a single-line pill.
if !isUSDF {
Text(viewModel.marketCap.formatted())
.font(.appTextCaption)
.foregroundStyle(Color.textSecondary)
}
}
}
.lineLimit(1)
.fixedSize()
// Spec's capsule is wider than its content (~107x37pt): pad the
// label and floor its width so short names still read as a pill
// rather than shrink-wrapping to the icon.
.padding(.leading, 4)
.padding(.trailing, 12)
.frame(minWidth: 96, alignment: .leading)
} else {
CurrencyLabel(
imageURL: metadata.imageURL,
name: metadata.name,
amount: nil
)
}
}
}
}

/// Hides the navigation bar's background so scrolled content passes under the
/// toolbar instead of being cut off by an opaque bar. The bar's items keep their
/// own (Liquid Glass) backgrounds.
private struct TransparentNavigationBar: ViewModifier {
let enabled: Bool

func body(content: Content) -> some View {
if enabled {
content.toolbarBackgroundVisibility(.hidden, for: .navigationBar)
} else {
content
}
}
}
Expand Down
Loading