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/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 } diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index 36c099cc2..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) @@ -47,7 +50,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/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/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift new file mode 100644 index 000000000..9f97efffd --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -0,0 +1,263 @@ +// +// WalletScreen.swift +// Flipcash +// + +import SwiftUI +import UIKit +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) + } +} + +/// 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() } +} + +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 + /// 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 + 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 + } + } + } + // 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() } + .onChange(of: rate) { _, _ in refresh() } + } + } + + // MARK: - Content + + private var walletContent: some View { + 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: HeaderHeightKey.self, value: proxy.size.height) + } + ) + + TokenCardStack( + items: cards, + scrolledPast: scrolledPast, + onCardTap: { router.push(.currencyInfo($0.mint)) } + ) + + addMoneyButton + .padding(.top, 24) + + // Bottom inset so the last card clears the floating tab bar. + Color.clear.frame(height: 96) + } + .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 { + 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) + } +} + +/// 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() } + } +} 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.