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
33 changes: 33 additions & 0 deletions Flipcash/Core/Controllers/Database/Database+Onboarding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// Database+Onboarding.swift
// Flipcash
//

import Foundation
import FlipcashCore
import SQLite

/// Durable-history checks backing the wallet onboarding funnel. These read the
/// event history (not the current balance), so a milestone stays complete even
/// after the user later spends the balance — mirroring Android.
nonisolated extension Database {

/// True once a completed deposit or buy exists — the "added money" milestone.
func hasEverAddedMoney() throws -> Bool {
let a = ActivityTable()
let funded = [Activity.Kind.deposited.rawValue, Activity.Kind.bought.rawValue]
return try reader.pluck(
a.table.filter(funded.contains(a.kind) && a.state == Activity.State.completed.rawValue)
) != nil
}

/// True once the caller has *sent* a tip — the "scanned a tip card" milestone.
/// Received tips don't count, so it's scoped to `senderId == selfUserID`.
func hasEverTipped(selfUserID: UserID) throws -> Bool {
let m = ConversationMessageTable()
// cashAction 1 == tipped (see ConversationMessageTable.cashAction).
return try reader.pluck(
m.table.filter(m.cashAction == 1 && m.senderId == selfUserID)
) != nil
}
}
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Main/Home/HomeTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ struct HomeTabView: View {
case .scan:
ScanScreen(isEmbedded: true)
case .wallet:
WalletScreen()
WalletScreen(onScanTipCard: { selection = .scan })
case .chat:
ChatTab()
case .tipCard:
Expand Down
126 changes: 126 additions & 0 deletions Flipcash/Core/Screens/Main/Home/OnboardingFunnel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//
// OnboardingFunnel.swift
// Flipcash
//

import SwiftUI
import FlipcashUI

/// A step in the wallet onboarding funnel (Figma frame 8966:1516, ported from
/// Android's `OnboardingItem`).
enum OnboardingItem: Identifiable, Equatable {
case addMoney(isCompleted: Bool)
case scanTipCard(isCompleted: Bool)

var id: String { title }

var isCompleted: Bool {
switch self {
case .addMoney(let done), .scanTipCard(let done): return done
}
}

var title: String {
switch self {
case .addMoney: return "Add Money"
case .scanTipCard: return "Scan a Tip Card"
}
}

var subtitle: String {
switch self {
case .addMoney: return "Add money to your account"
case .scanTipCard: return "Give your first tip"
}
}
}

/// The "Send Your First Tip" onboarding funnel shown on the v2 Wallet: a title +
/// completed-count, then a card of tappable steps. Completed steps show a green
/// check, dim, and stop responding to taps. Ported from Android's
/// `OnboardingFunnel`.
struct OnboardingFunnelView: View {

let title: String
let items: [OnboardingItem]
let onTap: (OnboardingItem) -> Void

private var completedCount: Int { items.filter(\.isCompleted).count }

var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text(title)
.font(.appTextLarge)
.foregroundStyle(Color.textMain)
Spacer()
Text("\(completedCount) / \(items.count)")
.font(.appTextSmall)
.foregroundStyle(Color.textSecondary)
}
.padding(.horizontal, 16)

VStack(spacing: 0) {
ForEach(items) { item in
row(item)
}
}
.background(Color.white.opacity(0.05))
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
}
}

private func row(_ item: OnboardingItem) -> some View {
Button {
onTap(item)
} label: {
HStack(spacing: 12) {
icon(item)
.frame(width: 24, height: 24)

VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.appTextMedium)
.foregroundStyle(Color.textMain)
Text(item.subtitle)
.font(.appTextSmall)
.foregroundStyle(Color.textSecondary)
}
.opacity(item.isCompleted ? 0.38 : 1)

Spacer(minLength: 8)

Image(systemName: "chevron.right")
.font(.appTextSmall)
.foregroundStyle(Color.textSecondary)
}
.padding(16)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(item.isCompleted)
}

@ViewBuilder private func icon(_ item: OnboardingItem) -> some View {
if item.isCompleted {
Image(systemName: "checkmark.circle.fill")
.resizable()
.scaledToFit()
.foregroundStyle(Color.Sentiment.positive)
} else {
switch item {
case .addMoney:
Image(systemName: "plus.circle")
.resizable()
.scaledToFit()
.foregroundStyle(Color.textMain)
case .scanTipCard:
Image("NavScan")
.renderingMode(.template)
.resizable()
.scaledToFit()
.foregroundStyle(Color.textMain)
}
}
}
}
105 changes: 64 additions & 41 deletions Flipcash/Core/Screens/Main/Home/WalletScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ struct WalletScreen: View {

@Environment(SessionContainer.self) private var sessionContainer

/// Invoked by the funnel's "Scan a Tip Card" step to switch to the Scan tab.
let onScanTipCard: () -> Void

var body: some View {
WalletScreenContent(sessionContainer: sessionContainer)
WalletScreenContent(sessionContainer: sessionContainer, onScanTipCard: onScanTipCard)
}
}

