From 7431427a013e16c1dc8660e3120ef3136399cd88 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 15 Aug 2026 10:09:50 -0400 Subject: [PATCH] feat(currency-info): revamped new-UI token info layout Behind BetaFlags.newUI, replace the currency info screen with the new layout: a hero bill card, inline Give / Convert / Withdraw tiles (a single Get when the token is not held), a per-token Recent preview, the reused market-cap chart, the About block, and a created-at footer. The legacy layout is untouched. - Actions: Give -> give; Convert / Get -> the pushed buy flow; Withdraw -> withdrawCurrency(mint) (pre-selected). - USDF: Convert + Withdraw only, no market-cap chart, no created date, not shareable, and the title uses the mint name ("Dollars"). - Title bar: a leading Liquid Glass pill (icon, name, market cap) revealed only once the hero card title scrolls under the bar; the bar background is hidden and the top scroll edge softened so content scrolls beneath it. - Reuse: CurrencyInfoMarketCapSection as-is; About extracted to CurrencyInfoAboutSection (keeps expand/collapse). Per-token Recent loads via the view model, keeping DB access out of the view. - Spacing across this screen and the wallet snaps to the 4pt grid. Builds on the pushable buy flow (.buyCurrency). --- .../CurrencyInfoAboutSection.swift | 33 ++++ .../Currency Info/CurrencyInfoContentV2.swift | 187 ++++++++++++++++++ .../Currency Info/CurrencyInfoScreen.swift | 121 ++++++++++-- .../Currency Info/CurrencyInfoViewModel.swift | 11 ++ .../Screens/Main/Home/OnboardingFunnel.swift | 2 +- .../Main/Home/RecentActivitySection.swift | 43 ++++ .../Screens/Main/Home/WalletActivityRow.swift | 4 +- .../Core/Screens/Main/Home/WalletScreen.swift | 30 +-- 8 files changed, 389 insertions(+), 42 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoAboutSection.swift create mode 100644 Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift create mode 100644 Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoAboutSection.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoAboutSection.swift new file mode 100644 index 00000000..fcd22fe6 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoAboutSection.swift @@ -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) + } +} diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift new file mode 100644 index 00000000..cca8ebd0 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -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" + } +} diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index 814eab4d..fbd7a088 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -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 @@ -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( @@ -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, @@ -130,7 +160,8 @@ private struct CurrencyInfoScreenContent: View { }, onDeposit: { router.push(.usdcDepositEducation) }, onWithdraw: { router.push(.withdrawCurrency(mint)) } - ) + ) + } case .error(let error): CurrencyInfoErrorView(error: error) { dismiss() @@ -138,9 +169,24 @@ private struct CurrencyInfoScreenContent: View { } } .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) { @@ -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()) } } } diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift index 70f45d8a..c068768f 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoViewModel.swift @@ -29,6 +29,11 @@ class CurrencyInfoViewModel { private(set) var loadingState: LoadingState = .loading + /// Recent activity for this token (newest first), previewed on the new-UI + /// info screen. Loaded via ``loadRecentActivities(limit:)`` so DB access stays + /// in the view model rather than the view. + private(set) var recentActivities: [Activity] = [] + @ObservationIgnored private var updateableMint: Updateable? var mintMetadata: StoredMintMetadata? { @@ -135,6 +140,12 @@ class CurrencyInfoViewModel { } } + /// Loads the newest `limit` activities for this token into ``recentActivities``. + func loadRecentActivities(limit: Int) async { + let all = (try? await database.getActivities(mint: mint)) ?? [] + recentActivities = Array(all.prefix(limit)) + } + func loadMintMetadata() async { // If already loaded from cache, no need to show loading state let wasAlreadyLoaded = isLoaded diff --git a/Flipcash/Core/Screens/Main/Home/OnboardingFunnel.swift b/Flipcash/Core/Screens/Main/Home/OnboardingFunnel.swift index 551a5bee..b7642327 100644 --- a/Flipcash/Core/Screens/Main/Home/OnboardingFunnel.swift +++ b/Flipcash/Core/Screens/Main/Home/OnboardingFunnel.swift @@ -78,7 +78,7 @@ struct OnboardingFunnelView: View { icon(item) .frame(width: 24, height: 24) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { Text(item.title) .font(.appTextMedium) .foregroundStyle(Color.textMain) diff --git a/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift b/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift new file mode 100644 index 00000000..54bfb142 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/RecentActivitySection.swift @@ -0,0 +1,43 @@ +// +// RecentActivitySection.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore + +/// The "Recent" activity preview: a tappable header that opens the full history, +/// over a short list of ``WalletActivityRow``s. Shared by the wallet (all tokens) +/// and the currency info screen (a single token), which differ only in what the +/// header opens. +struct RecentActivitySection: View { + + let activities: [Activity] + /// Opens the full history — cross-token on the wallet, per-token on currency info. + let onShowAll: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // The header is the "dive in" affordance — the rows themselves are a + // non-interactive preview. + Button(action: onShowAll) { + 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(activities) { activity in + WalletActivityRow(activity: activity) + } + } + } +} diff --git a/Flipcash/Core/Screens/Main/Home/WalletActivityRow.swift b/Flipcash/Core/Screens/Main/Home/WalletActivityRow.swift index 29d584ce..1d40fa76 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletActivityRow.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletActivityRow.swift @@ -32,7 +32,7 @@ struct WalletActivityRow: View { .frame(width: 40, height: 40) .clipShape(Circle()) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { Text(displayTitle) .font(.appTextMedium) .foregroundStyle(Color.textMain) @@ -49,7 +49,7 @@ struct WalletActivityRow: View { .foregroundStyle(Color.textMain) .lineLimit(1) } - .padding(.vertical, 10) + .padding(.vertical, 12) .task(id: activity.id) { await resolveCounterparty() } } diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index 273277b3..a71f2bce 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -184,32 +184,12 @@ private struct WalletScreenContent: View { } private var recentActivitySection: some View { - VStack(alignment: .leading, spacing: 0) { - // The header is the "dive in" affordance — tapping it (or the - // chevron) opens the full cross-token activity history. The rows - // themselves are a non-interactive preview. - Button { - router.push(.activity) - } label: { - HStack(spacing: 6) { - 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(.top, 24) - .padding(.bottom, 4) - - ForEach(recentActivities) { activity in - WalletActivityRow(activity: activity) - } + // The header opens the full cross-token history here; currency info + // passes its own per-token destination. + RecentActivitySection(activities: recentActivities) { + router.push(.activity) } + .padding(.top, 24) } private func handleOnboardingTap(_ item: OnboardingItem) {