From 5fb160914efd6c3f308f6ecb1b3dbda8becac306 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 11 Aug 2026 16:49:51 -0400 Subject: [PATCH 1/6] feat(wallet): bill-color token cards + collapsing stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port Android's TokenCard/TokenCardStack: - TokenCardView — a bill-style card painting a horizontal gradient from the token's bill-customization colors (USDF's fixed gold, a dark-green fallback), with the token icon + name, an appreciation pill, and an autosizing balance. - TokenCardStack — a fanning deck that collapses then scrolls off as the list scrolls, driven by a scrolled-past offset. Reimplemented with offset placement (not SwiftUI's Layout) to keep the iOS 15 deployment target. - Session.billColors(for:) resolves a mint's stored bill palette. --- .../Screens/Main/Home/TokenCardStack.swift | 63 ++++++++ .../Screens/Main/Home/TokenCardView.swift | 136 ++++++++++++++++++ Flipcash/Core/Session/Session.swift | 14 ++ 3 files changed, 213 insertions(+) create mode 100644 Flipcash/Core/Screens/Main/Home/TokenCardStack.swift create mode 100644 Flipcash/Core/Screens/Main/Home/TokenCardView.swift diff --git a/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift b/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift new file mode 100644 index 000000000..80c5c47ae --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/TokenCardStack.swift @@ -0,0 +1,63 @@ +// +// TokenCardStack.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore + +/// A vertical stack of ``TokenCardView``s that fans out (each card revealing its +/// `fannedReveal` header) and **collapses, then scrolls off** as the enclosing +/// scroll view scrolls up: cards pin at the top and tighten from the fanned gap +/// to `collapsedReveal` (a growing deck); once fully collapsed the deck releases +/// and scrolls off with the rest of the content. Ported from Android's +/// `TokenCardStack` custom `Layout` — reimplemented with `offset` placement so it +/// works on the iOS 15 deployment target (which predates SwiftUI's `Layout`). +/// +/// The measured height is always the *fanned* height, so the enclosing scroll +/// view's range is stable — cards are only repositioned, never resized. +/// `scrolledPast` is the px the stack's top has scrolled above the viewport top; +/// the parent measures it and passes it in (see `WalletScreen`). +struct TokenCardStack: View { + + let items: [TokenCardData] + var cardHeight: CGFloat = 224 + var fannedReveal: CGFloat = 64 + var collapsedReveal: CGFloat = 12 + var pinInset: CGFloat = 0 + var scrolledPast: CGFloat = 0 + var onCardTap: (TokenCardData) -> Void = { _ in } + + /// Always the fanned height, so the scroll range stays stable while cards collapse. + private var stackHeight: CGFloat { + items.isEmpty ? 0 : cardHeight + fannedReveal * CGFloat(items.count - 1) + } + + /// Scroll distance at which every card has finished collapsing (the last pins last). + private var collapseComplete: CGFloat { + max(0, CGFloat(items.count - 1) * (fannedReveal - collapsedReveal) - pinInset) + } + + var body: some View { + // Cap `past` at collapseComplete: once fully collapsed the deck stops + // pinning and the frozen layout scrolls off with the content. + let past = min(max(scrolledPast, 0), collapseComplete) + ZStack(alignment: .top) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + let fannedY = CGFloat(index) * fannedReveal + let pinnedY = past + pinInset + CGFloat(index) * collapsedReveal + Button { + onCardTap(item) + } label: { + TokenCardView(data: item, height: cardHeight) + } + .buttonStyle(.plain) + .offset(y: max(fannedY, pinnedY)) + // Cards drawn front-to-back so the last (highest-value) sits on top. + .zIndex(Double(index)) + } + } + .frame(maxWidth: .infinity) + .frame(height: stackHeight, alignment: .top) + } +} diff --git a/Flipcash/Core/Screens/Main/Home/TokenCardView.swift b/Flipcash/Core/Screens/Main/Home/TokenCardView.swift new file mode 100644 index 000000000..31e4b19b8 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/TokenCardView.swift @@ -0,0 +1,136 @@ +// +// TokenCardView.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// One token's data for a ``TokenCardView`` / ``TokenCardStack``. +struct TokenCardData: Identifiable, Equatable { + let mint: PublicKey + let name: String + let imageURL: URL? + let balanceText: String + /// Signed appreciation, already formatted (e.g. "+$2.84"); nil hides the pill. + let appreciationText: String? + /// The token's bill-customization colors (`#RRGGBB`). Empty → dark-green fallback. + let colors: [String] + let isUSDF: Bool + + var id: PublicKey { mint } +} + +/// A reusable bill-style card for a single token (Figma frame 8966:99811, ported +/// from Android's `TokenCard`). The background is a horizontal gradient painted +/// from the token's bill-customization colors — the same palette users pick in +/// the currency creator — with USDF's fixed gold branding and a dark-green +/// fallback for tokens with no customization. The header shows the token icon + +/// name (leading) and an optional appreciation pill + balance (trailing). +/// +/// These stack: render several with a negative vertical spacing so only each +/// card's header shows, like ``TokenCardStack``. +struct TokenCardView: View { + + let name: String + let imageURL: URL? + let balanceText: String + /// Signed appreciation, already formatted (e.g. "+$2.84"); nil hides the pill. + let appreciationText: String? + /// The token's bill-customization colors (`#RRGGBB`). Empty → dark-green fallback. + let colors: [String] + let isUSDF: Bool + + var height: CGFloat = 224 + + init(name: String, imageURL: URL?, balanceText: String, appreciationText: String?, colors: [String], isUSDF: Bool, height: CGFloat = 224) { + self.name = name + self.imageURL = imageURL + self.balanceText = balanceText + self.appreciationText = appreciationText + self.colors = colors + self.isUSDF = isUSDF + self.height = height + } + + init(data: TokenCardData, height: CGFloat = 224) { + self.init( + name: data.name, + imageURL: data.imageURL, + balanceText: data.balanceText, + appreciationText: data.appreciationText, + colors: data.colors, + isUSDF: data.isUSDF, + height: height + ) + } + + private static let cornerRadius: CGFloat = 20 + private static let fallback = Color(hex: "#06450F")! + private static let usdfGradient = [Color(hex: "#C4980B")!, Color(hex: "#B06B00")!] + + private var gradient: LinearGradient { + let stops: [Color] + if isUSDF { + stops = Self.usdfGradient + } else { + let parsed = colors.compactMap(Color.init(hex:)) + switch parsed.count { + case 0: stops = [Self.fallback, Self.fallback] + case 1: stops = [parsed[0], parsed[0]] + default: stops = parsed + } + } + return LinearGradient(colors: stops, startPoint: .leading, endPoint: .trailing) + } + + var body: some View { + RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .fill(gradient) + .overlay( + RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .strokeBorder(Color.white.opacity(0.10), lineWidth: 1) + ) + .frame(maxWidth: .infinity) + .frame(height: height) + .overlay(alignment: .top) { header.padding(16) } + } + + private var header: some View { + HStack(spacing: 8) { + if let imageURL { + RemoteImage(url: imageURL) + .frame(width: 24, height: 24) + .clipShape(Circle()) + } + Text(name) + .font(.appTextSmall) + .foregroundStyle(Color.white) + .lineLimit(1) + + Spacer(minLength: 8) + + if let appreciationText { + Text(appreciationText) + .font(.appTextSmall) + .foregroundStyle(Color.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(Color.white, lineWidth: 1) + ) + .layoutPriority(1) + } + + Text(balanceText) + .font(.appDisplaySmall) + .fontWeight(.bold) + .foregroundStyle(Color.white) + .lineLimit(1) + .minimumScaleFactor(0.5) + .contentTransition(.numericText()) + } + } +} diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 7529509d4..6b166c5ee 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -152,6 +152,20 @@ class Session { updateableBalances.value.first { $0.mint == mint } } + /// The mint's bill-customization colors (the `#RRGGBB` palette users pick in + /// the currency creator), used to paint its token card. Empty when the mint + /// has no stored metadata or no customization. + func billColors(for mint: PublicKey) -> [String] { + guard + let json = (try? database.getMintMetadata(mint: mint))?.billColors, + let data = json.data(using: .utf8), + let colors = try? JSONDecoder().decode([String].self, from: data) + else { + return [] + } + return colors + } + /// True when the user has at least one non-USDF balance with a displayable /// fiat value. Skips the sort + allocate that `balances(for:)` does, so /// callers gating a presentation pay only the early-exit predicate cost. From 840e62874b1db06c973e020c0527241269dd7c3f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 11 Aug 2026 16:49:51 -0400 Subject: [PATCH 2/6] feat(wallet): v2 WalletScreen with the token card stack The v2 Wallet tab now renders the big-balance header (reused BalanceHeaderButton + appreciation) over the collapsing TokenCardStack, tapping a card into currency info, with an add-money affordance and empty state. Balances are projected to card data once per change (resolving each token's bill colors) rather than per body eval. HomeTabView's Wallet tab switches from the embedded BalanceScreen to WalletScreen. The appreciation pill always shows; a rounds-to-zero value reads as positive (+$0.00), never -$0.00. --- .../Core/Screens/Main/BalanceScreen.swift | 2 +- .../Core/Screens/Main/Home/HomeTabView.swift | 2 +- .../Core/Screens/Main/Home/WalletScreen.swift | 204 ++++++++++++++++++ 3 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 Flipcash/Core/Screens/Main/Home/WalletScreen.swift diff --git a/Flipcash/Core/Screens/Main/BalanceScreen.swift b/Flipcash/Core/Screens/Main/BalanceScreen.swift index 889f9695a..920ac40ba 100644 --- a/Flipcash/Core/Screens/Main/BalanceScreen.swift +++ b/Flipcash/Core/Screens/Main/BalanceScreen.swift @@ -268,7 +268,7 @@ extension StoredBalance { } } -private struct BalanceHeaderButton: View { +struct BalanceHeaderButton: View { let balance: ExchangedFiat @Environment(RatesController.self) private var ratesController diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 36c099cc2..741fb017b 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -47,7 +47,7 @@ struct HomeTabView: View { case .scan: ScanScreen(isEmbedded: true) case .wallet: - BalanceScreen(isEmbedded: true) + WalletScreen() case .chat: ChatTab() case .tipCard: diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift new file mode 100644 index 000000000..7831e3214 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -0,0 +1,204 @@ +// +// WalletScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashUI +import FlipcashCore + +/// The v2 Wallet tab: the big balance header, per-token bill cards in a +/// collapsing ``TokenCardStack``, and an add-money affordance. Replaces the v1 +/// ``BalanceScreen`` list in the tab-bar UI. Owns its own `NavigationStack` bound +/// to `router[.balance]`, so the existing push destinations (currency info, +/// transaction history) work unchanged. +struct WalletScreen: View { + + @Environment(SessionContainer.self) private var sessionContainer + + var body: some View { + WalletScreenContent(sessionContainer: sessionContainer) + } +} + +/// Bubbles the token stack's top offset (px scrolled above the viewport top) up +/// to the scroll view so the stack can collapse as it scrolls. +private struct StackScrollKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } +} + +private struct WalletScreenContent: View { + + @Environment(AppRouter.self) private var router + @Environment(RatesController.self) private var ratesController + @Environment(HistoryController.self) private var historyController + + let session: Session + + @State private var cards: [TokenCardData] = [] + @State private var total: ExchangedFiat + @State private var appreciation: (amount: FiatAmount, isPositive: Bool) + @State private var scrolledPast: CGFloat = 0 + + private static let scrollSpace = "walletScroll" + + init(sessionContainer: SessionContainer) { + self.session = sessionContainer.session + let rate = sessionContainer.ratesController.rateForBalanceCurrency() + // Seed synchronously so the first render shows real balances, not an + // empty-state flash (mirrors BalanceScreen). + let seed = Self.snapshot(session: sessionContainer.session, rate: rate) + _cards = State(initialValue: seed.cards) + _total = State(initialValue: seed.total) + _appreciation = State(initialValue: seed.appreciation) + } + + private var rate: Rate { ratesController.rateForBalanceCurrency() } + + var body: some View { + @Bindable var router = router + NavigationStack(path: $router[.balance]) { + Background(color: .backgroundMain) { + Group { + if cards.isEmpty { + emptyState + } else { + walletContent + } + } + } + .navigationTitle("Wallet") + .toolbarTitleDisplayMode(.inline) + .appRouterDestinations() + .onAppear { historyController.sync() } + .onChange(of: session.balances) { _, _ in refresh() } + .onChange(of: rate) { _, _ in refresh() } + } + } + + // MARK: - Content + + private var walletContent: some View { + ScrollView { + VStack(spacing: 0) { + header + .padding(.vertical, 30) + + TokenCardStack( + items: cards, + scrolledPast: scrolledPast, + onCardTap: { router.push(.currencyInfo($0.mint)) } + ) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: StackScrollKey.self, + value: -proxy.frame(in: .named(Self.scrollSpace)).minY + ) + } + ) + + addMoneyButton + .padding(.top, 24) + + // Bottom inset so the last card clears the floating tab bar. + Color.clear.frame(height: 96) + } + .padding(.horizontal, 20) + } + .coordinateSpace(name: Self.scrollSpace) + .onPreferenceChange(StackScrollKey.self) { scrolledPast = max(0, $0) } + } + + private var header: some View { + VStack(spacing: 4) { + BalanceHeaderButton(balance: total) + .frame(height: 60) + ValueAppreciation(amount: appreciation.amount, isPositive: appreciation.isPositive) + } + } + + private var addMoneyButton: some View { + Button { + router.presentAddMoney(.general, source: .balance) + } label: { + HStack(spacing: 6) { + Image(systemName: "plus.circle") + Text("Add Money") + } + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + + private var emptyState: some View { + VStack(spacing: 10) { + Text("No Balance Yet") + .font(.appTextLarge) + Text("Add money to get started") + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + BubbleButton(text: "Add Money") { + router.presentAddMoney(.general, source: .balance) + } + .padding(.top, 8) + } + .padding(.horizontal, 40) + } + + // MARK: - Data + + private func refresh() { + let snapshot = Self.snapshot(session: session, rate: rate) + withAnimation(.default) { + cards = snapshot.cards + total = snapshot.total + appreciation = snapshot.appreciation + } + } + + /// Pure balance → view-data projection, shared by the synchronous seed and + /// `refresh()`. Resolves each token's bill colors once (a DB read per token) + /// rather than per body evaluation. + private static func snapshot( + session: Session, + 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 cards = visible.map { balance -> TokenCardData in + let (value, isPositive) = balance.stored.computeAppreciation(with: rate) + // The pill always shows (per Figma). A sub-cent value rounds to + // "$0.00" and reads as positive, so a tiny negative never renders "-$0.00". + let roundsToZero = value.nativeAmount.value < 0.005 + let appreciationText = (isPositive || roundsToZero ? "+" : "-") + value.nativeAmount.formatted() + return TokenCardData( + mint: balance.stored.mint, + name: balance.stored.name, + imageURL: balance.stored.imageURL, + balanceText: balance.exchangedFiat.nativeAmount.formatted(), + appreciationText: appreciationText, + colors: session.billColors(for: balance.stored.mint), + isUSDF: balance.stored.mint == .usdf + ) + } + + let total = all.map(\.exchangedFiat).total(rate: rate) + + var net: Decimal = 0 + for balance in all { + let (value, isPositive) = balance.stored.computeAppreciation(with: rate) + net += isPositive ? value.nativeAmount.value : -value.nativeAmount.value + } + let appreciation = ( + amount: FiatAmount(value: abs(net), currency: rate.currency), + isPositive: net >= 0 + ) + + return (cards, total, appreciation) + } +} From 27c8ac37343e35ba968bf20f37c8a1dc8935de0b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 11 Aug 2026 16:58:19 -0400 Subject: [PATCH 3/6] fix(wallet): drop the v2 wallet top bar; robust scroll-collapse tracking - Hide the navigation bar on the v2 Wallet root (per Figma) so the balance header sits directly under the status bar; pushed destinations keep theirs. - Rework the TokenCardStack scroll tracking from a named coordinate space (which wasn't updating scrolledPast, so the deck never collapsed) to global coordinates: an outer GeometryReader captures the fixed viewport top and the stack reports its global top as it scrolls, giving a reliable scrolled-past offset that drives the collapse. --- .../Core/Screens/Main/Home/WalletScreen.swift | 68 ++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index 7831e3214..5f67e1738 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -41,8 +41,6 @@ private struct WalletScreenContent: View { @State private var appreciation: (amount: FiatAmount, isPositive: Bool) @State private var scrolledPast: CGFloat = 0 - private static let scrollSpace = "walletScroll" - init(sessionContainer: SessionContainer) { self.session = sessionContainer.session let rate = sessionContainer.ratesController.rateForBalanceCurrency() @@ -68,8 +66,10 @@ private struct WalletScreenContent: View { } } } - .navigationTitle("Wallet") - .toolbarTitleDisplayMode(.inline) + // 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. + .toolbar(.hidden, for: .navigationBar) .appRouterDestinations() .onAppear { historyController.sync() } .onChange(of: session.balances) { _, _ in refresh() } @@ -80,35 +80,41 @@ private struct WalletScreenContent: View { // MARK: - Content private var walletContent: some View { - ScrollView { - VStack(spacing: 0) { - header - .padding(.vertical, 30) - - TokenCardStack( - items: cards, - scrolledPast: scrolledPast, - onCardTap: { router.push(.currencyInfo($0.mint)) } - ) - .background( - GeometryReader { proxy in - Color.clear.preference( - key: StackScrollKey.self, - value: -proxy.frame(in: .named(Self.scrollSpace)).minY - ) - } - ) - - addMoneyButton - .padding(.top, 24) - - // Bottom inset so the last card clears the floating tab bar. - Color.clear.frame(height: 96) + // Track scroll via global coordinates: `viewportTop` is the scroll + // container's fixed top; the stack's global top drops below it as the + // list scrolls up, and that difference (clamped ≥ 0) is how far the stack + // has scrolled past the top — what drives the collapse. + GeometryReader { outer in + let viewportTop = outer.frame(in: .global).minY + ScrollView { + VStack(spacing: 0) { + header + .padding(.vertical, 30) + + TokenCardStack( + items: cards, + scrolledPast: scrolledPast, + onCardTap: { router.push(.currencyInfo($0.mint)) } + ) + .background( + GeometryReader { proxy in + Color.clear.preference( + key: StackScrollKey.self, + value: max(0, viewportTop - proxy.frame(in: .global).minY) + ) + } + ) + + addMoneyButton + .padding(.top, 24) + + // Bottom inset so the last card clears the floating tab bar. + Color.clear.frame(height: 96) + } + .padding(.horizontal, 20) } - .padding(.horizontal, 20) + .onPreferenceChange(StackScrollKey.self) { scrolledPast = $0 } } - .coordinateSpace(name: Self.scrollSpace) - .onPreferenceChange(StackScrollKey.self) { scrolledPast = max(0, $0) } } private var header: some View { From f0ad2c54b11d6c63f48da47473a618ac591d52c0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 11 Aug 2026 17:22:58 -0400 Subject: [PATCH 4/6] fix(wallet): make the card stack actually collapse on scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The collapse never fired because a GeometryReader in scroll content only emits its preference at layout time, not during scroll (confirmed: the offset preference fired exactly once and stayed 0 through a full scroll) — so scrolledPast was always 0. Drive it instead from the underlying UIScrollView.contentOffset via KVO (ScrollOffsetReader), which updates every scroll frame. The collapse threshold is the measured header height, so the deck tightens once the stack reaches the top. --- .../Core/Screens/Main/Home/WalletScreen.swift | 115 +++++++++++++----- 1 file changed, 84 insertions(+), 31 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index 5f67e1738..9f97efffd 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -4,6 +4,7 @@ // import SwiftUI +import UIKit import FlipcashUI import FlipcashCore @@ -21,10 +22,10 @@ struct WalletScreen: View { } } -/// Bubbles the token stack's top offset (px scrolled above the viewport top) up -/// to the scroll view so the stack can collapse as it scrolls. -private struct StackScrollKey: PreferenceKey { - static var defaultValue: CGFloat = 0 +/// The header block's height — the scroll distance at which the stack reaches +/// the top and the collapse begins. Measured once at layout (scroll-independent). +private struct HeaderHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 150 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } @@ -40,6 +41,11 @@ private struct WalletScreenContent: View { @State private var total: ExchangedFiat @State private var appreciation: (amount: FiatAmount, isPositive: Bool) @State private var scrolledPast: CGFloat = 0 + /// The header block's height — the scroll distance before the stack reaches + /// the top. Collapse starts past this. + @State private var headerHeight: CGFloat = 150 + /// The scroll view's content offset at rest, captured on the first KVO tick. + @State private var restOffset: CGFloat? init(sessionContainer: SessionContainer) { self.session = sessionContainer.session @@ -80,41 +86,42 @@ private struct WalletScreenContent: View { // MARK: - Content private var walletContent: some View { - // Track scroll via global coordinates: `viewportTop` is the scroll - // container's fixed top; the stack's global top drops below it as the - // list scrolls up, and that difference (clamped ≥ 0) is how far the stack - // has scrolled past the top — what drives the collapse. - GeometryReader { outer in - let viewportTop = outer.frame(in: .global).minY - ScrollView { - VStack(spacing: 0) { - header - .padding(.vertical, 30) - - TokenCardStack( - items: cards, - scrolledPast: scrolledPast, - onCardTap: { router.push(.currencyInfo($0.mint)) } - ) + ScrollView { + VStack(spacing: 0) { + header + .padding(.vertical, 30) + // The header's height is scroll-independent, so this fires once + // at layout — it's the distance the stack sits below the top. .background( GeometryReader { proxy in - Color.clear.preference( - key: StackScrollKey.self, - value: max(0, viewportTop - proxy.frame(in: .global).minY) - ) + Color.clear.preference(key: HeaderHeightKey.self, value: proxy.size.height) } ) - addMoneyButton - .padding(.top, 24) + TokenCardStack( + items: cards, + scrolledPast: scrolledPast, + onCardTap: { router.push(.currencyInfo($0.mint)) } + ) - // Bottom inset so the last card clears the floating tab bar. - Color.clear.frame(height: 96) - } - .padding(.horizontal, 20) + addMoneyButton + .padding(.top, 24) + + // Bottom inset so the last card clears the floating tab bar. + Color.clear.frame(height: 96) } - .onPreferenceChange(StackScrollKey.self) { scrolledPast = $0 } + .padding(.horizontal, 20) + // The underlying UIScrollView's contentOffset updates on every scroll + // frame — SwiftUI preferences in scroll content only fire at layout — + // so it, not a GeometryReader, is what drives the collapse. Once the + // scroll passes the header (the stack reaches the top), the excess is + // how far the stack has scrolled past the top. + .background(ScrollOffsetReader { offsetY in + if restOffset == nil { restOffset = offsetY } + scrolledPast = max(0, (offsetY - (restOffset ?? 0)) - headerHeight) + }) } + .onPreferenceChange(HeaderHeightKey.self) { headerHeight = $0 } } private var header: some View { @@ -208,3 +215,49 @@ private struct WalletScreenContent: View { return (cards, total, appreciation) } } + +/// Reports the enclosing `UIScrollView`'s vertical content offset on every scroll +/// frame. SwiftUI preferences on scroll content only fire at layout time (not +/// during scroll), so this KVO bridge is what makes the card stack collapse. +private struct ScrollOffsetReader: UIViewRepresentable { + + let onChange: (CGFloat) -> Void + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.isUserInteractionEnabled = false + // The scroll view isn't in the hierarchy yet during makeUIView; defer. + DispatchQueue.main.async { context.coordinator.attach(from: view) } + return view + } + + func updateUIView(_ uiView: UIView, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(onChange: onChange) } + + // KVO on `contentOffset` fires on the main thread for a UIScrollView; the + // unchecked conformance documents that the stored closure is only ever + // touched there. + final class Coordinator: @unchecked Sendable { + private let onChange: (CGFloat) -> Void + private var observation: NSKeyValueObservation? + + init(onChange: @escaping (CGFloat) -> Void) { self.onChange = onChange } + + func attach(from view: UIView) { + var current: UIView? = view.superview + while let candidate = current { + if let scrollView = candidate as? UIScrollView { + observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] _, change in + guard let self, let y = change.newValue?.y else { return } + MainActor.assumeIsolated { self.onChange(y) } + } + return + } + current = candidate.superview + } + } + + deinit { observation?.invalidate() } + } +} From 673c0a4f1b459b86acb572f58ef00de3591b13bb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 12 Aug 2026 09:42:27 -0400 Subject: [PATCH 5/6] fix(ui): size the tab bar to spec (32pt icons, 58pt bar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bar was undersized: 26pt icons in a ~50pt bar. Match the Figma tab bar (node 8966:1557) — 32pt icons in 50pt items (9pt vertical padding) inside the 4pt-padded capsule, for a 58pt overall height. --- Flipcash/Core/Screens/Main/Home/HomeTabBar.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift b/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift index 74029c7f9..da9ca959d 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabBar.swift @@ -16,9 +16,10 @@ struct HomeTabBar: View { private let tabs = HomeTab.allCases - /// Vertical padding inside the capsule around each item. - private let itemVerticalPadding: CGFloat = 8 - private let iconSize: CGFloat = 26 + // Figma tab bar (node 8966:1557): 32pt icons in 50pt-tall items (9pt above + // and below), inside a capsule with 4pt padding → 58pt overall. + private let itemVerticalPadding: CGFloat = 9 + private let iconSize: CGFloat = 32 private var itemHeight: CGFloat { iconSize + itemVerticalPadding * 2 } From 613d21c3547d060c2eb4e0c7e5fb25ca97cdc3a9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 12 Aug 2026 09:48:34 -0400 Subject: [PATCH 6/6] fix(ui): narrow the tab bar to the Figma pill width Inset the bar ~42pt per side (was 20pt) so it renders ~318pt wide on the 402pt reference frame, matching the floating pill in the Figma. --- Flipcash/Core/Screens/Main/Home/HomeTabView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 741fb017b..d3b331f96 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -29,7 +29,10 @@ struct HomeTabView: View { .transition(.opacity) HomeTabBar(selection: $selection) - .padding(.horizontal, 20) + // Figma insets the pill ~42pt from each edge (318pt wide on the + // 402pt frame); a fixed margin keeps the floating look across + // device widths. + .padding(.horizontal, 42) .padding(.bottom, 8) } .background(Color.backgroundMain)