From 058f6ab75ee41217f001779b7f3c8df9f5fcc6a0 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Wed, 17 Dec 2025 16:56:57 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Baccarat/Views/SettingsView.swift | 104 ++------- Blackjack/Views/SettingsView.swift | 154 ++----------- CasinoKit/Sources/CasinoKit/Exports.swift | 3 + .../Views/Settings/SettingsComponents.swift | 210 ++++++++++++++++++ 4 files changed, 248 insertions(+), 223 deletions(-) diff --git a/Baccarat/Views/SettingsView.swift b/Baccarat/Views/SettingsView.swift index 62a97ca..03de157 100644 --- a/Baccarat/Views/SettingsView.swift +++ b/Baccarat/Views/SettingsView.swift @@ -301,46 +301,12 @@ struct DeckCountPicker: View { var body: some View { VStack(spacing: Design.Spacing.medium) { ForEach(DeckCount.allCases) { count in - Button { - selection = count - } label: { - HStack { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text(count.displayName) - .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) - .foregroundStyle(.white) - - Text(count.description) - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - Spacer() - - if selection == count { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: Design.Size.checkmark)) - .foregroundStyle(.yellow) - } else { - Circle() - .strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium) - .frame(width: Design.Size.checkmark, height: Design.Size.checkmark) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(selection == count ? Color.yellow.opacity(Design.Opacity.subtle) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .strokeBorder( - selection == count ? Color.yellow.opacity(Design.Opacity.medium) : Color.white.opacity(Design.Opacity.subtle), - lineWidth: Design.LineWidth.thin - ) - ) - } - .buttonStyle(.plain) + SelectableRow( + title: count.displayName, + subtitle: count.description, + isSelected: selection == count, + action: { selection = count } + ) } } } @@ -447,57 +413,13 @@ struct TableLimitsPicker: View { var body: some View { VStack(spacing: Design.Spacing.small) { ForEach(TableLimits.allCases) { limit in - Button { - selection = limit - } label: { - HStack { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text(limit.displayName) - .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) - .foregroundStyle(.white) - - Text(limit.detailedDescription) - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - Spacer() - - // Limits badge - Text(limit.description) - .font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded)) - .foregroundStyle(selection == limit ? .black : .yellow) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xSmall) - .background( - Capsule() - .fill(selection == limit ? Color.yellow : Color.yellow.opacity(Design.Opacity.hint)) - ) - - if selection == limit { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: Design.Size.checkmark - 2)) - .foregroundStyle(.yellow) - } else { - Circle() - .strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium) - .frame(width: Design.Size.checkmark - 2, height: Design.Size.checkmark - 2) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(selection == limit ? Color.yellow.opacity(Design.Opacity.subtle) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .strokeBorder( - selection == limit ? Color.yellow.opacity(Design.Opacity.medium) : Color.white.opacity(Design.Opacity.subtle), - lineWidth: Design.LineWidth.thin - ) - ) - } - .buttonStyle(.plain) + SelectableRow( + title: limit.displayName, + subtitle: limit.detailedDescription, + isSelected: selection == limit, + badge: { BadgePill(text: limit.description, isSelected: selection == limit) }, + action: { selection = limit } + ) } } } diff --git a/Blackjack/Views/SettingsView.swift b/Blackjack/Views/SettingsView.swift index 81b2739..bb35344 100644 --- a/Blackjack/Views/SettingsView.swift +++ b/Blackjack/Views/SettingsView.swift @@ -166,47 +166,13 @@ struct GameStylePicker: View { var body: some View { VStack(spacing: Design.Spacing.small) { ForEach(BlackjackStyle.allCases) { style in - Button { - selection = style - } label: { - HStack { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text(style.displayName) - .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) - .foregroundStyle(.white) - - Text(style.description) - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - .lineLimit(2) - } - - Spacer() - - if selection == style { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: Design.Size.checkmark)) - .foregroundStyle(Color.Settings.accent) - } else { - Circle() - .strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium) - .frame(width: Design.Size.checkmark, height: Design.Size.checkmark) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(selection == style ? Color.Settings.accent.opacity(Design.Opacity.subtle) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .strokeBorder( - selection == style ? Color.Settings.accent.opacity(Design.Opacity.medium) : Color.white.opacity(Design.Opacity.subtle), - lineWidth: Design.LineWidth.thin - ) - ) - } - .buttonStyle(.plain) + SelectableRow( + title: style.displayName, + subtitle: style.description, + isSelected: selection == style, + accentColor: Color.Settings.accent, + action: { selection = style } + ) } } } @@ -220,46 +186,13 @@ struct DeckCountPicker: View { var body: some View { VStack(spacing: Design.Spacing.medium) { ForEach(DeckCount.allCases) { count in - Button { - selection = count - } label: { - HStack { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text(count.displayName) - .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) - .foregroundStyle(.white) - - Text(count.description) - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - Spacer() - - if selection == count { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: Design.Size.checkmark)) - .foregroundStyle(Color.Settings.accent) - } else { - Circle() - .strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium) - .frame(width: Design.Size.checkmark, height: Design.Size.checkmark) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(selection == count ? Color.Settings.accent.opacity(Design.Opacity.subtle) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .strokeBorder( - selection == count ? Color.Settings.accent.opacity(Design.Opacity.medium) : Color.white.opacity(Design.Opacity.subtle), - lineWidth: Design.LineWidth.thin - ) - ) - } - .buttonStyle(.plain) + SelectableRow( + title: count.displayName, + subtitle: count.description, + isSelected: selection == count, + accentColor: Color.Settings.accent, + action: { selection = count } + ) } } } @@ -273,57 +206,14 @@ struct TableLimitsPicker: View { var body: some View { VStack(spacing: Design.Spacing.small) { ForEach(TableLimits.allCases) { limit in - Button { - selection = limit - } label: { - HStack { - VStack(alignment: .leading, spacing: Design.Spacing.xxSmall) { - Text(limit.displayName) - .font(.system(size: Design.BaseFontSize.large, weight: .semibold)) - .foregroundStyle(.white) - - Text(limit.detailedDescription) - .font(.system(size: Design.BaseFontSize.body)) - .foregroundStyle(.white.opacity(Design.Opacity.medium)) - } - - Spacer() - - // Limits badge pill - Text(limit.description) - .font(.system(size: Design.BaseFontSize.body, weight: .bold, design: .rounded)) - .foregroundStyle(selection == limit ? .black : Color.Settings.accent) - .padding(.horizontal, Design.Spacing.small) - .padding(.vertical, Design.Spacing.xSmall) - .background( - Capsule() - .fill(selection == limit ? Color.Settings.accent : Color.Settings.accent.opacity(Design.Opacity.hint)) - ) - - if selection == limit { - Image(systemName: "checkmark.circle.fill") - .font(.system(size: Design.Size.checkmark - 2)) - .foregroundStyle(Color.Settings.accent) - } else { - Circle() - .strokeBorder(Color.white.opacity(Design.Opacity.light), lineWidth: Design.LineWidth.medium) - .frame(width: Design.Size.checkmark - 2, height: Design.Size.checkmark - 2) - } - } - .padding() - .background( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .fill(selection == limit ? Color.Settings.accent.opacity(Design.Opacity.subtle) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: Design.CornerRadius.medium) - .strokeBorder( - selection == limit ? Color.Settings.accent.opacity(Design.Opacity.medium) : Color.white.opacity(Design.Opacity.subtle), - lineWidth: Design.LineWidth.thin - ) - ) - } - .buttonStyle(.plain) + SelectableRow( + title: limit.displayName, + subtitle: limit.detailedDescription, + isSelected: selection == limit, + accentColor: Color.Settings.accent, + badge: { BadgePill(text: limit.description, isSelected: selection == limit, accentColor: Color.Settings.accent) }, + action: { selection = limit } + ) } } } diff --git a/CasinoKit/Sources/CasinoKit/Exports.swift b/CasinoKit/Sources/CasinoKit/Exports.swift index ffbc759..2d93f9d 100644 --- a/CasinoKit/Sources/CasinoKit/Exports.swift +++ b/CasinoKit/Sources/CasinoKit/Exports.swift @@ -52,6 +52,9 @@ // - SpeedPicker // - VolumePicker // - BalancePicker +// - SelectableRow (card-like selectable picker row) +// - SelectionIndicator (checkmark circle) +// - BadgePill (capsule badge for values) // MARK: - Branding // - AppIconView, AppIconConfig diff --git a/CasinoKit/Sources/CasinoKit/Views/Settings/SettingsComponents.swift b/CasinoKit/Sources/CasinoKit/Views/Settings/SettingsComponents.swift index dd45cd4..dbdd7ff 100644 --- a/CasinoKit/Sources/CasinoKit/Views/Settings/SettingsComponents.swift +++ b/CasinoKit/Sources/CasinoKit/Views/Settings/SettingsComponents.swift @@ -140,6 +140,188 @@ public struct VolumePicker: View { } } +// MARK: - Selectable Row + +/// A card-like selectable row with title, subtitle, optional badge, and selection indicator. +/// Use this for settings pickers like table limits, deck count, game style, etc. +public struct SelectableRow: View { + /// The main title text. + public let title: String + + /// The subtitle/description text. + public let subtitle: String + + /// Whether this row is currently selected. + public let isSelected: Bool + + /// Optional badge view (e.g., a pill showing "$10 - $1,000"). + public let badge: Badge? + + /// The accent color for selection highlighting. + public let accentColor: Color + + /// Action when tapped. + public let action: () -> Void + + /// Creates a selectable row. + /// - Parameters: + /// - title: The main title. + /// - subtitle: The subtitle description. + /// - isSelected: Whether this row is selected. + /// - accentColor: Color for selection (default: yellow). + /// - badge: Optional badge view. + /// - action: Action when tapped. + public init( + title: String, + subtitle: String, + isSelected: Bool, + accentColor: Color = .yellow, + @ViewBuilder badge: () -> Badge? = { nil as EmptyView? }, + action: @escaping () -> Void + ) { + self.title = title + self.subtitle = subtitle + self.isSelected = isSelected + self.accentColor = accentColor + self.badge = badge() + self.action = action + } + + public var body: some View { + Button(action: action) { + HStack { + VStack(alignment: .leading, spacing: CasinoDesign.Spacing.xxSmall) { + Text(title) + .font(.system(size: CasinoDesign.BaseFontSize.large, weight: .semibold)) + .foregroundStyle(.white) + + Text(subtitle) + .font(.system(size: CasinoDesign.BaseFontSize.body)) + .foregroundStyle(.white.opacity(CasinoDesign.Opacity.medium)) + } + + Spacer() + + if let badge = badge { + badge + } + + SelectionIndicator(isSelected: isSelected, accentColor: accentColor) + } + .padding() + .background( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium) + .fill(isSelected ? accentColor.opacity(CasinoDesign.Opacity.subtle) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: CasinoDesign.CornerRadius.medium) + .strokeBorder( + isSelected ? accentColor.opacity(CasinoDesign.Opacity.medium) : Color.white.opacity(CasinoDesign.Opacity.subtle), + lineWidth: CasinoDesign.LineWidth.thin + ) + ) + } + .buttonStyle(.plain) + } +} + +// Convenience initializer for rows without a badge +extension SelectableRow where Badge == EmptyView { + /// Creates a selectable row without a badge. + public init( + title: String, + subtitle: String, + isSelected: Bool, + accentColor: Color = .yellow, + action: @escaping () -> Void + ) { + self.title = title + self.subtitle = subtitle + self.isSelected = isSelected + self.accentColor = accentColor + self.badge = nil + self.action = action + } +} + +// MARK: - Selection Indicator + +/// A circle indicator that shows selected (checkmark) or unselected (outline) state. +public struct SelectionIndicator: View { + /// Whether the item is selected. + public let isSelected: Bool + + /// The accent color for the checkmark. + public let accentColor: Color + + /// The size of the indicator. + public let size: CGFloat + + /// Creates a selection indicator. + /// - Parameters: + /// - isSelected: Whether selected. + /// - accentColor: Color for checkmark (default: yellow). + /// - size: Size of the indicator (default: checkmark size from design). + public init( + isSelected: Bool, + accentColor: Color = .yellow, + size: CGFloat = CasinoDesign.Size.checkmark + ) { + self.isSelected = isSelected + self.accentColor = accentColor + self.size = size + } + + public var body: some View { + if isSelected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: size)) + .foregroundStyle(accentColor) + } else { + Circle() + .strokeBorder(Color.white.opacity(CasinoDesign.Opacity.light), lineWidth: CasinoDesign.LineWidth.medium) + .frame(width: size, height: size) + } + } +} + +// MARK: - Badge Pill + +/// A capsule-shaped badge for displaying values like "$10 - $1,000". +public struct BadgePill: View { + /// The text to display in the badge. + public let text: String + + /// Whether the parent row is selected. + public let isSelected: Bool + + /// The accent color. + public let accentColor: Color + + /// Creates a badge pill. + /// - Parameters: + /// - text: The badge text. + /// - isSelected: Whether the parent row is selected. + /// - accentColor: Color for the badge (default: yellow). + public init(text: String, isSelected: Bool, accentColor: Color = .yellow) { + self.text = text + self.isSelected = isSelected + self.accentColor = accentColor + } + + public var body: some View { + Text(text) + .font(.system(size: CasinoDesign.BaseFontSize.body, weight: .bold, design: .rounded)) + .foregroundStyle(isSelected ? .black : accentColor) + .padding(.horizontal, CasinoDesign.Spacing.small) + .padding(.vertical, CasinoDesign.Spacing.xSmall) + .background( + Capsule() + .fill(isSelected ? accentColor : accentColor.opacity(CasinoDesign.Opacity.hint)) + ) + } +} + // MARK: - Balance Picker /// A grid picker for selecting a starting balance. @@ -198,6 +380,34 @@ public struct BalancePicker: View { #Preview { ScrollView { VStack(spacing: 20) { + // Selectable rows + VStack(spacing: CasinoDesign.Spacing.small) { + SelectableRow( + title: "Low Stakes", + subtitle: "Standard mini table", + isSelected: true, + badge: { BadgePill(text: "$10 - $1,000", isSelected: true) }, + action: {} + ) + + SelectableRow( + title: "Medium Stakes", + subtitle: "Regular casino table", + isSelected: false, + badge: { BadgePill(text: "$25 - $5,000", isSelected: false) }, + action: {} + ) + + SelectableRow( + title: "6 Decks", + subtitle: "Standard casino shoe", + isSelected: false, + action: {} + ) + } + + Divider().background(Color.white.opacity(0.1)) + SettingsToggle( title: "Sound Effects", subtitle: "Play sounds for game events",