From 2b15e57c33899c5c9caa17c697980dc7c5f0b255 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Thu, 1 Jan 2026 14:37:34 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Baccarat/Baccarat/BaccaratApp.swift | 6 +- .../Baccarat/Models/WalkthroughTags.swift | 189 ++++++++ .../Baccarat/Resources/Localizable.xcstrings | 417 +++++++++++++++++ .../Views/Game/ActionButtonsView.swift | 1 + .../Baccarat/Views/Game/GameTableView.swift | 100 ++--- .../Baccarat/Views/Sheets/RulesHelpView.swift | 12 + .../Views/Table/BettingTableView.swift | 1 + Baccarat/README.md | 4 +- Blackjack/Blackjack/BlackjackApp.swift | 6 +- .../Blackjack/Models/WalkthroughTags.swift | 189 ++++++++ .../Blackjack/Resources/Localizable.xcstrings | 421 +++++++++++++++++- .../Views/Game/ActionButtonsView.swift | 2 + .../Blackjack/Views/Game/GameTableView.swift | 87 ++-- .../Views/Sheets/RulesHelpView.swift | 12 + .../Views/Table/BlackjackTableView.swift | 2 + Blackjack/README.md | 4 +- CasinoKit/Package.swift | 4 + CasinoKit/README.md | 105 ++--- CasinoKit/Sources/CasinoKit/Exports.swift | 11 +- .../CasinoKit/Models/TooltipManager.swift | 114 ----- .../CasinoKit/Resources/Localizable.xcstrings | 3 - .../CasinoKit/Views/Bars/TopBarView.swift | 233 +++++++--- .../CasinoKit/Views/ContextualTooltip.swift | 164 ------- ONBOARDING_IMPLEMENTATION.md | 355 ++++++++------- TOOLTIP_REFACTORING.md | 246 ---------- 25 files changed, 1746 insertions(+), 942 deletions(-) create mode 100644 Baccarat/Baccarat/Models/WalkthroughTags.swift create mode 100644 Blackjack/Blackjack/Models/WalkthroughTags.swift delete mode 100644 CasinoKit/Sources/CasinoKit/Models/TooltipManager.swift delete mode 100644 CasinoKit/Sources/CasinoKit/Views/ContextualTooltip.swift delete mode 100644 TOOLTIP_REFACTORING.md diff --git a/Baccarat/Baccarat/BaccaratApp.swift b/Baccarat/Baccarat/BaccaratApp.swift index 4850f15..c226e5f 100644 --- a/Baccarat/Baccarat/BaccaratApp.swift +++ b/Baccarat/Baccarat/BaccaratApp.swift @@ -11,8 +11,10 @@ import CasinoKit struct BaccaratApp: App { var body: some Scene { WindowGroup { - AppLaunchView(config: .baccarat) { - ContentView() + SherpaContainerView(configuration: .default) { + AppLaunchView(config: .baccarat) { + ContentView() + } } } } diff --git a/Baccarat/Baccarat/Models/WalkthroughTags.swift b/Baccarat/Baccarat/Models/WalkthroughTags.swift new file mode 100644 index 0000000..4579ed6 --- /dev/null +++ b/Baccarat/Baccarat/Models/WalkthroughTags.swift @@ -0,0 +1,189 @@ +// +// WalkthroughTags.swift +// Baccarat +// +// Defines the walkthrough steps for onboarding new players. +// + +import SwiftUI +import CasinoKit + +/// Walkthrough steps for Baccarat onboarding. +enum BaccaratWalkthroughTags: SherpaTags { + // MARK: - Top Bar Elements + + /// Shows the current balance + case balance + + /// Shows remaining cards in the shoe + case cardsRemaining + + /// Opens statistics sheet + case statsButton + + /// Opens rules/help sheet + case rulesButton + + /// Opens settings sheet + case settingsButton + + /// Shows the road map history display + case history + + // MARK: - Gameplay Elements + + /// Introduce the betting zones (Player, Banker, Tie) + case bettingZone + + /// Explain the betting hints showing patterns and tips + case bettingHint + + /// Explain the chip selector for choosing bet amounts + case chipSelector + + /// Show the deal button to start the round + case dealButton + + func makeCallout() -> Callout { + switch self { + // Top Bar + case .balance: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "dollarsign.circle.fill", + text: String(localized: "walkthrough.balance"), + onTap: onTap + ) + } + + case .cardsRemaining: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "rectangle.portrait.on.rectangle.portrait.fill", + text: String(localized: "walkthrough.cardsRemaining"), + onTap: onTap + ) + } + + case .statsButton: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "chart.bar.fill", + text: String(localized: "walkthrough.statsButton"), + onTap: onTap + ) + } + + case .rulesButton: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "info.circle", + text: String(localized: "walkthrough.rulesButton"), + onTap: onTap + ) + } + + case .settingsButton: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "gearshape.fill", + text: String(localized: "walkthrough.settingsButton"), + onTap: onTap + ) + } + + case .history: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "clock.fill", + text: String(localized: "walkthrough.history"), + onTap: onTap + ) + } + + // Gameplay + case .bettingZone: + return .custom(edge: .top) { onTap in + WalkthroughCalloutView( + icon: "hand.tap.fill", + text: String(localized: "walkthrough.bettingZone"), + onTap: onTap + ) + } + + case .bettingHint: + return .custom(edge: .top) { onTap in + WalkthroughCalloutView( + icon: "lightbulb.fill", + text: String(localized: "walkthrough.bettingHint"), + onTap: onTap + ) + } + + case .chipSelector: + return .custom(edge: .top) { onTap in + WalkthroughCalloutView( + icon: "circle.grid.2x2.fill", + text: String(localized: "walkthrough.chipSelector"), + onTap: onTap + ) + } + + case .dealButton: + return .custom(edge: .top) { onTap in + WalkthroughCalloutView( + icon: "play.fill", + text: String(localized: "walkthrough.dealButton"), + onTap: onTap + ) + } + } + } +} + +// MARK: - Custom Callout View + +/// Fully custom callout view with dark background and white text +struct WalkthroughCalloutView: View { + let icon: String + let text: String + let onTap: () -> Void + + // Maximum width to prevent edge clipping + private let maxCalloutWidth: CGFloat = 320 + + var body: some View { + Button(action: onTap) { + HStack(spacing: Design.Spacing.small) { + Image(systemName: icon) + .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) + .foregroundStyle(.yellow) + + Text(text) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + .multilineTextAlignment(.leading) + .lineLimit(2) + + Spacer(minLength: 0) + + Text(String(localized: "walkthrough.close")) + .font(.system(size: Design.BaseFontSize.small, weight: .semibold)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .padding(Design.Spacing.medium) + .frame(maxWidth: maxCalloutWidth) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.black.opacity(Design.Opacity.almostFull)) + .shadow( + color: .black.opacity(Design.Opacity.medium), + radius: Design.Shadow.radiusLarge, + y: Design.Shadow.offsetMedium + ) + ) + } + .buttonStyle(.plain) + .padding(.horizontal, Design.Spacing.xLarge) + } +} diff --git a/Baccarat/Baccarat/Resources/Localizable.xcstrings b/Baccarat/Baccarat/Resources/Localizable.xcstrings index 39b353e..4a579b0 100644 --- a/Baccarat/Baccarat/Resources/Localizable.xcstrings +++ b/Baccarat/Baccarat/Resources/Localizable.xcstrings @@ -1983,6 +1983,29 @@ } } }, + "Game Sessions" : { + "comment" : "Title for the Game Sessions help page", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Game Sessions" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sesiones de Juego" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sessions de Jeu" + } + } + } + }, "GAME STATS" : { "comment" : "Section in the statistics sheet dedicated to displaying statistics specific to baccarat.", "localizations" : { @@ -3620,6 +3643,7 @@ }, "Results appear here, then in the road maps below" : { "comment" : "Instructional text for new players.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3736,6 +3760,7 @@ }, "Select a chip and tap a bet zone" : { "comment" : "Onboarding hint for placing bets.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3803,6 +3828,144 @@ } } }, + "sessions.autoStart" : { + "comment" : "Game Sessions help - auto start explanation", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A new session starts automatically when you begin playing." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Una nueva sesión comienza automáticamente cuando empiezas a jugar." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une nouvelle session démarre automatiquement lorsque vous commencez à jouer." + } + } + } + }, + "sessions.endSession" : { + "comment" : "Game Sessions help - end session explanation", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End your session anytime from the Statistics screen." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termina tu sesión en cualquier momento desde la pantalla de Estadísticas." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminez votre session à tout moment depuis l'écran Statistiques." + } + } + } + }, + "sessions.history" : { + "comment" : "Game Sessions help - history explanation", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View your complete session history to see past performance." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consulta tu historial completo de sesiones para ver tu rendimiento pasado." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consultez votre historique complet de sessions pour voir vos performances passées." + } + } + } + }, + "sessions.likeRealCasino" : { + "comment" : "Game Sessions help - like real casino", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Track your play just like at a real casino table!" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Rastrea tu juego como en una mesa de casino real!" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivez votre jeu comme à une vraie table de casino !" + } + } + } + }, + "sessions.trackProgress" : { + "comment" : "Game Sessions help - track progress", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sessions track your playing time, hands, wins, losses, and chip balance." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las sesiones rastrean tu tiempo de juego, manos, victorias, derrotas y saldo de fichas." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les sessions suivent votre temps de jeu, mains, victoires, défaites et solde de jetons." + } + } + } + }, + "sessions.viewStats" : { + "comment" : "Game Sessions help - view stats", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the chart icon to view your current session stats." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca el ícono de gráfico para ver las estadísticas de tu sesión actual." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez sur l'icône de graphique pour voir les statistiques de votre session actuelle." + } + } + } + }, "Set a budget and stick to it." : { "comment" : "Tip for players to set a budget and stick to it when playing baccarat.", "localizations" : { @@ -4238,6 +4401,7 @@ }, "Tap Deal to start the round" : { "comment" : "Instructional text for new players.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4787,6 +4951,259 @@ } } }, + "walkthrough.balance" : { + "comment" : "Walkthrough hint for the balance display", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your current balance is shown here" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu saldo actual se muestra aquí" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre solde actuel est affiché ici" + } + } + } + }, + "walkthrough.bettingHint" : { + "comment" : "Walkthrough hint for the betting hint display", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tips based on patterns and trends" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consejos según patrones y tendencias" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conseils selon les tendances" + } + } + } + }, + "walkthrough.bettingZone" : { + "comment" : "Walkthrough hint for the betting zone", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a chip and tap a bet zone" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecciona una ficha y toca una zona de apuesta" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionnez un jeton et touchez une zone de mise" + } + } + } + }, + "walkthrough.cardsRemaining" : { + "comment" : "Walkthrough hint for cards remaining display", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cards remaining in the shoe" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cartas restantes en el zapato" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cartes restantes dans le sabot" + } + } + } + }, + "walkthrough.chipSelector" : { + "comment" : "Walkthrough hint for the chip selector", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a chip value to bet" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige el valor de la ficha para apostar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez une valeur de jeton pour miser" + } + } + } + }, + "walkthrough.close" : { + "comment" : "Walkthrough button to close/advance to next step", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer" + } + } + } + }, + "walkthrough.dealButton" : { + "comment" : "Walkthrough hint for the deal button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap Deal to start the round" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca Repartir para comenzar la ronda" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Touchez Distribuer pour commencer la manche" + } + } + } + }, + "walkthrough.history" : { + "comment" : "Walkthrough hint for the road map history display", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Track game results and spot trends" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rastrea resultados y detecta tendencias" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivez les résultats et repérez les tendances" + } + } + } + }, + "walkthrough.rulesButton" : { + "comment" : "Walkthrough hint for the rules/help button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Learn the rules and how to play" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aprende las reglas y cómo jugar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apprenez les règles et comment jouer" + } + } + } + }, + "walkthrough.settingsButton" : { + "comment" : "Walkthrough hint for the settings button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Customize game rules and options" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personaliza las reglas y opciones del juego" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personnalisez les règles et options du jeu" + } + } + } + }, + "walkthrough.statsButton" : { + "comment" : "Walkthrough hint for the statistics button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View session stats and history" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ver estadísticas e historial" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voir statistiques et historique" + } + } + } + }, "WIN" : { "comment" : "The text that appears as a badge when a player wins a hand in baccarat.", "localizations" : { diff --git a/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift b/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift index 9cf245e..ac4be5f 100644 --- a/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift +++ b/Baccarat/Baccarat/Views/Game/ActionButtonsView.swift @@ -61,6 +61,7 @@ struct ActionButtonsView: View { onClear: onClear, onDeal: onDeal ) + .sherpaTag(BaccaratWalkthroughTags.dealButton) } @ViewBuilder diff --git a/Baccarat/Baccarat/Views/Game/GameTableView.swift b/Baccarat/Baccarat/Views/Game/GameTableView.swift index cd3b2ca..8e82dbc 100644 --- a/Baccarat/Baccarat/Views/Game/GameTableView.swift +++ b/Baccarat/Baccarat/Views/Game/GameTableView.swift @@ -9,7 +9,7 @@ import SwiftUI import CasinoKit /// The main game table view containing all game elements. -struct GameTableView: View { +struct GameTableView: View, SherpaDelegate { @State private var settings = GameSettings() @State private var gameState: GameState? @State private var selectedChip: ChipDenomination = .hundred @@ -18,10 +18,10 @@ struct GameTableView: View { @State private var showStats = false @State private var showWelcome = false - // MARK: - Onboarding State + // MARK: - Walkthrough State - /// Tooltip manager for contextual hints - @State private var tooltipManager: TooltipManager? + /// Whether the Sherpa walkthrough is active + @State private var isWalkthroughActive = false /// Screen size for card sizing (measured from TableBackgroundView) @State private var screenSize: CGSize = CGSize(width: 375, height: 667) @@ -85,6 +85,17 @@ struct GameTableView: View { // Use global debug flag from Design constants private var showDebugBorders: Bool { Design.showDebugBorders } + /// Sherpa tags for the top bar elements + private var topBarSherpaTags: TopBarSherpaTags { + TopBarSherpaTags( + balance: .balance, + cardsRemaining: .cardsRemaining, + stats: .statsButton, + rules: .rulesButton, + settings: .settingsButton + ) + } + // MARK: - Body var body: some View { @@ -109,9 +120,6 @@ struct GameTableView: View { if gameState == nil { gameState = GameState(settings: settings) } - if tooltipManager == nil { - tooltipManager = TooltipManager(onboarding: state.onboarding) - } checkForWelcomeSheet() } .sheet(isPresented: $showSettings) { @@ -161,30 +169,17 @@ struct GameTableView: View { ], onboarding: state.onboarding, onDismiss: { showWelcome = false }, - onShowHints: checkOnboardingHints + onShowHints: startWalkthrough ) } .onChange(of: showWelcome) { wasShowing, isShowing in - // Handle swipe-down dismissal: treat as "Start Playing" (no tooltips) + // Handle swipe-down dismissal: treat as "Start Playing" (no walkthrough) if wasShowing && !isShowing && !state.onboarding.hasCompletedWelcome { state.onboarding.skipOnboarding() } } - .onChange(of: state.totalBetAmount) { _, newTotal in - if newTotal > 0, state.onboarding.shouldShowHint("dealButton") { - showDealHintWithDelay() - } - } - .onChange(of: state.currentPhase) { oldPhase, newPhase in - if newPhase == .showingResult, oldPhase != newPhase { - if state.onboarding.shouldShowHint("firstResult") { - showResultHintWithDelay() - } - } - } - - // Dynamic tooltip display - .dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: state.onboarding)) + // Sherpa walkthrough modifier + .sherpa(isActive: isWalkthroughActive, tags: BaccaratWalkthroughTags.self, delegate: self) } // MARK: - Onboarding Helpers @@ -198,36 +193,29 @@ struct GameTableView: View { } } - private func checkOnboardingHints() { - if state.onboarding.shouldShowHint("bettingZone") { - tooltipManager?.show( - key: "bettingZone", - message: String(localized: "Select a chip and tap a bet zone"), - icon: "hand.tap.fill", - position: .bottom, - delay: 1.0 - ) - } + /// Starts the Sherpa walkthrough when user taps "Show Me How" + private func startWalkthrough() { + // Reset onboarding hints so walkthrough can be seen again + state.onboarding.reset() + state.onboarding.completeWelcome() + isWalkthroughActive = true } - private func showDealHintWithDelay() { - tooltipManager?.show( - key: "dealButton", - message: String(localized: "Tap Deal to start the round"), - icon: "play.fill", - position: .bottom, - delay: 0.5 - ) + // MARK: - SherpaDelegate + + /// Returns nil to hide skip button and progress indicator + func accessoryView(sherpa: Sherpa) -> AnyView? { + nil } - private func showResultHintWithDelay() { - tooltipManager?.show( - key: "firstResult", - message: String(localized: "Results appear here, then in the road maps below"), - icon: "chart.bar.fill", - position: .bottom, - delay: 2.0 - ) + func onWalkthroughComplete(sherpa: Sherpa) { + isWalkthroughActive = false + state.onboarding.completeWelcome() + } + + func onWalkthroughSkipped(sherpa: Sherpa, atStep: Int, totalSteps: Int) { + isWalkthroughActive = false + state.onboarding.completeWelcome() } // MARK: - Private Views @@ -256,7 +244,8 @@ struct GameTableView: View { secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil, onSettings: { showSettings = true }, onHelp: { showRules = true }, - onStats: { showStats = true } + onStats: { showStats = true }, + sherpaTags: topBarSherpaTags ) .debugBorder(showDebugBorders, color: .cyan, label: "TopBar") @@ -288,6 +277,7 @@ struct GameTableView: View { Spacer(minLength: 0) } + .sherpaTag(BaccaratWalkthroughTags.history) .frame(width: 240) .background( RoundedRectangle(cornerRadius: Design.CornerRadius.medium) @@ -341,6 +331,7 @@ struct GameTableView: View { secondaryInfo: hintInfo.secondaryText, style: hintInfo.style ) + .sherpaTag(BaccaratWalkthroughTags.bettingHint) .transition(.opacity) .padding(.vertical, Design.Spacing.small) .debugBorder(showDebugBorders, color: .purple, label: "Hint") @@ -356,6 +347,7 @@ struct GameTableView: View { currentBet: state.minBetForChipSelector, maxBet: state.maxBet ) + .sherpaTag(BaccaratWalkthroughTags.chipSelector) .transition(.opacity.combined(with: .move(edge: .bottom))) .debugBorder(showDebugBorders, color: .pink, label: "ChipSelector") } @@ -399,7 +391,8 @@ struct GameTableView: View { secondaryIcon: settings.showCardsRemaining ? "rectangle.portrait.on.rectangle.portrait.fill" : nil, onSettings: { showSettings = true }, onHelp: { showRules = true }, - onStats: { showStats = true } + onStats: { showStats = true }, + sherpaTags: topBarSherpaTags ) .debugBorder(showDebugBorders, color: .cyan, label: "TopBar") @@ -430,6 +423,7 @@ struct GameTableView: View { // Road map history - show in portrait before deal on larger screens if settings.showHistory && !isSmallScreen && !isDealing { RoadMapView(results: state.recentResults) + .sherpaTag(BaccaratWalkthroughTags.history) .frame(maxWidth: isLargeScreen ? maxContentWidth : .infinity) .padding(.horizontal, Design.Spacing.medium) .debugBorder(showDebugBorders, color: .orange, label: "RoadMap") @@ -454,6 +448,7 @@ struct GameTableView: View { secondaryInfo: hintInfo.secondaryText, style: hintInfo.style ) + .sherpaTag(BaccaratWalkthroughTags.bettingHint) .transition(.opacity) .padding(.vertical, Design.Spacing.small) .debugBorder(showDebugBorders, color: .purple, label: "Hint") @@ -467,6 +462,7 @@ struct GameTableView: View { currentBet: state.minBetForChipSelector, maxBet: state.maxBet ) + .sherpaTag(BaccaratWalkthroughTags.chipSelector) .transition(.opacity.combined(with: .move(edge: .bottom))) .debugBorder(showDebugBorders, color: .pink, label: "ChipSelector") } diff --git a/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift b/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift index de655ec..5b0a68d 100644 --- a/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift +++ b/Baccarat/Baccarat/Views/Sheets/RulesHelpView.swift @@ -23,6 +23,18 @@ struct RulesHelpView: View { String(localized: "Baccarat has one of the lowest house edges in the casino.") ] ), + RulePage( + title: String(localized: "Game Sessions"), + icon: "clock.badge.checkmark.fill", + content: [ + String(localized: "sessions.trackProgress"), + String(localized: "sessions.autoStart"), + String(localized: "sessions.viewStats"), + String(localized: "sessions.endSession"), + String(localized: "sessions.history"), + String(localized: "sessions.likeRealCasino") + ] + ), RulePage( title: String(localized: "Card Values"), icon: "suit.spade.fill", diff --git a/Baccarat/Baccarat/Views/Table/BettingTableView.swift b/Baccarat/Baccarat/Views/Table/BettingTableView.swift index b508270..71e1042 100644 --- a/Baccarat/Baccarat/Views/Table/BettingTableView.swift +++ b/Baccarat/Baccarat/Views/Table/BettingTableView.swift @@ -163,6 +163,7 @@ struct BettingTableView: View { ) ) .shadow(color: .black.opacity(Design.Opacity.light), radius: Design.Shadow.radiusLarge, y: Design.Shadow.offsetLarge) + .sherpaTag(BaccaratWalkthroughTags.bettingZone) } .debugBorder(showDebugBorders, color: .orange, label: "BettingTable") } diff --git a/Baccarat/README.md b/Baccarat/README.md index 1876247..44c297a 100644 --- a/Baccarat/README.md +++ b/Baccarat/README.md @@ -10,8 +10,8 @@ A feature-rich Baccarat (Punto Banco) app for iOS built with SwiftUI. Experience ### 🎓 First-Time User Experience - **Welcome Sheet** — Interactive introduction on first launch -- **Tutorial Mode** — Optional guided walkthrough of your first round -- **Contextual Hints** — Tips appear at the right moment during gameplay +- **Spotlight Walkthrough** — Guided tour with spotlight focus on key UI elements +- **Sherpa Framework** — Modern walkthrough with progress indicators and haptic feedback - **Never Intrusive** — All onboarding is skippable and shown only once ### 🎰 Authentic Punto Banco Gameplay diff --git a/Blackjack/Blackjack/BlackjackApp.swift b/Blackjack/Blackjack/BlackjackApp.swift index 13ab216..cda8501 100644 --- a/Blackjack/Blackjack/BlackjackApp.swift +++ b/Blackjack/Blackjack/BlackjackApp.swift @@ -18,8 +18,10 @@ struct BlackjackApp: App { var body: some Scene { WindowGroup { - AppLaunchView(config: .blackjack) { - ContentView() + SherpaContainerView(configuration: .default) { + AppLaunchView(config: .blackjack) { + ContentView() + } } } } diff --git a/Blackjack/Blackjack/Models/WalkthroughTags.swift b/Blackjack/Blackjack/Models/WalkthroughTags.swift new file mode 100644 index 0000000..f4a32fb --- /dev/null +++ b/Blackjack/Blackjack/Models/WalkthroughTags.swift @@ -0,0 +1,189 @@ +// +// WalkthroughTags.swift +// Blackjack +// +// Defines the walkthrough steps for onboarding new players. +// + +import SwiftUI +import CasinoKit + +/// Walkthrough steps for Blackjack onboarding. +enum BlackjackWalkthroughTags: SherpaTags { + // MARK: - Top Bar Elements + + /// Shows the current balance + case balance + + /// Shows remaining cards in the shoe + case cardsRemaining + + /// Opens statistics sheet + case statsButton + + /// Opens rules/help sheet + case rulesButton + + /// Opens settings sheet + case settingsButton + + // MARK: - Gameplay Elements + + /// Introduce the betting zone where chips are placed + case bettingZone + + /// Explain the betting hints based on card count + case bettingHint + + /// Explain the chip selector for choosing bet amounts + case chipSelector + + /// Show the deal button to start the round + case dealButton + + /// Explain player actions during the hand + case playerActions + + func makeCallout() -> Callout { + switch self { + // Top Bar + case .balance: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "dollarsign.circle.fill", + text: String(localized: "walkthrough.balance"), + onTap: onTap + ) + } + + case .cardsRemaining: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "rectangle.portrait.on.rectangle.portrait.fill", + text: String(localized: "walkthrough.cardsRemaining"), + onTap: onTap + ) + } + + case .statsButton: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "chart.bar.fill", + text: String(localized: "walkthrough.statsButton"), + onTap: onTap + ) + } + + case .rulesButton: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "info.circle", + text: String(localized: "walkthrough.rulesButton"), + onTap: onTap + ) + } + + case .settingsButton: + return .custom(edge: .bottom) { onTap in + WalkthroughCalloutView( + icon: "gearshape.fill", + text: String(localized: "walkthrough.settingsButton"), + onTap: onTap + ) + } + + // Gameplay + case .bettingZone: + return .custom(edge: .top) { onTap in + WalkthroughCalloutView( + icon: "hand.tap.fill", + text: String(localized: "walkthrough.bettingZone"), + onTap: onTap + ) + } + + case .bettingHint: + return .custom(edge: .top) { onTap in + WalkthroughCalloutView( + icon: "lightbulb.fill", + text: String(localized: "walkthrough.bettingHint"), + onTap: onTap + ) + } + + case .chipSelector: + return .custom(edge: .top) { onTap in + WalkthroughCalloutView( + icon: "circle.grid.2x2.fill", + text: String(localized: "walkthrough.chipSelector"), + onTap: onTap + ) + } + + case .dealButton: + return .custom(edge: .top) { onTap in + WalkthroughCalloutView( + icon: "play.fill", + text: String(localized: "walkthrough.dealButton"), + onTap: onTap + ) + } + + case .playerActions: + return .custom(edge: .top) { onTap in + WalkthroughCalloutView( + icon: "hand.point.up.left.fill", + text: String(localized: "walkthrough.playerActions"), + onTap: onTap + ) + } + } + } +} + +// MARK: - Custom Callout View + +/// Fully custom callout view with dark background and white text +struct WalkthroughCalloutView: View { + let icon: String + let text: String + let onTap: () -> Void + + // Maximum width to prevent edge clipping + private let maxCalloutWidth: CGFloat = 320 + + var body: some View { + Button(action: onTap) { + HStack(spacing: Design.Spacing.small) { + Image(systemName: icon) + .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) + .foregroundStyle(.yellow) + + Text(text) + .font(.system(size: Design.BaseFontSize.body, weight: .medium)) + .foregroundStyle(.white) + .multilineTextAlignment(.leading) + .lineLimit(2) + + Spacer(minLength: 0) + + Text(String(localized: "walkthrough.close")) + .font(.system(size: Design.BaseFontSize.small, weight: .semibold)) + .foregroundStyle(.white.opacity(Design.Opacity.medium)) + } + .padding(Design.Spacing.medium) + .frame(maxWidth: maxCalloutWidth) + .background( + RoundedRectangle(cornerRadius: Design.CornerRadius.medium) + .fill(Color.black.opacity(Design.Opacity.almostFull)) + .shadow( + color: .black.opacity(Design.Opacity.medium), + radius: Design.Shadow.radiusLarge, + y: Design.Shadow.offsetMedium + ) + ) + } + .buttonStyle(.plain) + .padding(.horizontal, Design.Spacing.xLarge) + } +} diff --git a/Blackjack/Blackjack/Resources/Localizable.xcstrings b/Blackjack/Blackjack/Resources/Localizable.xcstrings index 10bee1f..97ea259 100644 --- a/Blackjack/Blackjack/Resources/Localizable.xcstrings +++ b/Blackjack/Blackjack/Resources/Localizable.xcstrings @@ -2010,9 +2010,6 @@ } } } - }, - "Choose your action based on the hint above" : { - }, "Clear" : { "localizations" : { @@ -3552,6 +3549,29 @@ } } }, + "Game Sessions" : { + "comment" : "Title for the Game Sessions help page", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Game Sessions" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sesiones de Juego" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sessions de Jeu" + } + } + } + }, "GAME STYLE" : { "localizations" : { "en" : { @@ -6034,6 +6054,7 @@ }, "Select a chip and tap the bet area" : { "comment" : "Onboarding hint for placing bets.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -6124,6 +6145,144 @@ } } }, + "sessions.autoStart" : { + "comment" : "Game Sessions help - auto start explanation", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A new session starts automatically when you begin playing." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Una nueva sesión comienza automáticamente cuando empiezas a jugar." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Une nouvelle session démarre automatiquement lorsque vous commencez à jouer." + } + } + } + }, + "sessions.endSession" : { + "comment" : "Game Sessions help - end session explanation", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "End your session anytime from the Statistics screen." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termina tu sesión en cualquier momento desde la pantalla de Estadísticas." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Terminez votre session à tout moment depuis l'écran Statistiques." + } + } + } + }, + "sessions.history" : { + "comment" : "Game Sessions help - history explanation", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View your complete session history to see past performance." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consulta tu historial completo de sesiones para ver tu rendimiento pasado." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consultez votre historique complet de sessions pour voir vos performances passées." + } + } + } + }, + "sessions.likeRealCasino" : { + "comment" : "Game Sessions help - like real casino", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Track your play just like at a real casino table!" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Rastrea tu juego como en una mesa de casino real!" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suivez votre jeu comme à une vraie table de casino !" + } + } + } + }, + "sessions.trackProgress" : { + "comment" : "Game Sessions help - track progress", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sessions track your playing time, hands, wins, losses, and chip balance." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Las sesiones rastrean tu tiempo de juego, manos, victorias, derrotas y saldo de fichas." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les sessions suivent votre temps de jeu, mains, victoires, défaites et solde de jetons." + } + } + } + }, + "sessions.viewStats" : { + "comment" : "Game Sessions help - view stats", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap the chart icon to view your current session stats." + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca el ícono de gráfico para ver las estadísticas de tu sesión actual." + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appuyez sur l'icône de graphique pour voir les statistiques de votre session actuelle." + } + } + } + }, "Settings" : { "localizations" : { "en" : { @@ -7191,9 +7350,6 @@ } } } - }, - "Tap Deal to start the round" : { - }, "TAP TO BET" : { "localizations" : { @@ -7608,6 +7764,259 @@ } } }, + "walkthrough.balance" : { + "comment" : "Walkthrough hint for the balance display", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your current balance is shown here" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu saldo actual se muestra aquí" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre solde actuel est affiché ici" + } + } + } + }, + "walkthrough.bettingHint" : { + "comment" : "Walkthrough hint for the betting hint display", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Betting tips based on card count" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Consejos de apuesta según el conteo" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conseils de mise selon le comptage" + } + } + } + }, + "walkthrough.bettingZone" : { + "comment" : "Walkthrough hint for the betting zone", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Select a chip and tap the bet area" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Selecciona una ficha y toca el área de apuesta" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionnez un jeton et touchez la zone de mise" + } + } + } + }, + "walkthrough.cardsRemaining" : { + "comment" : "Walkthrough hint for cards remaining display", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cards remaining in the shoe" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cartas restantes en el zapato" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cartes restantes dans le sabot" + } + } + } + }, + "walkthrough.close" : { + "comment" : "Walkthrough button to close/advance to next step", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cerrar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fermer" + } + } + } + }, + "walkthrough.chipSelector" : { + "comment" : "Walkthrough hint for the chip selector", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a chip value to bet" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige el valor de la ficha para apostar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez une valeur de jeton pour miser" + } + } + } + }, + "walkthrough.dealButton" : { + "comment" : "Walkthrough hint for the deal button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tap Deal to start the round" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toca Repartir para comenzar la ronda" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Touchez Distribuer pour commencer la manche" + } + } + } + }, + "walkthrough.playerActions" : { + "comment" : "Walkthrough hint for player action buttons", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose your action based on the hint above" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elige tu acción según la sugerencia de arriba" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choisissez votre action selon l'indice ci-dessus" + } + } + } + }, + "walkthrough.rulesButton" : { + "comment" : "Walkthrough hint for the rules/help button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Learn the rules and how to play" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aprende las reglas y cómo jugar" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apprenez les règles et comment jouer" + } + } + } + }, + "walkthrough.settingsButton" : { + "comment" : "Walkthrough hint for the settings button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Customize game rules and options" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personaliza las reglas y opciones del juego" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personnalisez les règles et options du jeu" + } + } + } + }, + "walkthrough.statsButton" : { + "comment" : "Walkthrough hint for the statistics button", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View session stats and history" + } + }, + "es-MX" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ver estadísticas e historial" + } + }, + "fr-CA" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voir statistiques et historique" + } + } + } + }, "Win Rate" : { "localizations" : { "en" : { diff --git a/Blackjack/Blackjack/Views/Game/ActionButtonsView.swift b/Blackjack/Blackjack/Views/Game/ActionButtonsView.swift index 665422c..20503da 100644 --- a/Blackjack/Blackjack/Views/Game/ActionButtonsView.swift +++ b/Blackjack/Blackjack/Views/Game/ActionButtonsView.swift @@ -50,6 +50,7 @@ struct ActionButtonsView: View { onClear: { state.clearBet() }, onDeal: { Task { await state.deal() } } ) + .sherpaTag(BlackjackWalkthroughTags.dealButton) } // MARK: - Player Turn Buttons @@ -83,6 +84,7 @@ struct ActionButtonsView: View { .transition(.scale.combined(with: .opacity)) } } + .sherpaTag(BlackjackWalkthroughTags.playerActions) .onAppear { animatedActions = availableActions } diff --git a/Blackjack/Blackjack/Views/Game/GameTableView.swift b/Blackjack/Blackjack/Views/Game/GameTableView.swift index 6568cec..0e19684 100644 --- a/Blackjack/Blackjack/Views/Game/GameTableView.swift +++ b/Blackjack/Blackjack/Views/Game/GameTableView.swift @@ -8,7 +8,7 @@ import SwiftUI import CasinoKit -struct GameTableView: View { +struct GameTableView: View, SherpaDelegate { @State private var settings = GameSettings() @State private var gameState: GameState? @State private var selectedChip: ChipDenomination = .twentyFive @@ -20,10 +20,10 @@ struct GameTableView: View { @State private var showStats = false @State private var showWelcome = false - // MARK: - Onboarding State + // MARK: - Walkthrough State - /// Tooltip manager for contextual hints - @State private var tooltipManager: TooltipManager? + /// Whether the Sherpa walkthrough is active + @State private var isWalkthroughActive = false /// Screen size for card sizing (measured from TableBackgroundView) @State private var screenSize: CGSize = CGSize(width: 375, height: 667) @@ -61,9 +61,6 @@ struct GameTableView: View { if gameState == nil { gameState = GameState(settings: settings) } - if tooltipManager == nil { - tooltipManager = TooltipManager(onboarding: state.onboarding) - } checkForWelcomeSheet() } .sheet(isPresented: $showSettings) { @@ -111,27 +108,17 @@ struct GameTableView: View { ], onboarding: state.onboarding, onDismiss: { showWelcome = false }, - onShowHints: checkOnboardingHints + onShowHints: startWalkthrough ) } .onChange(of: showWelcome) { wasShowing, isShowing in - // Handle swipe-down dismissal: treat as "Start Playing" (no tooltips) + // Handle swipe-down dismissal: treat as "Start Playing" (no walkthrough) if wasShowing && !isShowing && !state.onboarding.hasCompletedWelcome { state.onboarding.skipOnboarding() } } - .onChange(of: state.currentBet) { _, newBet in - if newBet > 0, state.onboarding.shouldShowHint("dealButton") { - showDealHintWithDelay() - } - } - .onChange(of: state.currentPhase) { oldPhase, newPhase in - if case .playerTurn = newPhase, oldPhase != newPhase { - if state.onboarding.shouldShowHint("playerActions") { - showActionsHintWithDelay() - } - } - } + // Sherpa walkthrough modifier + .sherpa(isActive: isWalkthroughActive, tags: BlackjackWalkthroughTags.self, delegate: self) } // Use global debug flag from Design constants @@ -198,7 +185,14 @@ struct GameTableView: View { leadingButtons: hintToolbarButtons(for: state), onSettings: { showSettings = true }, onHelp: { showRules = true }, - onStats: { showStats = true } + onStats: { showStats = true }, + sherpaTags: TopBarSherpaTags( + balance: BlackjackWalkthroughTags.balance, + cardsRemaining: BlackjackWalkthroughTags.cardsRemaining, + stats: BlackjackWalkthroughTags.statsButton, + rules: BlackjackWalkthroughTags.rulesButton, + settings: BlackjackWalkthroughTags.settingsButton + ) ) .frame(maxWidth: maxContentWidth) .debugBorder(showDebugBorders, color: .cyan, label: "TopBar") @@ -223,6 +217,7 @@ struct GameTableView: View { currentBet: state.minBetForChipSelector, maxBet: state.settings.maxBet ) + .sherpaTag(BlackjackWalkthroughTags.chipSelector) .transition(.opacity.combined(with: .move(edge: .bottom))) .debugBorder(showDebugBorders, color: .pink, label: "ChipSelector") } @@ -315,9 +310,6 @@ struct GameTableView: View { .onChange(of: state.balance) { oldBalance, newBalance in Design.debugLog("💰 Balance: \(oldBalance) → \(newBalance)") } - - // Dynamic tooltip display - .dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: state.onboarding)) } // MARK: - Onboarding Helpers @@ -332,39 +324,30 @@ struct GameTableView: View { } } - private func checkOnboardingHints() { - // Show betting hint if not yet shown - if state.onboarding.shouldShowHint("bettingZone") { - tooltipManager?.show( - key: "bettingZone", - message: String(localized: "Select a chip and tap the bet area"), - icon: "hand.tap.fill", - position: .bottom, - delay: 1.0 - ) - } + /// Starts the Sherpa walkthrough when user taps "Show Me How" + private func startWalkthrough() { + // Reset onboarding hints so walkthrough can be seen again + state.onboarding.reset() + state.onboarding.completeWelcome() + isWalkthroughActive = true } - private func showDealHintWithDelay() { - tooltipManager?.show( - key: "dealButton", - message: String(localized: "Tap Deal to start the round"), - icon: "play.fill", - position: .bottom, - delay: 0.5 - ) + // MARK: - SherpaDelegate + + /// Returns nil to hide skip button and progress indicator + func accessoryView(sherpa: Sherpa) -> AnyView? { + nil } - private func showActionsHintWithDelay() { - tooltipManager?.show( - key: "playerActions", - message: String(localized: "Choose your action based on the hint above"), - icon: "hand.point.up.left.fill", - position: .bottom, - delay: 1.0 - ) + func onWalkthroughComplete(sherpa: Sherpa) { + isWalkthroughActive = false + state.onboarding.completeWelcome() } + func onWalkthroughSkipped(sherpa: Sherpa, atStep: Int, totalSteps: Int) { + isWalkthroughActive = false + state.onboarding.completeWelcome() + } } // MARK: - Preview diff --git a/Blackjack/Blackjack/Views/Sheets/RulesHelpView.swift b/Blackjack/Blackjack/Views/Sheets/RulesHelpView.swift index 9fce463..a015ee2 100644 --- a/Blackjack/Blackjack/Views/Sheets/RulesHelpView.swift +++ b/Blackjack/Blackjack/Views/Sheets/RulesHelpView.swift @@ -22,6 +22,18 @@ struct RulesHelpView: View { String(localized: "If the dealer busts and you haven't, you win.") ] ), + RulePage( + title: String(localized: "Game Sessions"), + icon: "clock.badge.checkmark.fill", + content: [ + String(localized: "sessions.trackProgress"), + String(localized: "sessions.autoStart"), + String(localized: "sessions.viewStats"), + String(localized: "sessions.endSession"), + String(localized: "sessions.history"), + String(localized: "sessions.likeRealCasino") + ] + ), RulePage( title: String(localized: "Card Values"), icon: "suit.spade.fill", diff --git a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift index 6fb515b..9f7eb53 100644 --- a/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift +++ b/Blackjack/Blackjack/Views/Table/BlackjackTableView.swift @@ -213,12 +213,14 @@ struct BlackjackTableView: View { state: state, selectedChip: selectedChip ) + .sherpaTag(BlackjackWalkthroughTags.bettingZone) .transition(.scale.combined(with: .opacity)) .debugBorder(showDebugBorders, color: .blue, label: "BetZone") // Betting hint based on count (only when card counting enabled) if let hint = state.bettingHint { BlackjackBettingHintView(hint: hint, trueCount: state.engine.trueCount) + .sherpaTag(BlackjackWalkthroughTags.bettingHint) .transition(.opacity) .padding(.vertical, 10) .debugBorder(showDebugBorders, color: .purple, label: "BetHint") diff --git a/Blackjack/README.md b/Blackjack/README.md index 4fa8fcc..04d8f74 100644 --- a/Blackjack/README.md +++ b/Blackjack/README.md @@ -10,8 +10,8 @@ A feature-rich Blackjack app for iOS built with SwiftUI. Master basic strategy, ### 🎓 First-Time User Experience - **Welcome Sheet** — Interactive introduction on first launch -- **Tutorial Mode** — Optional guided walkthrough of your first hand -- **Contextual Hints** — Tips appear at the right moment during gameplay +- **Spotlight Walkthrough** — Guided tour with spotlight focus on key UI elements +- **Sherpa Framework** — Modern walkthrough with progress indicators and haptic feedback - **Never Intrusive** — All onboarding is skippable and shown only once ### 🎰 Authentic Casino Gameplay diff --git a/CasinoKit/Package.swift b/CasinoKit/Package.swift index 06123b4..dc29e14 100644 --- a/CasinoKit/Package.swift +++ b/CasinoKit/Package.swift @@ -16,9 +16,13 @@ let package = Package( targets: ["CasinoKit"] ) ], + dependencies: [ + .package(url: "https://github.com/mbrucedogs/Sherpa.git", branch: "develop") + ], targets: [ .target( name: "CasinoKit", + dependencies: ["Sherpa"], resources: [ .process("Resources") ] diff --git a/CasinoKit/README.md b/CasinoKit/README.md index 5dce363..f7e4578 100644 --- a/CasinoKit/README.md +++ b/CasinoKit/README.md @@ -140,84 +140,71 @@ WelcomeSheet( ) ``` -**ContextualTooltip** - Show hints at the right moment. +**Sherpa Walkthrough** - Guided spotlight walkthrough (re-exported from Sherpa package). ```swift -@State private var showBettingHint = false +// 1. Define walkthrough steps +enum MyTags: SherpaTags { + case bettingZone + case dealButton + + func makeCallout() -> Callout { + switch self { + case .bettingZone: + return .localizedLabeled("walkthrough.bettingZone", systemImage: "hand.tap.fill") + case .dealButton: + return .localizedLabeled("walkthrough.dealButton", systemImage: "play.fill") + } + } +} -var body: some View { - BettingZone(/* ... */) - .contextualTooltip( - "Tap chips, then tap here to bet", - icon: "hand.tap.fill", - position: .bottom, - isShowing: $showBettingHint, - onDismiss: { - gameState.onboarding.markHintShown("bettingZone") - } - ) +// 2. Wrap app in SherpaContainerView (in @main App) +SherpaContainerView(configuration: .default) { + ContentView() +} + +// 3. Tag views and activate walkthrough +struct GameTableView: View, SherpaDelegate { + @State private var isWalkthroughActive = false + + var body: some View { + VStack { + BettingZone() + .sherpaTag(MyTags.bettingZone) + + DealButton() + .sherpaTag(MyTags.dealButton) + } + .sherpa(isActive: isWalkthroughActive, tags: MyTags.self, delegate: self) + } + + func onWalkthroughComplete(sherpa: Sherpa) { + isWalkthroughActive = false + onboarding.completeWelcome() + } } ``` -**OnboardingState** - Track which hints have been shown. +**OnboardingState** - Track onboarding completion status. ```swift let onboarding = OnboardingState(gameIdentifier: "blackjack") -// Check if a hint should be shown -if onboarding.shouldShowHint("bettingZone") { - showBettingHint = true -} - -// Mark hint as shown (persisted automatically) -onboarding.markHintShown("bettingZone") - // Check if user has seen welcome if !onboarding.hasCompletedWelcome { showWelcome = true } -// Enable tutorial mode (shows all hints again) -onboarding.startTutorialMode() +// Mark welcome/walkthrough as completed +onboarding.completeWelcome() + +// Skip onboarding entirely +onboarding.skipOnboarding() // Reset onboarding (for testing) onboarding.reset() ``` -**TooltipManager** - Generic, scalable tooltip management (recommended). - -```swift -@State private var tooltipManager: TooltipManager? - -var body: some View { - GameView() - .onAppear { - tooltipManager = TooltipManager(onboarding: gameState.onboarding) - } - .dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: gameState.onboarding)) -} - -// Show a tooltip with automatic dismiss tracking -private func showBettingHint() { - tooltipManager?.show( - key: "bettingZone", - message: "Select a chip and tap here to bet", - icon: "hand.tap.fill", - position: .bottom, - delay: 1.0 - ) -} - -// Dismiss current tooltip manually -tooltipManager?.dismiss() -``` - -The `TooltipManager` automatically: -- Ensures only one tooltip shows at a time -- Marks tooltips as shown when dismissed or replaced -- Respects `OnboardingState.shouldShowHint()` checks -- Handles delayed presentation with cancellation if needed - **PulsingModifier** - Draw attention to interactive elements. ```swift @@ -776,7 +763,6 @@ CasinoKit/ │ │ ├── ChipDenomination.swift │ │ ├── TableLimits.swift # Betting limit presets │ │ ├── OnboardingState.swift # Onboarding tracking -│ │ ├── TooltipManager.swift # Tooltip management │ │ └── Session/ │ │ ├── GameSession.swift # Generic session with stats │ │ ├── GameSessionProtocol.swift # Session protocols @@ -794,7 +780,6 @@ CasinoKit/ │ │ │ └── SheetContainerView.swift │ │ ├── Onboarding/ │ │ │ ├── WelcomeSheet.swift # First-launch welcome -│ │ │ ├── ContextualTooltip.swift # In-game hints │ │ │ └── PulsingModifier.swift # Attention-grabbing pulse │ │ ├── Branding/ │ │ │ ├── AppIconView.swift diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift index 8973866..9a5e84d 100644 --- a/CasinoKit/Sources/CasinoKit/Exports.swift +++ b/CasinoKit/Sources/CasinoKit/Exports.swift @@ -8,13 +8,15 @@ // This file ensures all public types are exported from the module. // Clients can simply `import CasinoKit` to access all components. +// Re-export Sherpa for walkthrough functionality +@_exported import Sherpa + // MARK: - Models // - Card, Suit, Rank // - Deck // - ChipDenomination // - TableLimits // - OnboardingState -// - TooltipManager, TooltipConfig // - GameSettingsProtocol (shared settings interface) // - SettingsKeys, SettingsDefaults (persistence helpers) @@ -26,9 +28,14 @@ // - ChipStackView, ChipOnTableView // - SheetContainerView, SheetSection // - WelcomeSheet, WelcomeFeature -// - ContextualTooltip, ContextualTooltipModifier // - PulsingModifier +// MARK: - Walkthrough (via Sherpa) +// Re-exported from Sherpa package: +// - SherpaContainerView, SherpaConfiguration +// - SherpaTags, Callout, Sherpa, SherpaDelegate +// - .sherpaTag(), .sherpa() view modifiers + // MARK: - Effects // - ConfettiView, ConfettiPiece diff --git a/CasinoKit/Sources/CasinoKit/Models/TooltipManager.swift b/CasinoKit/Sources/CasinoKit/Models/TooltipManager.swift deleted file mode 100644 index 0820a8d..0000000 --- a/CasinoKit/Sources/CasinoKit/Models/TooltipManager.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// TooltipManager.swift -// CasinoKit -// -// Manages contextual tooltips for onboarding. -// - -import SwiftUI - -/// Configuration for a contextual tooltip -public struct TooltipConfig: Equatable { - public let key: String - public let message: String - public let icon: String - public let position: ContextualTooltip.TooltipPosition - - public init(key: String, message: String, icon: String, position: ContextualTooltip.TooltipPosition) { - self.key = key - self.message = message - self.icon = icon - self.position = position - } -} - -/// Manages the display of contextual tooltips with automatic dismissal and persistence -@Observable -@MainActor -public final class TooltipManager { - private let onboarding: OnboardingState - - /// Currently showing tooltip (nil if none showing) - public var currentTooltip: TooltipConfig? { - didSet { - // Mark the old tooltip as shown when replaced by a new one - if let oldValue = oldValue, oldValue.key != currentTooltip?.key { - onboarding.markHintShown(oldValue.key) - } - } - } - - public init(onboarding: OnboardingState) { - self.onboarding = onboarding - } - - /// Shows a tooltip with a delay - public func show( - key: String, - message: String, - icon: String = "lightbulb.fill", - position: ContextualTooltip.TooltipPosition = .bottom, - delay: TimeInterval = 1.0 - ) { - Task { @MainActor in - try? await Task.sleep(for: .seconds(delay)) - // Double-check hint hasn't been shown while we were waiting - guard onboarding.shouldShowHint(key) else { return } - withAnimation(.spring(duration: CasinoDesign.Animation.springDuration)) { - currentTooltip = TooltipConfig( - key: key, - message: message, - icon: icon, - position: position - ) - } - } - } - - /// Dismisses the current tooltip and marks it as shown - public func dismiss() { - if let current = currentTooltip { - onboarding.markHintShown(current.key) - } - withAnimation(.spring(duration: CasinoDesign.Animation.springDuration)) { - currentTooltip = nil - } - } -} - -// MARK: - View Modifier - -public extension View { - /// Displays the current tooltip from a TooltipManager - func dynamicTooltip(_ manager: TooltipManager) -> some View { - self.overlay { - if let tooltip = manager.currentTooltip { - VStack { - if tooltip.position == .bottom { - Spacer() - .allowsHitTesting(false) - } - - ContextualTooltip( - message: tooltip.message, - icon: tooltip.icon, - position: tooltip.position, - onDismiss: { - manager.dismiss() - } - ) - .padding(.horizontal, CasinoDesign.Spacing.medium) - .transition(.move(edge: tooltip.position == .bottom ? .bottom : .top).combined(with: .opacity)) - - if tooltip.position == .top { - Spacer() - .allowsHitTesting(false) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .zIndex(1000) - } - } - } -} - diff --git a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings index 5a9f60d..780dc36 100644 --- a/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings +++ b/CasinoKit/Sources/CasinoKit/Resources/Localizable.xcstrings @@ -1130,9 +1130,6 @@ "Global" : { "comment" : "Title for the \"Global\" tab in the statistics view.", "isCommentAutoGenerated" : true - }, - "Got it" : { - }, "Hands" : { "comment" : "Label for the number of hands played in the current session.", diff --git a/CasinoKit/Sources/CasinoKit/Views/Bars/TopBarView.swift b/CasinoKit/Sources/CasinoKit/Views/Bars/TopBarView.swift index baad2b4..01c878b 100644 --- a/CasinoKit/Sources/CasinoKit/Views/Bars/TopBarView.swift +++ b/CasinoKit/Sources/CasinoKit/Views/Bars/TopBarView.swift @@ -32,8 +32,32 @@ public struct TopBarButton: Identifiable { } } +/// Optional Sherpa tags for TopBarView elements. +/// Use this to enable walkthrough highlighting of individual top bar elements. +public struct TopBarSherpaTags { + public let balance: Tags? + public let cardsRemaining: Tags? + public let stats: Tags? + public let rules: Tags? + public let settings: Tags? + + public init( + balance: Tags? = nil, + cardsRemaining: Tags? = nil, + stats: Tags? = nil, + rules: Tags? = nil, + settings: Tags? = nil + ) { + self.balance = balance + self.cardsRemaining = cardsRemaining + self.stats = stats + self.rules = rules + self.settings = settings + } +} + /// A top bar showing balance and customizable toolbar buttons. -public struct TopBarView: View { +public struct TopBarView: View { /// The current balance to display. public let balance: Int @@ -55,6 +79,9 @@ public struct TopBarView: View { /// Action when stats is tapped. public let onStats: (() -> Void)? + /// Optional Sherpa tags for walkthrough highlighting. + public let sherpaTags: TopBarSherpaTags? + // MARK: - Font Sizes (fixed for top bar constraints) private let balanceFontSize: CGFloat = 24 @@ -71,7 +98,140 @@ public struct TopBarView: View { /// - onSettings: Settings button action. /// - onHelp: Help button action. /// - onStats: Stats button action. + /// - sherpaTags: Optional Sherpa tags for walkthrough highlighting. public init( + balance: Int, + secondaryInfo: String? = nil, + secondaryIcon: String? = nil, + leadingButtons: [TopBarButton] = [], + onSettings: (() -> Void)? = nil, + onHelp: (() -> Void)? = nil, + onStats: (() -> Void)? = nil, + sherpaTags: TopBarSherpaTags? = nil + ) { + self.balance = balance + self.secondaryInfo = secondaryInfo + self.secondaryIcon = secondaryIcon + self.leadingButtons = leadingButtons + self.onSettings = onSettings + self.onHelp = onHelp + self.onStats = onStats + self.sherpaTags = sherpaTags + } + + public var body: some View { + HStack { + // Balance display + balanceView + + Spacer() + + // Secondary info (centered) + if let info = secondaryInfo { + secondaryInfoView(info: info) + } + + Spacer() + + // Toolbar buttons + toolbarButtonsView + } + .padding(.horizontal, CasinoDesign.Spacing.large) + .padding(.vertical, CasinoDesign.Spacing.small) + } + + // MARK: - Private Views + + @ViewBuilder + private var balanceView: some View { + let view = HStack(spacing: CasinoDesign.Spacing.xxSmall) { + Text("$") + .font(.system(size: dollarFontSize, weight: .bold)) + .foregroundStyle(Color.CasinoTopBar.balanceText) + + Text(balance.formatted()) + .font(.system(size: balanceFontSize, weight: .bold, design: .rounded)) + .foregroundStyle(Color.CasinoTopBar.balanceText) + .contentTransition(.numericText()) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "Balance", bundle: .module)) + .accessibilityValue("$\(balance.formatted())") + + if let tag = sherpaTags?.balance { + view.sherpaTag(tag) + } else { + view + } + } + + @ViewBuilder + private func secondaryInfoView(info: String) -> some View { + let view = HStack(spacing: CasinoDesign.Spacing.xSmall) { + if let icon = secondaryIcon { + Image(systemName: icon) + } + Text(info) + } + .font(.system(size: secondaryFontSize)) + .foregroundStyle(Color.CasinoTopBar.secondaryText) + + if let tag = sherpaTags?.cardsRemaining { + view.sherpaTag(tag) + } else { + view + } + } + + @ViewBuilder + private var toolbarButtonsView: some View { + HStack(spacing: CasinoDesign.Spacing.medium) { + // Custom leading buttons (game-specific) + ForEach(leadingButtons) { button in + ToolbarButton(icon: button.icon, action: button.action) + .accessibilityLabel(button.accessibilityLabel) + } + + if let onStats = onStats { + let statsButton = ToolbarButton(icon: "chart.bar.fill", action: onStats) + .accessibilityLabel(String(localized: "Statistics", bundle: .module)) + + if let tag = sherpaTags?.stats { + statsButton.sherpaTag(tag) + } else { + statsButton + } + } + + if let onHelp = onHelp { + let helpButton = ToolbarButton(icon: "info.circle", action: onHelp) + .accessibilityLabel(String(localized: "Rules", bundle: .module)) + + if let tag = sherpaTags?.rules { + helpButton.sherpaTag(tag) + } else { + helpButton + } + } + + if let onSettings = onSettings { + let settingsButton = ToolbarButton(icon: "gearshape.fill", action: onSettings) + .accessibilityLabel(String(localized: "Settings", bundle: .module)) + + if let tag = sherpaTags?.settings { + settingsButton.sherpaTag(tag) + } else { + settingsButton + } + } + } + } +} + +/// Convenience initializer for TopBarView without Sherpa tags. +public extension TopBarView where Tags == NoSherpaTags { + /// Creates a top bar without Sherpa walkthrough support. + init( balance: Int, secondaryInfo: String? = nil, secondaryIcon: String? = nil, @@ -87,72 +247,19 @@ public struct TopBarView: View { self.onSettings = onSettings self.onHelp = onHelp self.onStats = onStats + self.sherpaTags = nil } - - public var body: some View { - HStack { - // Balance display - HStack(spacing: CasinoDesign.Spacing.xxSmall) { - Text("$") - .font(.system(size: dollarFontSize, weight: .bold)) - .foregroundStyle(Color.CasinoTopBar.balanceText) - - Text(balance.formatted()) - .font(.system(size: balanceFontSize, weight: .bold, design: .rounded)) - .foregroundStyle(Color.CasinoTopBar.balanceText) - .contentTransition(.numericText()) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(String(localized: "Balance", bundle: .module)) - .accessibilityValue("$\(balance.formatted())") - - Spacer() - - // Secondary info (centered) - if let info = secondaryInfo { - HStack(spacing: CasinoDesign.Spacing.xSmall) { - if let icon = secondaryIcon { - Image(systemName: icon) - } - Text(info) - } - .font(.system(size: secondaryFontSize)) - .foregroundStyle(Color.CasinoTopBar.secondaryText) - } - - Spacer() - - // Toolbar buttons - HStack(spacing: CasinoDesign.Spacing.medium) { - // Custom leading buttons (game-specific) - ForEach(leadingButtons) { button in - ToolbarButton(icon: button.icon, action: button.action) - .accessibilityLabel(button.accessibilityLabel) - } - - if let onStats = onStats { - ToolbarButton(icon: "chart.bar.fill", action: onStats) - .accessibilityLabel(String(localized: "Statistics", bundle: .module)) - } - - if let onHelp = onHelp { - ToolbarButton(icon: "info.circle", action: onHelp) - .accessibilityLabel(String(localized: "Rules", bundle: .module)) - } - - if let onSettings = onSettings { - ToolbarButton(icon: "gearshape.fill", action: onSettings) - .accessibilityLabel(String(localized: "Settings", bundle: .module)) - } - } - } - .padding(.horizontal, CasinoDesign.Spacing.large) - .padding(.vertical, CasinoDesign.Spacing.small) +} + +/// A placeholder SherpaTags type for when no walkthrough is needed. +public enum NoSherpaTags: SherpaTags { + public func makeCallout() -> Callout { + .text("") } } /// A single toolbar button. -private struct ToolbarButton: View { +struct ToolbarButton: View { let icon: String let action: () -> Void @@ -172,7 +279,7 @@ private struct ToolbarButton: View { Color.CasinoTable.felt.ignoresSafeArea() VStack { - TopBarView( + TopBarView( balance: 10_500, secondaryInfo: "411", secondaryIcon: "rectangle.portrait.on.rectangle.portrait.fill", diff --git a/CasinoKit/Sources/CasinoKit/Views/ContextualTooltip.swift b/CasinoKit/Sources/CasinoKit/Views/ContextualTooltip.swift deleted file mode 100644 index 9629e31..0000000 --- a/CasinoKit/Sources/CasinoKit/Views/ContextualTooltip.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// ContextualTooltip.swift -// CasinoKit -// -// Contextual tooltip for showing hints and tips to users. -// - -import SwiftUI - -/// A tooltip that appears to guide users through features. -public struct ContextualTooltip: View { - let message: String - let icon: String - let position: TooltipPosition - let onDismiss: () -> Void - - @ScaledMetric(relativeTo: .body) private var bodyFontSize: CGFloat = CasinoDesign.BaseFontSize.body - @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = CasinoDesign.IconSize.small - - public enum TooltipPosition { - case top, bottom, leading, trailing - } - - public init( - message: String, - icon: String = "lightbulb.fill", - position: TooltipPosition = .top, - onDismiss: @escaping () -> Void - ) { - self.message = message - self.icon = icon - self.position = position - self.onDismiss = onDismiss - } - - public var body: some View { - HStack(spacing: CasinoDesign.Spacing.small) { - Image(systemName: icon) - .font(.system(size: iconSize)) - .foregroundStyle(Color.Sheet.accent) - - Text(message) - .font(.system(size: bodyFontSize)) - .foregroundStyle(.white) - .fixedSize(horizontal: false, vertical: true) - - Spacer(minLength: 0) - - Button { - onDismiss() - } label: { - Text("Got it") - .font(.system(size: bodyFontSize, weight: .semibold)) - .foregroundStyle(Color.Sheet.accent) - } - } - .padding(CasinoDesign.Spacing.medium) - .background( - RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium) - .fill(Color.black.opacity(CasinoDesign.Opacity.almostFull)) - .shadow( - color: .black.opacity(CasinoDesign.Opacity.medium), - radius: CasinoDesign.Shadow.radiusLarge, - y: CasinoDesign.Shadow.offsetMedium - ) - ) - .padding(.horizontal, CasinoDesign.Spacing.medium) - .transition(.move(edge: edgeForPosition).combined(with: .opacity)) - } - - private var edgeForPosition: Edge { - switch position { - case .top: return .top - case .bottom: return .bottom - case .leading: return .leading - case .trailing: return .trailing - } - } -} - -/// Modifier to show a contextual tooltip above a view. -public struct ContextualTooltipModifier: ViewModifier { - let message: String - let icon: String - let position: ContextualTooltip.TooltipPosition - @Binding var isShowing: Bool - let onDismiss: () -> Void - - public func body(content: Content) -> some View { - ZStack(alignment: alignmentForPosition) { - content - - if isShowing { - ContextualTooltip( - message: message, - icon: icon, - position: position, - onDismiss: { - withAnimation(.spring(duration: CasinoDesign.Animation.quick)) { - isShowing = false - } - onDismiss() - } - ) - .offset(offsetForPosition) - .zIndex(1000) - } - } - } - - private var alignmentForPosition: Alignment { - switch position { - case .top: return .top - case .bottom: return .bottom - case .leading: return .leading - case .trailing: return .trailing - } - } - - private var offsetForPosition: CGSize { - switch position { - case .top: return CGSize(width: 0, height: -CasinoDesign.Spacing.small) - case .bottom: return CGSize(width: 0, height: CasinoDesign.Spacing.small) - case .leading: return CGSize(width: -CasinoDesign.Spacing.small, height: 0) - case .trailing: return CGSize(width: CasinoDesign.Spacing.small, height: 0) - } - } -} - -public extension View { - /// Shows a contextual tooltip when the binding is true. - func contextualTooltip( - _ message: String, - icon: String = "lightbulb.fill", - position: ContextualTooltip.TooltipPosition = .top, - isShowing: Binding, - onDismiss: @escaping () -> Void = {} - ) -> some View { - modifier(ContextualTooltipModifier( - message: message, - icon: icon, - position: position, - isShowing: isShowing, - onDismiss: onDismiss - )) - } -} - -#Preview { - ZStack { - Color.black - - VStack { - Text("Some Content") - .foregroundStyle(.white) - } - } - .contextualTooltip( - "This is a helpful tip that appears at the right moment!", - position: .top, - isShowing: .constant(true) - ) -} - diff --git a/ONBOARDING_IMPLEMENTATION.md b/ONBOARDING_IMPLEMENTATION.md index 45eeca9..c37150c 100644 --- a/ONBOARDING_IMPLEMENTATION.md +++ b/ONBOARDING_IMPLEMENTATION.md @@ -2,93 +2,154 @@ ## Overview -A comprehensive, non-intrusive onboarding system has been implemented for both Blackjack and Baccarat games. The system provides a great first-time user experience without annoying experienced players. +A comprehensive, non-intrusive onboarding system has been implemented for both Blackjack and Baccarat games using the **Sherpa** walkthrough framework. The system provides a great first-time user experience without annoying experienced players. -## Components Created (in CasinoKit) +## Architecture -### 1. OnboardingState.swift +The onboarding system uses: +- **Sherpa** - A SwiftUI walkthrough framework for spotlight-based guided tours +- **OnboardingState** - Tracks completion status and hint visibility +- **WelcomeSheet** - First-launch welcome screen with feature highlights + +## Sherpa Integration + +### Package Dependency + +Sherpa is added as a dependency in `CasinoKit/Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/mbrucedogs/Sherpa.git", branch: "develop") +] +``` + +It's re-exported from CasinoKit, so games only need to `import CasinoKit`. + +### App Structure + +Each game wraps its content in `SherpaContainerView`: + +```swift +@main +struct BlackjackApp: App { + var body: some Scene { + WindowGroup { + SherpaContainerView(configuration: .default) { + AppLaunchView(config: .blackjack) { + ContentView() + } + } + } + } +} +``` + +### Walkthrough Tags + +Each game defines walkthrough steps using `SherpaTags`: + +```swift +enum BlackjackWalkthroughTags: SherpaTags { + case bettingZone + case dealButton + case playerActions + + func makeCallout() -> Callout { + switch self { + case .bettingZone: + return .localizedLabeled( + "walkthrough.bettingZone", + systemImage: "hand.tap.fill", + edge: .top + ) + // ... + } + } +} +``` + +### View Tagging + +Views are tagged for spotlight highlighting: + +```swift +BettingZoneView(state: state, selectedChip: selectedChip) + .sherpaTag(BlackjackWalkthroughTags.bettingZone) + +BettingActionsView(...) + .sherpaTag(BlackjackWalkthroughTags.dealButton) +``` + +### Activation + +The walkthrough is activated when the user taps "Show Me How": + +```swift +struct GameTableView: View, SherpaDelegate { + @State private var isWalkthroughActive = false + + var body: some View { + mainGameView(state: state) + .sherpa(isActive: isWalkthroughActive, tags: BlackjackWalkthroughTags.self, delegate: self) + } + + private func startWalkthrough() { + isWalkthroughActive = true + } + + // MARK: - SherpaDelegate + + func onWalkthroughComplete(sherpa: Sherpa) { + isWalkthroughActive = false + state.onboarding.completeWelcome() + } + + func onWalkthroughSkipped(sherpa: Sherpa, atStep: Int, totalSteps: Int) { + isWalkthroughActive = false + state.onboarding.completeWelcome() + } +} +``` + +## Components + +### 1. OnboardingState.swift (CasinoKit) - Tracks first-time user progress -- Manages which hints have been shown - Persists state to UserDefaults -- Supports tutorial mode (replay hints) - Game-specific identifiers (separate state per game) **Key Methods:** -- `shouldShowHint(key:)` - Check if hint should be displayed -- `markHintShown(key:)` - Mark hint as seen (persisted) -- `completeWelcome()` - Mark welcome sheet as completed -- `startTutorialMode()` / `endTutorialMode()` - Enable/disable tutorial replay +- `completeWelcome()` - Mark welcome/walkthrough as completed +- `skipOnboarding()` - Skip onboarding entirely - `reset()` - Clear all onboarding data (for testing) -### 2. WelcomeSheet.swift +### 2. WelcomeSheet.swift (CasinoKit) - First-launch welcome screen - Lists key features with icons -- Two CTAs: "Show Me How" (tutorial) or "Start Playing" (skip) -- Automatically shows game emoji (🃏 for Blackjack, 🎴 for Baccarat) +- Two CTAs: "Show Me How" (starts walkthrough) or "Start Playing" (skip) - Fully localized -### 3. ContextualTooltip.swift -- In-game hint tooltips -- Appears at the right moment -- "Got it" button to dismiss -- Animated entry/exit -- Automatically marks hint as shown on dismiss -- View modifier for easy integration +### 3. WalkthroughTags (Per Game) +- Defines walkthrough steps for each game +- Provides localized callout content +- Specifies icons and positioning -### 4. PulsingModifier.swift -- Visual pulse animation to draw attention -- Optional enhancement for interactive elements -- Configurable color and scale -- Currently available but not actively used (can be added later) - -## Integration in Games +## Walkthrough Steps ### Blackjack -**Welcome Sheet Features:** -1. Beat the Dealer - Get closer to 21 -2. Learn Strategy - Built-in hints -3. Practice Free - Start with $1,000 -4. Customize Rules - Change settings - -**Contextual Hints:** -- **Betting Zone**: "Tap chips, then tap here to bet" (when entering betting phase for first time) -- **Deal Button**: "Tap Deal to start the round" (after placing first bet) -- **Player Actions**: "Choose your action based on the hint above" (during first player turn) +| Step | Tag | Description | +|------|-----|-------------| +| 1 | `bettingZone` | Highlights betting area - "Select a chip and tap the bet area" | +| 2 | `dealButton` | Highlights deal button - "Tap Deal to start the round" | +| 3 | `playerActions` | Highlights action buttons - "Choose your action based on the hint above" | ### Baccarat -**Welcome Sheet Features:** -1. Bet on Player, Banker, or Tie -2. Track Patterns - Road maps show history -3. Practice Free - Start with $1,000 -4. Customize Settings - Change table limits - -**Contextual Hints:** -- **Betting Zone**: "Tap chips, then tap a betting zone" (when entering betting phase for first time) -- **Deal Button**: "Tap Deal to start the round" (after placing first bet) -- **Result Display**: "Results appear here, then in the road maps below" (after first round completes) - -## Settings Changes - -Both games now default to more beginner-friendly settings: - -| Setting | Old Default | New Default | -|---------|-------------|-------------| -| Table Limits | Low Stakes ($10-$1,000) | Casual ($5-$500) | -| Starting Balance | $10,000 | $1,000 | - -**Rationale:** -- Lower minimum bet ($5) is less intimidating for beginners -- $1,000 balance creates more meaningful decisions (not too much/little) -- Still allows ~200 hands at $5/hand for good learning experience -- Users can always increase in settings - -## Bug Fix - -**Blackjack Late Surrender:** -- Fixed inconsistency where `resetToDefaults()` set it to `true` but Vegas preset overrode to `false` -- Now correctly defaults to `false` to match Vegas rules +| Step | Tag | Description | +|------|-----|-------------| +| 1 | `bettingZone` | Highlights betting table - "Select a chip and tap a bet zone" | +| 2 | `dealButton` | Highlights deal button - "Tap Deal to start the round" | ## User Flow @@ -96,72 +157,73 @@ Both games now default to more beginner-friendly settings: 1. App loads 2. Welcome sheet appears automatically after 500ms delay 3. User chooses: - - **"Show Me How"**: Enables tutorial mode, shows contextual hints during first round - - **"Start Playing"**: Skips tutorial, hints still appear once naturally during gameplay + - **"Show Me How"**: Starts Sherpa walkthrough with spotlight focus + - **"Start Playing"**: Skips walkthrough, marks onboarding complete + +### Walkthrough Experience: +1. Screen dims with spotlight on highlighted element +2. Callout tooltip explains the element +3. User taps to advance to next step +4. Progress indicator shows current step +5. Skip button available at all times ### Subsequent Launches: - Welcome sheet never shows again -- Contextual hints appear once at the right moment -- Each hint only shows once per hint key -- Completely transparent to returning users +- Walkthrough can be replayed via settings (if implemented) -### Tutorial Mode: -- User can replay tutorial by tapping "Show Me How" on welcome -- All hints re-enabled for that session -- Tutorial mode doesn't persist across app launches +## Localization -## Technical Details +Walkthrough strings are stored in each game's `Localizable.xcstrings`: -### Hint Timing -- Hints are shown with delays using `Task.sleep(for:)` on MainActor -- Animations use spring duration for smooth transitions -- onChange modifiers trigger hints based on game state changes +| Key | English | Spanish (MX) | French (CA) | +|-----|---------|--------------|-------------| +| `walkthrough.bettingZone` | Select a chip and tap the bet area | Selecciona una ficha y toca el área de apuesta | Sélectionnez un jeton et touchez la zone de mise | +| `walkthrough.dealButton` | Tap Deal to start the round | Toca Repartir para comenzar la ronda | Touchez Distribuer pour commencer la manche | +| `walkthrough.playerActions` | Choose your action based on the hint above | Elige tu acción según la sugerencia de arriba | Choisissez votre action selon l'indice ci-dessus | -### Persistence -- Uses UserDefaults with game-specific keys -- Format: `"onboarding.{gameIdentifier}.{property}"` -- Examples: `"onboarding.blackjack.hasLaunched"`, `"onboarding.baccarat.hintsShown"` +## Benefits of Sherpa Migration -### Thread Safety -- All onboarding state is @MainActor -- Safe to use from SwiftUI views -- No threading concerns +| Before (TooltipManager) | After (Sherpa) | +|------------------------|----------------| +| Simple tooltips without focus | Spotlight effect highlights elements | +| Manual positioning | Smart auto-positioning | +| No navigation controls | Back/Next/Skip controls | +| No progress indicator | Step indicator dots | +| Basic animations | Smooth transitions with haptics | +| Custom implementation | Well-tested framework | +| 10+ languages | 10 languages built-in | -## Documentation Updates +## Files Changed -### Blackjack README -- Added "First-Time User Experience" section at top of features -- Documents welcome sheet, tutorial mode, and contextual hints +### CasinoKit +- `Package.swift` - Added Sherpa dependency +- `Exports.swift` - Re-exports Sherpa +- Deleted: `TooltipManager.swift`, `ContextualTooltip.swift` -### Baccarat README -- Added "First-Time User Experience" section at top of features -- Same structure as Blackjack for consistency +### Blackjack +- Added: `Models/WalkthroughTags.swift` +- Modified: `BlackjackApp.swift`, `GameTableView.swift`, `ActionButtonsView.swift` +- Modified: `BlackjackTableView.swift` (added sherpaTag) +- Modified: `Localizable.xcstrings` (walkthrough strings) -### CasinoKit README -- New "Onboarding & Tutorials" section with full API documentation -- Code examples for WelcomeSheet, ContextualTooltip, OnboardingState -- Updated file structure to show new components -- Added Blackjack to "Apps Using CasinoKit" list +### Baccarat +- Added: `Models/WalkthroughTags.swift` +- Modified: `BaccaratApp.swift`, `GameTableView.swift`, `ActionButtonsView.swift` +- Modified: `BettingTableView.swift` (added sherpaTag) +- Modified: `Localizable.xcstrings` (walkthrough strings) ## Best Practices Followed ✅ **Non-intrusive**: All onboarding is skippable -✅ **One-time only**: Hints never show twice (unless tutorial mode) -✅ **Right moment**: Hints appear contextually, not all at once -✅ **Short & visual**: Messages are concise with icons +✅ **Sequential flow**: Clear progression through steps +✅ **Visual focus**: Spotlight draws attention to key elements +✅ **Haptic feedback**: Tactile response on step changes ✅ **Localized**: All strings use String Catalog -✅ **Accessible**: Uses standard SwiftUI components -✅ **Persistent**: User's progress is saved -✅ **Game-specific**: Each game has independent onboarding state +✅ **Accessible**: VoiceOver announcements for each step +✅ **Persistent**: User's completion status is saved +✅ **Game-specific**: Each game has independent walkthrough -## Testing Recommendations - -### Manual Testing: -1. **Fresh install**: Delete app, reinstall, verify welcome sheet appears -2. **Tutorial mode**: Tap "Show Me How", verify hints appear in sequence -3. **Skip mode**: Tap "Start Playing", verify hints still appear once naturally -4. **Second launch**: Relaunch app, verify welcome doesn't show again -5. **Settings reset**: Check if onboarding can be reset (could add to settings) +## Testing ### Reset for Testing: Add this to a development menu if needed: @@ -171,60 +233,9 @@ Button("Reset Onboarding") { } ``` -## Future Enhancements (Not Implemented) - -These could be added later if desired: - -1. **Progressive discovery hints**: Tips after 5/10/20 hands - - "Enable card counting in settings" (Blackjack) - - "Side bets offer bigger payouts" (both games) - -2. **Onboarding analytics**: Track which hints are most helpful - -3. **User-requested replay**: "Show tutorial again" in settings - -4. **Pulsing hints**: Use PulsingModifier on interactive elements during tutorial - -5. **Accessibility announcements**: Post VoiceOver announcements for key moments - -## Files Created - -``` -CasinoKit/Sources/CasinoKit/ -├── Models/OnboardingState.swift -└── Views/ - ├── ContextualTooltip.swift - ├── WelcomeSheet.swift - └── PulsingModifier.swift -``` - -## Files Modified - -``` -Blackjack/ -├── Models/GameSettings.swift (defaults changed, bug fix) -├── Engine/GameState.swift (added onboarding property) -└── Views/Game/GameTableView.swift (integrated onboarding UI) - -Baccarat/ -├── Models/GameSettings.swift (defaults changed) -├── Engine/GameState.swift (added onboarding property) -└── Views/Game/GameTableView.swift (integrated onboarding UI) - -README Files: -├── Blackjack/README.md -├── Baccarat/README.md -└── CasinoKit/README.md -``` - -## Conclusion - -The onboarding system provides a polished first-time user experience that: -- Helps new users understand the game quickly -- Never annoys experienced players -- Maintains the premium feel of the apps -- Follows iOS best practices -- Is fully reusable for future casino games - -All code follows the project's Swift/SwiftUI guidelines, uses design constants, and includes proper accessibility support. - +### Manual Testing: +1. Delete app, reinstall, verify welcome sheet appears +2. Tap "Show Me How", verify spotlight walkthrough starts +3. Navigate through all steps +4. Verify completion callback fires +5. Relaunch app, verify welcome doesn't show again diff --git a/TOOLTIP_REFACTORING.md b/TOOLTIP_REFACTORING.md deleted file mode 100644 index 912e135..0000000 --- a/TOOLTIP_REFACTORING.md +++ /dev/null @@ -1,246 +0,0 @@ -# Tooltip System Refactoring - -## Overview - -The tooltip management system has been refactored from game-specific implementations to a generic, reusable system in CasinoKit. - -## Problem - -The original implementation had several issues: -- **Verbose**: Each game had duplicate `TooltipConfig` structs and complex state management -- **Not scalable**: Adding new tooltips required significant boilerplate -- **Repetitive logic**: Similar code duplicated across Blackjack and Baccarat -- **Difficult to maintain**: Changes required updates in multiple places - -## Solution - -Created a centralized `TooltipManager` class in CasinoKit that: -- Manages tooltip state generically -- Automatically handles dismiss tracking -- Ensures only one tooltip shows at a time -- Integrates seamlessly with `OnboardingState` - -## New Components - -### 1. `TooltipConfig` (CasinoKit) - -A simple struct defining tooltip properties: - -```swift -public struct TooltipConfig: Equatable { - public let key: String - public let message: String - public let icon: String - public let position: ContextualTooltip.TooltipPosition -} -``` - -### 2. `TooltipManager` (CasinoKit) - -An `@Observable` class that manages tooltip lifecycle: - -```swift -@Observable -@MainActor -public final class TooltipManager { - private let onboarding: OnboardingState - public var currentTooltip: TooltipConfig? - - public func show( - key: String, - message: String, - icon: String = "lightbulb.fill", - position: ContextualTooltip.TooltipPosition = .bottom, - delay: TimeInterval = 1.0 - ) - - public func dismiss() -} -``` - -**Key Features:** -- Automatic dismiss tracking via `didSet` on `currentTooltip` -- Delayed presentation with cancellation if hint already shown -- Respects `OnboardingState.shouldShowHint()` checks -- Single source of truth for current tooltip - -### 3. `dynamicTooltip` View Modifier (CasinoKit) - -A convenient view modifier that displays tooltips: - -```swift -public extension View { - func dynamicTooltip(_ manager: TooltipManager) -> some View -} -``` - -## Usage in Games - -### Before (Verbose) - -```swift -// State variables for each tooltip -@State private var showBettingHint = false -@State private var showDealHint = false -@State private var showActionsHint = false - -// Separate struct definition -private struct TooltipConfig { - let key: String - let message: String - let icon: String - let position: ContextualTooltip.TooltipPosition -} - -// Complex state with tracking -@State private var currentTooltip: TooltipConfig? { - didSet { - if let oldValue = oldValue, oldValue.key != currentTooltip?.key { - state.onboarding.markHintShown(oldValue.key) - } - } -} - -// Generic helper with lots of parameters -private func showTooltip(key: String, message: String, icon: String, position: ContextualTooltip.TooltipPosition, delay: TimeInterval) { - Task { @MainActor in - try? await Task.sleep(for: .seconds(delay)) - guard state.onboarding.shouldShowHint(key) else { return } - withAnimation(.spring(duration: Design.Animation.springDuration)) { - currentTooltip = TooltipConfig(key: key, message: message, icon: icon, position: position) - } - } -} - -// Complex overlay in view body -.overlay { - if let tooltip = currentTooltip { - VStack { - // ... positioning logic - ContextualTooltip( - message: tooltip.message, - icon: tooltip.icon, - position: tooltip.position, - onDismiss: { - state.onboarding.markHintShown(tooltip.key) - withAnimation(.spring(duration: Design.Animation.springDuration)) { - currentTooltip = nil - } - } - ) - // ... more positioning - } - } -} -``` - -### After (Clean) - -```swift -// Single state variable -@State private var tooltipManager: TooltipManager? - -// Initialize in onAppear -.onAppear { - tooltipManager = TooltipManager(onboarding: state.onboarding) -} - -// Simple show calls -private func showBettingHint() { - tooltipManager?.show( - key: "bettingZone", - message: "Select a chip and tap here to bet", - icon: "hand.tap.fill", - position: .bottom, - delay: 1.0 - ) -} - -// One-line view modifier -.dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: state.onboarding)) -``` - -## Benefits - -1. **Reduced code**: ~70 lines of boilerplate eliminated per game -2. **Better scalability**: Adding new tooltips is now trivial -3. **Single source of truth**: All tooltip logic in CasinoKit -4. **Easier maintenance**: Changes only need to be made in one place -5. **Reusable**: Any new game can use `TooltipManager` with minimal setup - -## Files Changed - -### CasinoKit -- **Created**: `Sources/CasinoKit/Models/TooltipManager.swift` -- **Updated**: `Sources/CasinoKit/Exports.swift` (added TooltipManager export) -- **Updated**: `README.md` (added TooltipManager documentation) - -### Blackjack -- **Updated**: `Views/Game/GameTableView.swift` - - Removed local `TooltipConfig` struct - - Removed `currentTooltip` @State with didSet - - Removed generic `showTooltip()` helper - - Added `@State private var tooltipManager: TooltipManager?` - - Simplified tooltip show methods - - Replaced complex overlay with `.dynamicTooltip()` modifier - -### Baccarat -- **Updated**: `Views/Game/GameTableView.swift` - - Same changes as Blackjack - -## Migration Guide - -To use `TooltipManager` in a new game: - -1. **Add state variable:** - ```swift - @State private var tooltipManager: TooltipManager? - ``` - -2. **Initialize in onAppear:** - ```swift - .onAppear { - tooltipManager = TooltipManager(onboarding: gameState.onboarding) - } - ``` - -3. **Apply view modifier:** - ```swift - .dynamicTooltip(tooltipManager ?? TooltipManager(onboarding: gameState.onboarding)) - ``` - -4. **Show tooltips:** - ```swift - tooltipManager?.show( - key: "uniqueKey", - message: "Your hint text", - icon: "lightbulb.fill", - position: .bottom, - delay: 1.0 - ) - ``` - -## Testing - -Both Blackjack and Baccarat have been built and tested successfully with the new system. - -```bash -# Blackjack -xcodebuild -workspace CasinoGames.xcworkspace -scheme Blackjack \ - -configuration Debug -destination 'platform=iOS Simulator,id=...' build -# ✅ BUILD SUCCEEDED - -# Baccarat -xcodebuild -workspace CasinoGames.xcworkspace -scheme Baccarat \ - -configuration Debug -destination 'platform=iOS Simulator,id=...' build -# ✅ BUILD SUCCEEDED -``` - -## Future Enhancements - -Potential improvements: -- **Tooltip queue**: Allow queueing multiple tooltips -- **Positioning hints**: Auto-detect best position based on screen space -- **Animation customization**: Per-tooltip animation styles -- **Analytics**: Track which tooltips are most helpful -