From 9a599958eede23f2972c8ff17fc97dd521cbcf70 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 16 Aug 2026 21:48:46 -0400 Subject: [PATCH] feat(wallet): expand token cards into the currency info screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a bill card in the wallet now pulls it out of the deck and into the currency info screen, Apple Wallet style, and closing puts it back where it came from. The deck is driven by a single `expansionProgress` scalar that the card's flight animates in lockstep with: the cards above gather onto the slot it rises to and fade underneath it, the ones below run off the bottom, and the balance header fades with them. The opened card keeps its natural place in the deck's z-order throughout, which is what lets it slide back *under* its neighbours on the way home rather than landing on top of them. Notes on the parts that are less obvious than they look: - The reorganisation has to be a value, not a branch. `visualEffect` interpolates a changing offset, but swapping which formula applies (by toggling the opened mint) resolves in one frame and reads as a snap. - Work that blocks the main thread — hiding the tab bar, which relayouts the whole `TabView`, and building the page — happens in an un-animated step before the animation is committed. SwiftUI's springs are driven on the main thread, so anything deferred until after the commit interrupts the motion instead of running alongside it. The tab bar is restored the same way, at the top of the close, so it does not arrive late. - The hand-off to the page's hero card fires on the animation's completion rather than a matching sleep: a spring is still settling when its nominal duration is up, and swapping then leaves the two cards a few points apart. The page also stays non-interactive until that hand-off, or a scroll begun in the gap slides the content out from under a card that is still the copy flying above it. - Both transitions finish their work in callbacks, so each is tagged and every callback checks it is still current — otherwise a close settling in the background tears down the open that interrupted it. The screen itself arrives in one pass. Nothing below the action tiles is gated on loading: the activity read runs off the main thread, and the market cap section builds its view model up front so it holds its full height around a placeholder plot. Only the chart's points, and so the expensive populated Swift Charts draw, wait for the transition to finish. Hosted as an overlay the screen has no navigation bar, so it draws its own chrome and its own top fade — the system's soft scroll edge is rendered by the bar's background, and without it the page scrolled up to meet the status bar at full strength. --- .../Currency Info/CurrencyInfoContentV2.swift | 57 +++- .../CurrencyInfoMarketCapSection.swift | 16 +- .../Currency Info/CurrencyInfoScreen.swift | 199 ++++++++++- .../Core/Screens/Main/Home/HomeTabView.swift | 4 + .../Screens/Main/Home/TokenCardStack.swift | 102 +++++- .../Main/Home/WalletCardExpansion.swift | 16 + .../Main/Home/WalletCardGeometry.swift | 19 ++ .../Main/Home/WalletCardNamespace.swift | 21 ++ .../Core/Screens/Main/Home/WalletScreen.swift | 311 +++++++++++++++++- 9 files changed, 722 insertions(+), 23 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Home/WalletCardExpansion.swift create mode 100644 Flipcash/Core/Screens/Main/Home/WalletCardGeometry.swift create mode 100644 Flipcash/Core/Screens/Main/Home/WalletCardNamespace.swift diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index cca8ebd0..8d5c2861 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift @@ -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 @@ -42,6 +57,12 @@ 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 + /// 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. @@ -50,6 +71,11 @@ 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 @@ -57,11 +83,28 @@ struct CurrencyInfoContentV2: View { // 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) + // 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) 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) @@ -74,7 +117,8 @@ struct CurrencyInfoContentV2: View { CurrencyInfoMarketCapSection( marketCap: viewModel.marketCap, currencyCode: ratesController.balanceCurrency, - marketCapController: marketCapController + marketCapController: marketCapController, + isReady: !defersHeavyContent ) } @@ -104,6 +148,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) } @@ -113,6 +161,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 { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift index 60be4ff2..9eeecd93 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift @@ -15,6 +15,12 @@ struct CurrencyInfoMarketCapSection: View { 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) { @@ -34,9 +40,17 @@ 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. .task { + guard chartViewModel == nil else { return } setupChart() } + // The points, and so the real plot, come once the host says it is safe. + .task(id: isReady) { + guard isReady, let viewModel = chartViewModel else { return } + loadChartData(for: viewModel.selectedRange, into: viewModel) + } .onChange(of: marketCap) { _, newMarketCap in // Live ticks only move the appended "current" point — history // doesn't change when the spot value moves, so no refetch. @@ -51,10 +65,10 @@ struct CurrencyInfoMarketCapSection: View { 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) { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index fbd7a088..0a9f11a1 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -16,18 +16,37 @@ import FlipcashCore /// changes. struct CurrencyInfoScreen: View { + /// How the screen is hosted. The content, view model and behaviour are the + /// same either way — only the top chrome differs, since an overlay is not a + /// navigation destination and so cannot use `.toolbar`. + enum Presentation { + /// Pushed onto a navigation stack; the system supplies the back button. + case pushed + /// Layered over the wallet so the tapped card can morph into the hero + /// card. Draws its own back button, which calls the closure. + case overlay(onClose: () -> Void, showsHeroCard: Bool = true) + /// Laid out inline beneath the card the wallet keeps on screen, so it + /// draws neither the hero card nor any top chrome. + case inline + } + @Environment(Container.self) private var container @Environment(SessionContainer.self) private var sessionContainer let mint: PublicKey var showBuyOnAppear: Bool = false + var presentation: Presentation = .pushed + /// Held true by a host that is animating this screen in. + var defersHeavyContent: Bool = false var body: some View { CurrencyInfoScreenContent( mint: mint, container: container, sessionContainer: sessionContainer, - showBuyOnAppear: showBuyOnAppear + showBuyOnAppear: showBuyOnAppear, + presentation: presentation, + defersHeavyContent: defersHeavyContent ) } } @@ -37,6 +56,8 @@ private struct CurrencyInfoScreenContent: View { @Environment(\.dismiss) private var dismiss @Environment(AppRouter.self) private var router + /// Set by the wallet so this screen zooms out of the tapped token card. + @Environment(\.walletCardNamespace) private var cardNamespace @State private var presentedSellViewModel: CurrencySellViewModel? @State private var isShowingCurrencySelection: Bool = false @@ -62,9 +83,39 @@ private struct CurrencyInfoScreenContent: View { private let ratesController: RatesController private let marketCapController: MarketCapController private let showBuyOnAppear: Bool + private let presentation: CurrencyInfoScreen.Presentation + private let defersHeavyContent: Bool private var isNewUI: Bool { BetaFlags.shared.hasEnabled(.newUI) } + /// Overlay hosting draws its own chrome; pushed hosting uses `.toolbar`. + private var overlayClose: (() -> Void)? { + if case .overlay(let onClose, _) = presentation { return onClose } + return nil + } + + /// False while the wallet's card is still flying into the hero slot — the + /// screen would otherwise draw a second card underneath it. + private var showsHeroCard: Bool { + switch presentation { + case .overlay(_, let shows): return shows + case .inline: return false + case .pushed: return true + } + } + + /// The wallet already shows the card above an inline-hosted screen. + private var isInline: Bool { + if case .inline = presentation { return true } + return false + } + + /// Only a pushed screen has a navigation bar to put items in. + private var usesToolbar: Bool { + if case .pushed = presentation { return true } + return false + } + // MARK: - Init - private init( @@ -72,8 +123,12 @@ private struct CurrencyInfoScreenContent: View { viewModel: CurrencyInfoViewModel, container: Container, sessionContainer: SessionContainer, - showBuyOnAppear: Bool + showBuyOnAppear: Bool, + presentation: CurrencyInfoScreen.Presentation, + defersHeavyContent: Bool ) { + self.presentation = presentation + self.defersHeavyContent = defersHeavyContent self.mint = mint self.ratesController = sessionContainer.ratesController self.session = sessionContainer.session @@ -89,7 +144,14 @@ private struct CurrencyInfoScreenContent: View { /// Creates the screen by mint address. Metadata is loaded from the database /// (fast path) or fetched from the network, showing a loading state until ready. - init(mint: PublicKey, container: Container, sessionContainer: SessionContainer, showBuyOnAppear: Bool = false) { + init( + mint: PublicKey, + container: Container, + sessionContainer: SessionContainer, + showBuyOnAppear: Bool = false, + presentation: CurrencyInfoScreen.Presentation = .pushed, + defersHeavyContent: Bool = false + ) { self.init( mint: mint, viewModel: CurrencyInfoViewModel( @@ -100,7 +162,9 @@ private struct CurrencyInfoScreenContent: View { ), container: container, sessionContainer: sessionContainer, - showBuyOnAppear: showBuyOnAppear + showBuyOnAppear: showBuyOnAppear, + presentation: presentation, + defersHeavyContent: defersHeavyContent ) } @@ -134,7 +198,9 @@ private struct CurrencyInfoScreenContent: View { withAnimation(.easeInOut(duration: 0.2)) { showsToolbarTitle = scrolledPast } - } + }, + showsHeroCard: showsHeroCard, + defersHeavyContent: defersHeavyContent ) } else { LoadedContent( @@ -168,6 +234,17 @@ private struct CurrencyInfoScreenContent: View { } } } + // Overlay hosting is not a navigation destination, so the chrome the + // toolbar would provide is drawn over the content instead — and the + // content needs the same top inset a nav bar would have reserved. + .modifier(OverlayTopInset(active: overlayClose != nil)) + .overlay(alignment: .top) { + if overlayClose != nil { + overlayChrome + .frame(height: Self.overlayBarHeight) + } + } + .toolbar(usesToolbar ? .automatic : .hidden, for: .navigationBar) .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 @@ -220,6 +297,61 @@ private struct CurrencyInfoScreenContent: View { // presentation queue. } + /// Height of the chrome an overlay draws in place of the navigation bar. + fileprivate static let overlayBarHeight: CGFloat = 44 + + /// Top inset for overlay hosting. The wallet parks the opened card at + /// `WalletCardGeometry.openCardTopInset`; the content adds 8pt of its own + /// padding above the hero card, so this makes the two line up exactly and + /// the hand-off invisible. + /// + /// The further 6pt is the scroll view's own soft top edge, which insets the + /// first row by that much. Without it the hero card lands 6pt below where + /// the wallet parked the card, and the hand-off reads as the card + /// overshooting and settling back. + fileprivate static let overlayContentInset: CGFloat = WalletCardGeometry.openCardTopInset - 8 - 6 + + /// Back + title pill + share, matching the pushed screen's toolbar. Used + /// only when hosted as an overlay. + @ViewBuilder private var overlayChrome: some View { + HStack(spacing: 8) { + Button { + overlayClose?() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: 44, height: 44) + // Hit-test the whole target, not just the glyph. + .contentShape(Circle()) + .modifier(CircleGlass()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("currency-info-back") + + toolbarContent() + .opacity(showsToolbarTitle ? 1 : 0) + + Spacer(minLength: 0) + + if !isUSDF { + ShareLink(item: URL(string: "https://app.flipcash.com/token/\(mint.base58)")!) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: 44, height: 44) + .contentShape(Circle()) + .modifier(CircleGlass()) + } + .simultaneousGesture(TapGesture().onEnded { + Analytics.buttonTapped(name: .shareTokenInfo) + }) + } + } + .padding(.horizontal, 16) + .animation(.smooth(duration: 0.2), value: showsToolbarTitle) + } + @ViewBuilder private func toolbarContent() -> some View { // USDF's name is already "Dollars", so no special-case is needed. if let metadata = mintMetadata { @@ -269,6 +401,63 @@ private struct CurrencyInfoScreenContent: View { } } +/// Reserves the space a navigation bar would have taken, so overlay-hosted +/// content starts below the chrome instead of underneath it, and fades what +/// scrolls up into that space. +/// +/// The system's own soft scroll edge is drawn by the navigation bar's +/// background, and an overlay has no navigation bar — it draws its own chrome +/// over the content — so without this the page scrolls up to meet the status +/// bar at full strength. +private struct OverlayTopInset: ViewModifier { + let active: Bool + + /// The screen's own top inset, read before this modifier adds its own, so + /// the fade can be positioned against the status bar rather than a fixed + /// guess at its height. + @State private var statusBarHeight: CGFloat = 0 + + /// How far below the status bar the content returns to full strength — + /// the height of the chrome, so it is clear again just under the buttons. + private static let fadeLength = CurrencyInfoScreenContent.overlayBarHeight + + func body(content: Content) -> some View { + if active { + content + .onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { + statusBarHeight = $0 + } + .safeAreaInset(edge: .top) { + Color.clear.frame(height: CurrencyInfoScreenContent.overlayContentInset) + } + .overlay(alignment: .top) { + LinearGradient( + colors: [Color.backgroundMain, Color.backgroundMain.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: statusBarHeight + Self.fadeLength) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + } + } else { + content + } + } +} + +/// Circular Liquid Glass for the overlay's back and share buttons, matching the +/// platters the toolbar gives those items when the screen is pushed. +private struct CircleGlass: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.glassEffect(.regular, in: .circle) + } else { + content.background(.ultraThinMaterial, in: Circle()) + } + } +} + /// 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. diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 7dbbcebe..0849f7b1 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -52,7 +52,10 @@ struct HomeTabView: View { /// bill/tipcard is up (the app-root overlay owns the screen then, as the v1 /// bill replaced the bottom bar). Sheets already cover the bar, so only /// in-tab pushes need handling here. + @State private var cardExpansion = WalletCardExpansion() + private var isTabBarHidden: Bool { + if cardExpansion.isExpanded { return true } if sessionContainer.session.isShowingBill { return true } guard let stack = selection.pushStack else { return false } return !router[stack].isEmpty @@ -136,6 +139,7 @@ struct HomeTabView: View { } case .wallet: WalletScreen(onScanTipCard: { selection = .scan }) + .environment(cardExpansion) case .chat: ChatTab() case .tipCard: diff --git a/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift b/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift index 0e75654f..4bb2af59 100644 --- a/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift +++ b/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift @@ -35,7 +35,38 @@ struct TokenCardStack: View { var collapsedReveal: CGFloat = defaultCollapsedReveal /// Where the back card pins, measured from the top of the scroll viewport. var pinInset: CGFloat = 0 - var onCardTap: (TokenCardData) -> Void = { _ in } + /// Matched-transition namespace so the tapped card can zoom into the pushed + /// currency-info hero card. `nil` disables the zoom source. + var namespace: Namespace.ID? = nil + /// The card being opened. The deck reorganises around it: the cards above it + /// collapse into its slot, the rest push off the bottom. + var expandingMint: PublicKey? = nil + /// How far that reorganisation has run: 0 is the resting fan, 1 is fully + /// cleared. The deck is driven by this scalar rather than by `expandingMint` + /// alone because `visualEffect` interpolates a *value* that changes under an + /// animation, but not a change of branch — toggling the mint swaps which + /// formula applies, which resolves in a single frame and reads as a snap. + var expansionProgress: CGFloat = 0 + /// Height of the enclosing scroll container, used to push the lower cards + /// clear of the screen. + var containerHeight: CGFloat = 0 + /// A card the stack must leave a hole for, because it is being drawn + /// elsewhere: in flight between the deck and the page, or by the page itself. + /// Outlives `expandingMint`, which clears as soon as closing starts. + var hiddenMint: PublicKey? = nil + /// Where the opened card comes to rest, leaving room for the chrome above it. + var openTopInset: CGFloat = 0 + /// Reports the tapped card along with its current on-screen top edge, so the + /// caller can work out the lift. + var onCardTap: (TokenCardData, CGFloat) -> Void = { _, _ in } + + /// Each card's current top edge in global space, keyed by mint. + @State private var cardTops: [PublicKey: CGFloat] = [:] + + private var expandingIndex: Int? { + guard let expandingMint else { return nil } + return items.firstIndex { $0.mint == expandingMint } + } /// Always the fanned height, so the scroll range stays stable while cards collapse. private var stackHeight: CGFloat { @@ -57,15 +88,61 @@ struct TokenCardStack: View { ZStack(alignment: .top) { ForEach(Array(items.enumerated()), id: \.element.id) { index, item in Button { - onCardTap(item) + onCardTap(item, cardTops[item.mint] ?? 0) } label: { TokenCardView(data: item, height: cardHeight) } .buttonStyle(.plain) - .visualEffect { [index] content, proxy in - content.offset(y: offset(for: index, stackTop: proxy.frame(in: .scrollView).minY)) + // Resting fan, and the reorganisation when a card is opened, are + // both expressed here so they interpolate as one animation. + .opacity(item.mint == hiddenMint ? 0 : 1) + .visualEffect { [index, expandingIndex, containerHeight, expansionProgress, openTopInset] content, proxy in + let rect = proxy.frame(in: .scrollView) + let fan = offset(for: index, stackTop: rect.minY) + + guard let expandingIndex, expansionProgress > 0 else { + return content.offset(y: fan).opacity(1) + } + + // Where the opened card comes to rest. Every card is laid out + // at the same origin, so this is the same offset for all of + // them — the cards above gather onto exactly the spot the + // opened one lands, which is what makes them read as + // collapsing into it. + let open = openTopInset - rect.minY + + // The opened card travels there and keeps its place in the + // deck's z-order the whole way. That ordering is what makes + // closing read as putting a card back: it slides under the + // cards on top of it rather than landing over them and then + // being covered in one frame when its z-order reverts. It + // also means the cards above gather *underneath* it. + if index == expandingIndex { + return content.offset(y: fan + (open - fan) * expansionProgress).opacity(1) + } + + // The cards above collapse up into it, and the ones below run + // off the bottom; both fade on the way. Collapsing the upper + // cards *down* onto the opened card instead — by the reveal + // they each contribute — sends them the wrong way entirely + // when a card low in the deck is picked, dragging the whole + // top of the deck down over the card that is rising past it. + let cleared = index < expandingIndex ? open : containerHeight - rect.minY + + return content + .offset(y: fan + (cleared - fan) * expansionProgress) + .opacity(1 - expansionProgress) + } + .onGeometryChange(for: CGFloat.self) { [index] proxy in + proxy.frame(in: .global).minY + + offset(for: index, stackTop: proxy.frame(in: .scrollView).minY) + } action: { top in + cardTops[item.mint] = top } - // Cards drawn front-to-back so the last (highest-value) sits on top. + // Cards drawn front-to-back so the last (highest-value) sits on + // top. The opened card keeps its natural place here — lifting it + // above the deck would make it pop out on open and snap back + // under on close. .zIndex(Double(index)) } } @@ -91,3 +168,18 @@ struct TokenCardStack: View { return restingTop - effectiveTop } } + +/// Marks a card as the morph source for the detail's hero card, but only when +/// the stack was given a namespace — it renders fine standalone (previews). +private struct WalletCardTransitionSource: 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 + } + } +} diff --git a/Flipcash/Core/Screens/Main/Home/WalletCardExpansion.swift b/Flipcash/Core/Screens/Main/Home/WalletCardExpansion.swift new file mode 100644 index 00000000..22743dc3 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/WalletCardExpansion.swift @@ -0,0 +1,16 @@ +// +// WalletCardExpansion.swift +// Flipcash +// + +import Observation + +/// Whether a token card is currently expanded into the currency info page. +/// +/// The expansion is an overlay rather than a push, so the tab bar's usual +/// "hide while a stack is non-empty" rule does not see it; the wallet reports it +/// here instead. +@Observable +final class WalletCardExpansion { + var isExpanded = false +} diff --git a/Flipcash/Core/Screens/Main/Home/WalletCardGeometry.swift b/Flipcash/Core/Screens/Main/Home/WalletCardGeometry.swift new file mode 100644 index 00000000..ca604439 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/WalletCardGeometry.swift @@ -0,0 +1,19 @@ +// +// WalletCardGeometry.swift +// Flipcash +// + +import CoreGraphics + +/// Where an opened token card sits, shared by the wallet (which animates the +/// card there) and the currency info screen (whose hero card must land on the +/// same spot for the hand-off between them to be invisible). +/// +/// Taken from the currency info spec (Figma 9121:14267): 44pt chrome at y=66, +/// card at y=134.5. +enum WalletCardGeometry { + /// Distance from the top of the scroll container to the opened card. + static let openCardTopInset: CGFloat = 72 + /// The card's height in both places. + static let cardHeight: CGFloat = 224 +} diff --git a/Flipcash/Core/Screens/Main/Home/WalletCardNamespace.swift b/Flipcash/Core/Screens/Main/Home/WalletCardNamespace.swift new file mode 100644 index 00000000..eae4e6c0 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/WalletCardNamespace.swift @@ -0,0 +1,21 @@ +// +// WalletCardNamespace.swift +// Flipcash +// + +import SwiftUI + +private struct WalletCardNamespaceKey: EnvironmentKey { + static let defaultValue: Namespace.ID? = nil +} + +extension EnvironmentValues { + /// The wallet card stack's matched-transition namespace, handed down through + /// the balance `NavigationStack` so a pushed currency-info screen can zoom out + /// of the tapped token card. `nil` anywhere the wallet is not the source, in + /// which case the screen pushes without a zoom. + var walletCardNamespace: Namespace.ID? { + get { self[WalletCardNamespaceKey.self] } + set { self[WalletCardNamespaceKey.self] = newValue } + } +} diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index a71f2bce..9ce4c64f 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -29,6 +29,9 @@ private struct WalletScreenContent: View { @Environment(AppRouter.self) private var router @Environment(RatesController.self) private var ratesController @Environment(HistoryController.self) private var historyController + /// Reported so the tab bar hides while a card is open — the expansion is an + /// overlay, so the usual push-based rule does not see it. + @Environment(WalletCardExpansion.self) private var cardExpansion let session: Session let onScanTipCard: () -> Void @@ -41,6 +44,61 @@ private struct WalletScreenContent: View { /// The unified recent-activity preview (newest first). @State private var recentActivities: [Activity] + /// The card being opened. The deck reorganises around it and the detail + /// panel rises beneath it. + @State private var expandingMint: PublicKey? + /// The card in flight between the deck and the page's hero slot. + @State private var flyingCard: TokenCardData? + /// Where that card currently sits, measured from the top of the container. + @State private var flyingCardOffset: CGFloat = 0 + /// The slot the deck leaves empty while that card is drawn elsewhere. + @State private var hiddenMint: PublicKey? + /// Released once the transition has settled, letting the page build its + /// chart and read its activity without stuttering the animation. + @State private var heavyContentReady = false + /// True once it has landed, at which point the page draws the card instead. + @State private var cardHasLanded = false + /// Whether the page has been brought in behind the flying card. + @State private var pageIsIn = false + /// Whether the page exists yet. Split from ``pageIsIn`` so the cost of + /// building it is paid on its own runloop turn, while the card is already + /// in flight, rather than on the frame it becomes visible. + @State private var pageIsBuilt = false + /// Fades the wallet — balance header included — out behind the opening card. + @State private var deckOpacity: Double = 1 + /// Drives the deck's reorganisation, 0 (resting) to 1 (cleared), animated in + /// lockstep with the card's flight so the two read as one gesture. + @State private var expansionProgress: CGFloat = 0 + /// Identifies the transition currently in flight. Opening and closing both + /// finish their work in callbacks — an animation completion, and a delayed + /// page fade — which keep running even once the transition that scheduled + /// them has been replaced. Each captures the token it started with and does + /// nothing if it no longer matches, so a close settling in the background + /// cannot tear down the open that interrupted it. + @State private var transitionToken = 0 + /// Size of the wallet's container, used to push the lower cards off-screen + /// and to size the detail panel below the open card. + @State private var containerSize: CGSize = .zero + + /// Safe-area insets: the wallet's container excludes them, so they are added + /// back when sizing the panel against the full screen. + @State private var safeArea: EdgeInsets = .init() + + private var safeAreaTop: CGFloat { safeArea.top } + + /// Geometry taken from the currency info spec (Figma 9121:14267), which + /// places the card at y=134.5 with the 44pt chrome at y=66, and starts the + /// action tiles 22pt below the card. The panel supplies 8pt of its own top + /// padding before those tiles, so it starts 14pt under the card. + private static let openCardTopInset: CGFloat = 72 + private static let openCardHeight: CGFloat = 224 + private static let cardToPanelGap: CGFloat = 14 + /// Chrome sits 12pt below the status bar, per the same frame. + private static let chromeTopPadding: CGFloat = 2 + + /// Matched-transition namespace shared with the pushed currency-info screen. + @Namespace private var cardNamespace + /// Rows of recent activity previewed on the wallet (the rest lives on the /// per-token transaction history). private static let recentPreviewCount = 3 @@ -74,6 +132,44 @@ private struct WalletScreenContent: View { Background(color: .backgroundMain) { walletContent } + // Once the deck has finished reorganising, the currency info screen + // takes over the whole screen with its hero card on the same spot the + // wallet parked the opened card — so the swap is invisible and the + // page scrolls as one, card included. + .overlay { + // Built once the card is already in flight, but held invisible + // until it is most of the way there. + if pageIsBuilt, let expandingMint { + CurrencyInfoScreen( + mint: expandingMint, + presentation: .overlay(onClose: closeCard, showsHeroCard: cardHasLanded), + defersHeavyContent: !heavyContentReady + ) + .id(expandingMint) + // Fades in place: sliding the page up drags its chrome and + // tiles in from the bottom. Safe to fade now that the deck is + // already gone, so nothing shows through it. + .background(Color.backgroundMain) + .opacity(pageIsIn ? 1 : 0) + // Not until the card is the page's own, rather than the copy + // still flying above it. The page finishes fading a moment + // before the spring is fully removed, and scrolling in that + // gap slides the content out from under a card that is not + // in the scroll view yet. + .allowsHitTesting(pageIsIn && cardHasLanded) + } + } + // The opened card flies above the incoming page, so there is one card + // moving and one continuous motion rather than two phases. + .overlay(alignment: .top) { + if let card = flyingCard { + TokenCardView(data: card, height: WalletCardGeometry.cardHeight) + .padding(.horizontal, 20) + .offset(y: flyingCardOffset) + } + } + .onGeometryChange(for: CGSize.self) { $0.size } action: { containerSize = $0 } + .onGeometryChange(for: EdgeInsets.self) { $0.safeAreaInsets } action: { safeArea = $0 } // No top bar on the wallet root (per Figma) — the balance header // sits directly under the status bar. Pushed destinations restore // their own nav bar. @@ -87,9 +183,180 @@ private struct WalletScreenContent: View { .onReceive(NotificationCenter.default.publisher(for: .databaseDidChange)) { _ in recentActivities = session.recentActivities(limit: Self.recentPreviewCount) } + // Popping back to the wallet restores the deck. + .onChange(of: router[.balance].count) { _, depth in + guard depth == 0, expandingMint != nil else { return } + closeCard() + } + } + .environment(\.walletCardNamespace, cardNamespace) + } + + /// Opens a token: the deck reorganises around the tapped card, which stays + /// on screen while the detail panel rises beneath it. + private func openCard(_ item: TokenCardData, currentTop: CGFloat) { + transitionToken &+= 1 + let token = transitionToken + + // Everything that costs a frame happens in this one un-animated step, so + // the animation that follows is pure interpolation. + var seed = Transaction() + seed.disablesAnimations = true + withTransaction(seed) { + flyingCard = item + flyingCardOffset = currentTop - safeArea.top + cardHasLanded = false + pageIsIn = false + pageIsBuilt = false + expansionProgress = 0 + expandingMint = item.mint + hiddenMint = item.mint + // Both of these block the main thread for a few frames — hiding the + // tab bar relayouts the whole `TabView`, and building the page runs + // its view model's synchronous metadata read and decode. They are + // done here, before the animation below is committed, so the cost + // lands as a short pause on the tap rather than as a stall part-way + // through the flight. SwiftUI's spring animations are driven on the + // main thread, so work deferred until *after* the commit does not + // run alongside the motion — it interrupts it. + cardExpansion.isExpanded = true + pageIsBuilt = true + } + + // The card's flight and the deck's reorganisation are one animation: the + // cards above collapse into the opened card's slot and the ones below run + // off the bottom exactly as it lifts away. + // + // The hand-off is driven by the animation finishing rather than by a + // matching sleep: a spring is still settling when its nominal duration + // is up, so on a timer the flying card is still short of its mark when + // the page's hero card appears at the final position. The two are then + // drawn a few points apart, which reads as the card juddering into + // place. `.removed` fires once it has actually stopped. + withAnimation(.smooth(duration: Self.liftDuration), completionCriteria: .removed) { + flyingCardOffset = WalletCardGeometry.openCardTopInset + expansionProgress = 1 + deckOpacity = 0 + } completion: { + guard token == transitionToken else { return } + // Both cards are in exactly the same place by now, so this swaps + // them in one step and needs no overlap to cover a gap. + var handoff = Transaction() + handoff.disablesAnimations = true + withTransaction(handoff) { + cardHasLanded = true + flyingCard = nil + } + + // Everything has come to rest, so the chart can fetch its points and + // draw for real inside the space it has been holding all along. + heavyContentReady = true + } + + Task { + // The card leads; the page follows it in, so the motion reads as one + // gesture instead of the content arriving on its own. Sized to finish + // just before the card lands, so the hero card it hands over to is + // fully opaque by then. + try? await Task.sleep(for: .seconds(Self.pageFollowDelay)) + guard token == transitionToken else { return } + withAnimation(.smooth(duration: Self.liftDuration - Self.pageFollowDelay)) { + pageIsIn = true + } + } + } + + /// Back + share for the opened card, standing in for the navigation bar the + /// wallet root hides. + private func openCardChrome(mint: PublicKey) -> some View { + HStack { + Button(action: closeCard) { + Image(systemName: "chevron.left") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: 44, height: 44) + // Without this the button only takes taps on the glyph + // itself, leaving most of the 44pt target dead. + .contentShape(Circle()) + .modifier(WalletCircleGlass()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("wallet-open-card-back") + + Spacer(minLength: 0) + + if mint != .usdf { + ShareLink(item: URL(string: "https://app.flipcash.com/token/\(mint.base58)")!) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: 44, height: 44) + .contentShape(Circle()) + .modifier(WalletCircleGlass()) + } + .simultaneousGesture(TapGesture().onEnded { + Analytics.buttonTapped(name: .shareTokenInfo) + }) + } } + .padding(.horizontal, 16) + .padding(.top, Self.chromeTopPadding) + .transition(.opacity) } + /// Closes a token: the reverse of opening. The page drops away and the deck + /// takes its card back, sliding it down into its own slot and under the + /// cards stacked on top of it — put back, rather than simply vanishing. + private func closeCard() { + guard expandingMint != nil else { return } + transitionToken &+= 1 + let token = transitionToken + + // Hand the card to the deck before anything moves. It is already sitting + // at the open position (the stack holds it there while `progress` is 1), + // so this swap is invisible — and from here the deck owns the return, + // which is what lets the card go *under* its neighbours on the way home. + var handback = Transaction() + handback.disablesAnimations = true + withTransaction(handback) { + hiddenMint = nil + flyingCard = nil + cardHasLanded = false + pageIsIn = false + pageIsBuilt = false + heavyContentReady = false + // Brought back here, before the motion, for the same reason it is + // hidden before the opening one: restoring it relayouts the whole + // `TabView`. Left until the animation finishes, that cost lands + // *after* the deck has settled and the bar visibly arrives late. + cardExpansion.isExpanded = false + } + + // `expandingMint` has to stay put for the duration: it is the anchor the + // reorganisation is measured against, and clearing it here would swap the + // deck back to its resting formula in one frame instead of unwinding it. + withAnimation(.smooth(duration: Self.liftDuration), completionCriteria: .removed) { + expansionProgress = 0 + deckOpacity = 1 + } completion: { + // Only if this close is still the current transition: tapping a card + // again while it settles starts an open, and clearing the anchor then + // would strand the wallet with its header faded out and no page. + guard token == transitionToken else { return } + // The deck is back in its resting fan, so the anchor can go. Waiting + // for the animation rather than a matching sleep matters here too: + // clearing it while the spring is still settling drops the cards the + // last few points in one frame. + withTransaction(handback) { + expandingMint = nil + } + } + } + + private static let liftDuration: TimeInterval = 0.32 + /// How far behind the card the page follows. + private static let pageFollowDelay: TimeInterval = 0.20 + // MARK: - Content private var walletContent: some View { @@ -114,30 +381,45 @@ private struct WalletScreenContent: View { .padding(.bottom, 20) } } + // The stack is deliberately outside the fade: its cards carry + // their own, driven by the same progress, so the opened one can + // stay solid while the rest of the deck goes. + .opacity(deckOpacity) if !cards.isEmpty { TokenCardStack( items: cards, - onCardTap: { router.push(.currencyInfo($0.mint)) } + namespace: cardNamespace, + expandingMint: expandingMint, + expansionProgress: expansionProgress, + containerHeight: containerSize.height, + hiddenMint: hiddenMint, + openTopInset: WalletCardGeometry.openCardTopInset, + onCardTap: openCard ) } - if !recentActivities.isEmpty { - recentActivitySection - } + Group { + if !recentActivities.isEmpty { + recentActivitySection + } - // Returning users (already funded) get the tile shortcuts below - // the activity; new users use the funnel's own steps instead. - if hasAddedMoney { - walletTiles - .padding(.top, 24) + // Returning users (already funded) get the tile shortcuts + // below the activity; new users use the funnel's own steps + // instead. + if hasAddedMoney { + walletTiles + .padding(.top, 24) + } } + .opacity(deckOpacity) // Bottom inset so the last card clears the floating tab bar. Color.clear.frame(height: 96) } .padding(.horizontal, 20) } + .scrollDisabled(expandingMint != nil) } private var header: some View { @@ -256,3 +538,14 @@ private struct WalletScreenContent: View { return (cards, total, appreciation, session.hasEverAddedMoney(), session.hasEverTipped()) } } + +/// Circular Liquid Glass for the wallet's open-card chrome. +private struct WalletCircleGlass: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.glassEffect(.regular, in: .circle) + } else { + content.background(.ultraThinMaterial, in: Circle()) + } + } +}