diff --git a/Flipcash/Core/Controllers/Database/Database+Onboarding.swift b/Flipcash/Core/Controllers/Database/Database+Onboarding.swift new file mode 100644 index 000000000..4ac2653df --- /dev/null +++ b/Flipcash/Core/Controllers/Database/Database+Onboarding.swift @@ -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 + } +} diff --git a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift index d3b331f96..b51249ac2 100644 --- a/Flipcash/Core/Screens/Main/Home/HomeTabView.swift +++ b/Flipcash/Core/Screens/Main/Home/HomeTabView.swift @@ -50,7 +50,7 @@ struct HomeTabView: View { case .scan: ScanScreen(isEmbedded: true) case .wallet: - WalletScreen() + WalletScreen(onScanTipCard: { selection = .scan }) case .chat: ChatTab() case .tipCard: diff --git a/Flipcash/Core/Screens/Main/Home/OnboardingFunnel.swift b/Flipcash/Core/Screens/Main/Home/OnboardingFunnel.swift new file mode 100644 index 000000000..33f71c147 --- /dev/null +++ b/Flipcash/Core/Screens/Main/Home/OnboardingFunnel.swift @@ -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) + } + } + } +} diff --git a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift index 9f97efffd..651381fe0 100644 --- a/Flipcash/Core/Screens/Main/Home/WalletScreen.swift +++ b/Flipcash/Core/Screens/Main/Home/WalletScreen.swift @@ -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) } } @@ -36,19 +39,23 @@ 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). @@ -56,21 +63,23 @@ private struct WalletScreenContent: View { _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 @@ -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) @@ -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 @@ -170,6 +191,8 @@ private struct WalletScreenContent: View { cards = snapshot.cards total = snapshot.total appreciation = snapshot.appreciation + hasAddedMoney = snapshot.hasAddedMoney + hasTipped = snapshot.hasTipped } } @@ -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() } @@ -212,7 +235,7 @@ private struct WalletScreenContent: View { isPositive: net >= 0 ) - return (cards, total, appreciation) + return (cards, total, appreciation, session.hasEverAddedMoney(), session.hasEverTipped()) } } diff --git a/Flipcash/Core/Session/Session.swift b/Flipcash/Core/Session/Session.swift index 6b166c5ee..f2241e11d 100644 --- a/Flipcash/Core/Session/Session.swift +++ b/Flipcash/Core/Session/Session.swift @@ -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.