Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions Flipcash/Core/Navigation/AppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,29 @@ final class AppRouter {
setPath([destination], on: targetStack)
}

/// Surfaces the wallet at its root — where a grabbed deposit lands after
/// "Put in Wallet".
///
/// Closes an expanded token card too: the wallet's own overlay is not a
/// pushed screen, so clearing the stack alone would leave it covering the
/// balance the deposit is about to move.
func showWallet() {
dismissExpandedCard()

// `requestedTabStack` covers the gap before `HomeTabView` selects the
// tab and publishes `activeTabStack` — until then the request is in
// flight, not yet arrived.
let alreadyThere = presentedSheets.isEmpty
&& activeTabStack == .balance
&& self[.balance].isEmpty
guard !alreadyThere, requestedTabStack != .balance else { return }

while !presentedSheets.isEmpty { dismissSheet() }
setPath([], on: .balance)
requestedTabStack = .balance
logger.info("Routed to wallet", metadata: ["stack": "\(Stack.balance)"])
}

/// Surfaces the user's own tip card: the You tab at its root.
///
/// Not expressible as `navigate(to:)` — the You tab's stack is entered by
Expand Down
14 changes: 13 additions & 1 deletion Flipcash/Core/Screens/Main/Bill/BillOverlayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ struct BillOverlayView: View {

private struct BillOverlayContent: View {

@Environment(AppRouter.self) private var router

@Bindable private var session: Session
private let sessionContainer: SessionContainer

Expand Down Expand Up @@ -68,7 +70,7 @@ private struct BillOverlayContent: View {
currencyName: valuation.mintMetadata?.name ?? "currency",
currencyImageURL: valuation.mintMetadata?.imageURL,
actionTitle: "Put in Wallet",
dismissAction: dismissBill
dismissAction: putInWallet
)
}
.interactiveDismissDisabled()
Expand Down Expand Up @@ -288,6 +290,16 @@ private struct BillOverlayContent: View {
}
}

/// Takes a grabbed deposit to the wallet: the bill comes down and the wallet
/// comes forward, where the balance is seen to rise.
private func putInWallet() {
// Released before the dismissal, which drops any deposit the user never
// asked to see.
session.walletDeposit.release()
session.dismissCashBill(style: .slide)
router.showWallet()
}