Expand All @@ -36,41 +39,47 @@ private struct WalletScreenContent: View {
@Environment(HistoryController.self) private var historyController

let session: Session
let onScanTipCard: () -> Void

@State private var cards: [TokenCardData] = []
@State private var total: ExchangedFiat
@State private var appreciation: (amount: FiatAmount, isPositive: Bool)
@State private var hasAddedMoney: Bool
@State private var hasTipped: 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.
/// The height of everything above the card stack (header + funnel) — the
/// scroll distance before the stack reaches the top. Collapse starts past it.
@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) {
init(sessionContainer: SessionContainer, onScanTipCard: @escaping () -> Void) {
self.session = sessionContainer.session
self.onScanTipCard = onScanTipCard
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)
_hasAddedMoney = State(initialValue: seed.hasAddedMoney)
_hasTipped = State(initialValue: seed.hasTipped)
}

private var rate: Rate { ratesController.rateForBalanceCurrency() }

private var isOnboardingComplete: Bool { hasAddedMoney && hasTipped }

private var onboardingItems: [OnboardingItem] {
[.addMoney(isCompleted: hasAddedMoney), .scanTipCard(isCompleted: hasTipped)]
}

var body: some View {
@Bindable var router = router
NavigationStack(path: $router[.balance]) {
Background(color: .backgroundMain) {
Group {
if cards.isEmpty {
emptyState
} else {
walletContent
}
}
walletContent
}
// No top bar on the wallet root (per Figma) — the balance header
// sits directly under the status bar. Pushed destinations restore
Expand All @@ -88,24 +97,42 @@ private struct WalletScreenContent: View {
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)) }
// Header + funnel scroll off before the stack collapses, so their
// combined height (scroll-independent, measured once at layout) is
// the collapse threshold.
VStack(spacing: 0) {
header
.padding(.vertical, 30)

if !isOnboardingComplete {
OnboardingFunnelView(
title: "Send Your First Tip",
items: onboardingItems,
onTap: handleOnboardingTap
)
.padding(.bottom, 20)
}
}
.background(
GeometryReader { proxy in
Color.clear.preference(key: HeaderHeightKey.self, value: proxy.size.height)
}
)

addMoneyButton
.padding(.top, 24)
if !cards.isEmpty {
TokenCardStack(
items: cards,
scrolledPast: scrolledPast,
onCardTap: { router.push(.currencyInfo($0.mint)) }
)
}

// Returning users (already funded) get a plain add-money row; new
// users use the funnel's own "Add Money" step instead.
if hasAddedMoney {
addMoneyButton
.padding(.top, 24)
}

// Bottom inset so the last card clears the floating tab bar.
Color.clear.frame(height: 96)
Expand Down Expand Up @@ -147,19 +174,13 @@ private struct WalletScreenContent: View {
.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)
private func handleOnboardingTap(_ item: OnboardingItem) {
switch item {
case .addMoney:
router.presentAddMoney(.general, source: .balance)
case .scanTipCard:
onScanTipCard()
}
.padding(.horizontal, 40)
}

// MARK: - Data
Expand All @@ -170,6 +191,8 @@ private struct WalletScreenContent: View {
cards = snapshot.cards
total = snapshot.total
appreciation = snapshot.appreciation
hasAddedMoney = snapshot.hasAddedMoney
hasTipped = snapshot.hasTipped
}
}

Expand All @@ -179,7 +202,7 @@ private struct WalletScreenContent: View {
private static func snapshot(
session: Session,
rate: Rate
) -> (cards: [TokenCardData], total: ExchangedFiat, appreciation: (amount: FiatAmount, isPositive: Bool)) {
) -> (cards: [TokenCardData], total: ExchangedFiat, appreciation: (amount: FiatAmount, isPositive: Bool), hasAddedMoney: Bool, hasTipped: Bool) {
let all = session.balances(for: rate)
let visible = all.filter { $0.stored.mint != .usdf || $0.exchangedFiat.hasDisplayableValue() }

Expand Down Expand Up @@ -212,7 +235,7 @@ private struct WalletScreenContent: View {
isPositive: net >= 0
)

return (cards, total, appreciation)
return (cards, total, appreciation, session.hasEverAddedMoney(), session.hasEverTipped())
}
}

Expand Down
12 changes: 12 additions & 0 deletions Flipcash/Core/Session/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ class Session {
updateableBalances.value.first { $0.mint == mint }
}

/// Whether the caller has ever added money (a completed deposit or buy) —
/// the wallet onboarding "add money" milestone. Derived from durable history,
/// so it stays true after the balance is spent.
func hasEverAddedMoney() -> Bool {
(try? database.hasEverAddedMoney()) ?? false
}

/// Whether the caller has ever sent a tip — the "scan a tip card" milestone.
func hasEverTipped() -> Bool {
(try? database.hasEverTipped(selfUserID: userID)) ?? false
}

/// 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.
Expand Down