Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Main/BalanceScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ extension StoredBalance {
}
}

private struct BalanceHeaderButton: View {
struct BalanceHeaderButton: View {
let balance: ExchangedFiat

@Environment(RatesController.self) private var ratesController
Expand Down
7 changes: 4 additions & 3 deletions Flipcash/Core/Screens/Main/Home/HomeTabBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
7 changes: 5 additions & 2 deletions Flipcash/Core/Screens/Main/Home/HomeTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -47,7 +50,7 @@ struct HomeTabView: View {
case .scan:
ScanScreen(isEmbedded: true)
case .wallet:
BalanceScreen(isEmbedded: true)
WalletScreen()
case .chat:
ChatTab()
case .tipCard:
Expand Down
63 changes: 63 additions & 0 deletions Flipcash/Core/Screens/Main/Home/TokenCardStack.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
136 changes: 136 additions & 0 deletions Flipcash/Core/Screens/Main/Home/TokenCardView.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}
}
Loading