diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoContentV2.swift index cca8ebd0..b224a478 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,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. @@ -50,6 +78,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 +90,34 @@ 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) - 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) @@ -74,7 +130,8 @@ struct CurrencyInfoContentV2: View { CurrencyInfoMarketCapSection( marketCap: viewModel.marketCap, currencyCode: ratesController.balanceCurrency, - marketCapController: marketCapController + marketCapController: marketCapController, + isReady: !defersHeavyContent ) } @@ -92,6 +149,8 @@ struct CurrencyInfoContentV2: View { .padding(.top, 8) .padding(.horizontal, 20) } + } + .opacity(contentOpacity) } .padding(.top, 8) .padding(.bottom, 40) @@ -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) } @@ -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 { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift index 60be4ff2..95dc0146 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoMarketCapSection.swift @@ -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) { @@ -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 @@ -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) { diff --git a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift index fbd7a088..db0c584b 100644 --- a/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift +++ b/Flipcash/Core/Screens/Main/Currency Info/CurrencyInfoScreen.swift @@ -16,18 +16,44 @@ 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, holding the card the wallet was showing. + /// Draws its own back button, which calls the closure. + /// + /// The host opens the screen by displacing the hero card down to where + /// the wallet's deck was holding it and animating `heroOffset` back to + /// zero, while `contentOpacity` brings the rest of the screen in behind + /// it. The card is the screen's own from the first frame — it is never a + /// copy laid over the top — so it scrolls with the content immediately + /// and there is no hand-over to mistime. + case overlay(onClose: () -> Void, heroOffset: CGFloat = 0, contentOpacity: Double = 1) + /// 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 +63,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 +90,48 @@ 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 + } + + /// How far the hero card is displaced from its resting slot while the host + /// animates it in. + private var heroOffset: CGFloat { + if case .overlay(_, let offset, _) = presentation { return offset } + return 0 + } + + /// Everything except the hero card, which the host brings in behind it. + private var contentOpacity: Double { + if case .overlay(_, _, let opacity) = presentation { return opacity } + return 1 + } + + /// The wallet keeps its own card above an inline-hosted screen. + private var showsHeroCard: Bool { + if case .inline = presentation { return false } + 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 +139,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 +160,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 +178,9 @@ private struct CurrencyInfoScreenContent: View { ), container: container, sessionContainer: sessionContainer, - showBuyOnAppear: showBuyOnAppear + showBuyOnAppear: showBuyOnAppear, + presentation: presentation, + defersHeavyContent: defersHeavyContent ) } @@ -134,7 +214,11 @@ private struct CurrencyInfoScreenContent: View { withAnimation(.easeInOut(duration: 0.2)) { showsToolbarTitle = scrolledPast } - } + }, + showsHeroCard: showsHeroCard, + heroOffset: heroOffset, + contentOpacity: contentOpacity, + defersHeavyContent: defersHeavyContent ) } else { LoadedContent( @@ -168,6 +252,20 @@ 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, opacity: contentOpacity)) + .overlay(alignment: .top) { + if overlayClose != nil { + overlayChrome + .frame(height: Self.overlayBarHeight) + // Arrives with the rest of the screen; only the card itself + // is held at full strength through the opening. + .opacity(contentOpacity) + } + } + .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 +318,65 @@ 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 + + /// Close + title pill + share. Used only when hosted as an overlay. + /// + /// A close box rather than a back chevron: the screen is lifted over the + /// wallet rather than pushed onto it, and it does not offer the edge-swipe + /// that a chevron would imply. + @ViewBuilder private var overlayChrome: some View { + HStack(spacing: 8) { + Button { + overlayClose?() + } label: { + Image(systemName: "xmark") + .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) + .accessibilityLabel("Close") + .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 +426,67 @@ 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 + /// Matches the rest of the screen's arrival, so the fade does not darken the + /// card before the screen behind it exists. + var opacity: Double = 1 + + /// 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) + .opacity(opacity) + .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..756b18f9 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? + /// How far the page's hero card is displaced from its resting slot. Seeded + /// with the tapped card's position in the deck and animated to zero, so the + /// card the page shows *is* the card that travels — there is no copy laid + /// over the top, and it belongs to the page's scroll view the whole time. + @State private var heroOffset: 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 + /// Brings the page in behind the travelling card: everything except the + /// card itself, which is continuous. + @State private var pageContentOpacity: Double = 0 + /// Whether the page exists yet. Split from its fade 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,35 @@ 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, + heroOffset: heroOffset, + contentOpacity: pageContentOpacity + ), + defersHeavyContent: !heavyContentReady + ) + .id(expandingMint) + // Fades in behind the card rather than sliding, which would + // drag the chrome and tiles in from the bottom. The deck is + // clearing underneath, so nothing shows through. + .background(Color.backgroundMain.opacity(pageContentOpacity)) + // Deliberately no edge-swipe back. The screen is lifted over + // the wallet rather than pushed onto it, and its chrome says + // so with a close box instead of a back chevron. + } + } + .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 +174,171 @@ 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. + // Deliberately not watching the stack depth to decide whether the + // card is still open. The opened card is an overlay, not a stack + // entry, so an empty stack is its *normal* state — pushing the full + // transaction history and popping back returns the depth to zero, + // which a depth watcher reads as "back at the wallet" and closes the + // card out from under the screen the user was returning to. } + .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) { + // The page's own hero card starts over the deck slot the tapped + // card occupies, so the two coincide exactly on the first frame. + heroOffset = (currentTop - safeArea.top) - WalletCardGeometry.openCardTopInset + pageContentOpacity = 0 + 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) { + heroOffset = 0 + expansionProgress = 1 + deckOpacity = 0 + } completion: { + guard token == transitionToken else { return } + // 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)) { + pageContentOpacity = 1 + } + } + } + + /// 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 + pageContentOpacity = 0 + 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 +363,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 +520,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()) + } + } +}