private func dismissBill() {
switch session.billState.bill {
case .tipcard:
Expand Down
21 changes: 20 additions & 1 deletion Flipcash/Core/Screens/Main/Home/TokenCardStack.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ struct TokenCardStack: View {
/// exactly and only the front card shows — the back cards hide completely
/// behind it (no slivers), Apple Wallet style.
static let defaultCollapsedReveal: CGFloat = 0
/// How far below its slot an arriving card starts. `nonisolated` because the
/// `visualEffect` closure that reads it is.
nonisolated static let arrivalRise: CGFloat = 40
/// How long (in scroll px) the fully-collapsed deck holds before releasing.
///
/// Zero: the deck collapses and then scrolls away like any other content.
Expand Down Expand Up @@ -65,6 +68,13 @@ struct TokenCardStack: View {
/// Extra displacement for the opened card, used to pick it up exactly where a
/// pull-to-close let go of it rather than at its open slot.
var openExtraOffset: CGFloat = 0
/// A card arriving with a deposit: it rises into its slot rather than simply
/// being there. `nil` when nothing is arriving.
var arrivingMint: PublicKey? = nil
/// How far that arrival has run: 0 is off-stage, 1 is settled. A scalar for
/// the same reason `expansionProgress` is one — a change of branch does not
/// interpolate.
var arrivalProgress: CGFloat = 1
/// 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 }
Expand Down Expand Up @@ -109,10 +119,19 @@ struct TokenCardStack: View {
// 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, openExtraOffset] content, proxy in
.visualEffect { [index, expandingIndex, containerHeight, expansionProgress, openTopInset, openExtraOffset, arrivingMint, arrivalProgress, mint = item.mint] content, proxy in
let rect = proxy.frame(in: .scrollView)
let fan = offset(for: index, stackTop: rect.minY)

// An arriving card rises into its slot from under the deck
// and fades up. Ahead of the expansion branches: a card
// still arriving cannot also be the one being opened.
if mint == arrivingMint, arrivalProgress < 1 {
return content
.offset(y: fan + Self.arrivalRise * (1 - arrivalProgress))
.opacity(Double(arrivalProgress))
}

guard let expandingIndex, expansionProgress > 0 else {
return content.offset(y: fan).opacity(1)
}
Expand Down
69 changes: 69 additions & 0 deletions Flipcash/Core/Screens/Main/Home/WalletDeposit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// WalletDeposit.swift
// Flipcash
//

import Observation
import FlipcashCore

/// Carries a grabbed deposit from the bill overlay across to the wallet, so the
/// wallet can be seen to receive it.
///
/// A grab refreshes `Session.balances` while the bill is still on screen, so by
/// the time "Put in Wallet" brings the wallet forward the rise has already
/// happened out of sight. This records what the wallet was showing beforehand;
/// the wallet rewinds to those figures and plays the rise on arrival.
@Observable
final class WalletDeposit {

/// A deposit that has reached the balances but has not yet been shown
/// arriving on the wallet.
struct Landing: Equatable {

/// The token the deposit arrived in.
let mint: PublicKey

/// The wallet's total before it arrived — where the rise starts.
let previousTotal: ExchangedFiat

/// The tokens the wallet was already showing a card for.
let previousMints: Set<PublicKey>

/// Whether the deposit brings a token the wallet has no card for yet.
var isNewToken: Bool { !previousMints.contains(mint) }
}

/// The deposit waiting to be played, or `nil` when none is in flight.
private(set) var landing: Landing?

/// Whether the user has asked for the wallet, releasing it to play.
private(set) var isReleased = false

/// The deposit the wallet has been released to play, or `nil` when nothing
/// is waiting on it.
var releasedLanding: Landing? { isReleased ? landing : nil }

/// Records what the wallet is showing, ahead of a deposit landing in the balances.
func arm(mint: PublicKey, previousTotal: ExchangedFiat, previousMints: Set<PublicKey>) {
landing = Landing(mint: mint, previousTotal: previousTotal, previousMints: previousMints)
isReleased = false
}

/// Releases the wallet to play the armed deposit — the user asked to put it in the wallet.
func release() {
guard landing != nil else { return }
isReleased = true
}

/// Drops a deposit that was never released, leaving the wallet to track balances as usual.
func discard() {
guard !isReleased else { return }
landing = nil
}

/// Clears the deposit once the wallet has played it.
func consume() {
landing = nil
isReleased = false
}
}
79 changes: 77 additions & 2 deletions Flipcash/Core/Screens/Main/Home/WalletScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ private struct WalletScreenContent: View {
/// The unified recent-activity preview (newest first).
@State private var recentActivities: [Activity]

/// The card the wallet is showing arrive with a deposit — it enters rather
/// than simply being there. `nil` when nothing is arriving.
@State private var arrivingMint: PublicKey?
/// How far that arrival has run: 0 is off-stage, 1 is settled.
@State private var arrivalProgress: CGFloat = 1

/// The card being opened. The deck reorganises around it and the detail
/// panel rises beneath it.
@State private var expandingMint: PublicKey?
Expand Down Expand Up @@ -141,7 +147,14 @@ private struct WalletScreenContent: View {
// empty-state flash.
let seed = Self.snapshot(session: sessionContainer.session, rate: rate)
_cards = State(initialValue: seed.cards)
_total = State(initialValue: seed.total)
// A released deposit can land before this view exists — the legacy tab
// path builds the wallet when the tab is selected, which is the moment
// "Put in Wallet" asks for. Seeded at the pre-deposit figures so the
// rise still has somewhere to start.
let landing = sessionContainer.session.walletDeposit.releasedLanding
_total = State(initialValue: landing?.previousTotal ?? seed.total)
_arrivingMint = State(initialValue: landing?.isNewToken == true ? landing?.mint : nil)
_arrivalProgress = State(initialValue: landing?.isNewToken == true ? 0 : 1)
_appreciation = State(initialValue: seed.appreciation)
_hasAddedMoney = State(initialValue: sessionContainer.session.hasEverAddedMoney())
_hasTipped = State(initialValue: sessionContainer.session.hasEverTipped())
Expand Down Expand Up @@ -204,6 +217,10 @@ private struct WalletScreenContent: View {
.onChange(of: router.requestedCardDismiss) { _, _ in
closeCard()
}
// Keyed on the deposit itself, so an arrival plays whether the
// wallet was already alive behind the bill (iOS 26 keeps every tab
// mounted) or is built on arrival.
.task(id: session.walletDeposit.releasedLanding?.mint) { await playDeposit() }
.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
Expand Down Expand Up @@ -504,6 +521,8 @@ private struct WalletScreenContent: View {
hiddenMint: hiddenMint,
openTopInset: WalletCardGeometry.openCardTopInset,
openExtraOffset: releasedPullOffset,
arrivingMint: arrivingMint,
arrivalProgress: arrivalProgress,
onCardTap: openCard
)
}
Expand Down Expand Up @@ -651,9 +670,65 @@ private struct WalletScreenContent: View {
}
}

// MARK: - Deposit

/// Whether a released deposit is still on its way to being shown.
private var isLandingDeposit: Bool { session.walletDeposit.releasedLanding != nil }

/// Shows a grabbed deposit arriving: the wallet rewinds to the figures it
/// was last seen with, then runs the balance up and brings in a card for a
/// token it had none for.
private func playDeposit() async {
guard let landing = session.walletDeposit.releasedLanding else { return }
let isNewToken = landing.isNewToken

// The balances already carry the deposit, so this is a step backwards —
// taken in one un-animated frame, under the bill that is still covering
// the wallet.
var rewind = Transaction()
rewind.disablesAnimations = true
withTransaction(rewind) {
total = landing.previousTotal
arrivingMint = isNewToken ? landing.mint : nil
arrivalProgress = isNewToken ? 0 : 1
}

// The bill is sliding down and its sheet coming off as this starts;
// running the rise underneath them spends it where it cannot be seen.
try? await Task.delay(milliseconds: Self.depositRevealDelay)
guard !Task.isCancelled else { return }

let snapshot = Self.snapshot(session: session, rate: rate)
withAnimation(.smooth(duration: Self.depositArrivalDuration), completionCriteria: .removed) {
cards = snapshot.cards
total = snapshot.total
appreciation = snapshot.appreciation
arrivalProgress = 1
} completion: {
arrivingMint = nil
// Held until here rather than cleared up front: while a landing is
// pending it is also what holds `refresh()` off the figures the
// animation is moving.
session.walletDeposit.consume()
// Picks up anything that moved while the figures were held.
refresh()
}
}

/// How long the wallet waits for the bill and its sheet to clear before
/// playing the arrival.
private static let depositRevealDelay = 450
/// How long the arriving card takes to rise into its slot. The balance rolls
/// on `BalanceHeaderButton`'s own animation, not this one.
private static let depositArrivalDuration: TimeInterval = 0.9

// MARK: - Data

private func refresh() {
// A landing owns the figures until it has played them; refreshing here
// would put the wallet back to the post-deposit total it is rewound
// from, cutting to the answer the rise is on its way to.
guard !isLandingDeposit else { return }
let snapshot = Self.snapshot(session: session, rate: rate)
withAnimation(.default) {
cards = snapshot.cards
Expand Down Expand Up @@ -692,7 +767,7 @@ private struct WalletScreenContent: View {
rate: Rate
) -> (cards: [TokenCardData], total: ExchangedFiat, appreciation: (amount: FiatAmount, isPositive: Bool)) {
let all = session.balances(for: rate)
let visible = all.filter { $0.stored.mint != .usdf || $0.exchangedFiat.hasDisplayableValue() }
let visible = Session.walletCardBalances(from: all)

let cards = visible.map { balance -> TokenCardData in
let (value, isPositive) = balance.stored.computeAppreciation(with: rate)
Expand Down
31 changes: 30 additions & 1 deletion Flipcash/Core/Session/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ class Session {
/// Post-transaction amount display, shown as a sheet.
var valuation: BillValuation? = nil

/// A grabbed deposit on its way to the wallet, held from the moment the
/// received bill is shown until the wallet has played its arrival.
let walletDeposit = WalletDeposit()

/// The currently visible balance-change toast, or `nil` when none is shown.
/// Set by ``consumeToast()`` and cleared after a 3-second display window.
var toast: Toast? = nil
Expand Down Expand Up @@ -146,6 +150,12 @@ class Session {
}
}

/// The balances the wallet shows a card for: every token it holds, and
/// Dollars only once they are worth showing.
static func walletCardBalances(from balances: [ExchangedBalance]) -> [ExchangedBalance] {
balances.filter { $0.stored.mint != .usdf || $0.exchangedFiat.hasDisplayableValue() }
}

func balance(for mint: PublicKey) -> StoredBalance? {
// Avoid the display-ordered sort in `balances` — this is called per
// SwiftUI body re-eval from amount-entry computed props.
Expand Down Expand Up @@ -1181,6 +1191,10 @@ class Session {
func showCashBill(_ billDescription: BillDescription) {
// Only inbound bills enqueue a "+$" deposit toast; sent bills don't.
if billDescription.received {
// Armed here rather than after the grab's balance refresh: that
// refresh is what the wallet needs the figures from *before*.
armWalletDeposit(mint: billDescription.exchangedFiat.mint)

enqueue(toast: .init(
amount: billDescription.exchangedFiat.nativeAmount,
isDeposit: true
Expand Down Expand Up @@ -1424,12 +1438,27 @@ class Session {
presentationState = .hidden(style)
billState = .default()
valuation = nil
// A bill that went away without the user asking for the wallet has
// nothing left to play there.
walletDeposit.discard()

// Consume toast after bill state is cleared
// so isShowingBill returns false
consumeToast()
}


/// Records the wallet's current figures against an incoming deposit in `mint`.
private func armWalletDeposit(mint: PublicKey) {
let rate = ratesController.rateForBalanceCurrency()
let balances = balances(for: rate)

walletDeposit.arm(
mint: mint,
previousTotal: balances.map(\.exchangedFiat).total(rate: rate),
previousMints: Set(Self.walletCardBalances(from: balances).map(\.stored.mint))
)
}

// MARK: - Cash Links -

private func createCashLink(payload: CashCode.Payload, exchangedFiat: ExchangedFiat, verifiedState: VerifiedState) async throws -> GiftCardCluster {
Expand Down
Loading