MyApp/ContentView.swift
35 KB · 728 lines · swift · Download from the repository page
import SwiftUIimport AVFoundation struct ContentView: View { @State private var game: GameSessionStore @State private var flow: GameFlowStore @State private var diagnostics = false @Environment(\.scenePhase) private var scenePhase init() { let store = GameSessionStore() _game = State(initialValue: store) _flow = State(initialValue: GameFlowStore(game: store)) } init(game: GameSessionStore, flow: GameFlowStore) { _game = State(initialValue: game) _flow = State(initialValue: flow) } var body: some View { Group { if flow.stage != .game { GuidedSetupView(flow: flow) } else { NavigationStack { RetroScreen { Picker("Host screen", selection: Binding(get: { game.hostTab }, set: { game.selectTab($0) })) { Text("Game").tag(HostTab.game) Text("Scoreboard").tag(HostTab.scoreboard) Text("Clips").tag(HostTab.clips) } .pickerStyle(.segmented) .disabled(!game.canChangeTab) switch game.hostTab { case .game: if !game.sessionStarted { PartySetupView(game: game) } else { HostDashboard(game: game) } case .scoreboard: ScoreboardView(game: game) case .clips: GalleryView(game: game) } } .retroTitle("Host Dashboard") .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("New Game") { flow.newGame() }.disabled(!game.canChangeTab) } .sharedBackgroundVisibility(.hidden) ToolbarItem(placement: .topBarTrailing) { Button("Diagnostics", systemImage: "stethoscope") { diagnostics = true } } .sharedBackgroundVisibility(.hidden) } .safeAreaInset(edge: .bottom) { if game.hostTab == .game && game.sessionStarted { GameActionBar(game: game) } } .sheet(isPresented: $diagnostics) { DiagnosticsView(game: game).retroAppearance() } } .retroAppearance() } } .background { DirectionProbe(outer: false) { ids, outer in game.directionChanged(ids: ids, outer: outer) } } .sceneAccessory { CameraCaptureAccessory(isEnabled: $game.accessoryEnabled) { ParticipantView( presentation: game.participant, image: game.frame, simulated: false, stop: { game.voidAttempt() }, directionChanged: { ids, outer in game.directionChanged(ids: ids, outer: outer) }, cueAppeared: { game.cueAppeared($0) }, cueDisappeared: { game.cueDisappeared($0) } ) .onAppear { game.accessoryPresented = true } .onDisappear { game.accessoryPresented = false if game.phase.active { game.interrupt("Outer display closed. No penalty.") } } } .onAvailabilityChange { available in game.accessoryAvailable = available if !available && game.phase.active { game.interrupt("Outer display unavailable. No penalty.") } } } .onChange(of: scenePhase) { _, newPhase in if newPhase != .active && (game.phase.active || game.cameraReady || game.isPreparingCamera) { game.interrupt("App left the foreground. Reframe before retrying; no penalty.") } } }} struct PartySetupView: View { @Bindable var game: GameSessionStore var body: some View { VStack(alignment: .leading, spacing: RetroTheme.spaceL) { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { RetroTag(title: "PLAYERS") ForEach($game.players) { $player in HStack { TextField("Player name", text: $player.name).textFieldStyle(.roundedBorder) if game.players.count > 2 { Button(role: .destructive) { game.players.removeAll { $0.id == player.id } } label: { Image(systemName: "minus.circle") } } } } Button("Add Player", systemImage: "plus") { game.players.append(Player(name: "Player \(game.players.count + 1)")) }.disabled(game.players.count >= 12) } } RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { RetroTag(title: "SESSION") Stepper("Challenges per turn: \(game.challengesPerTurn)", value: $game.challengesPerTurn, in: 1...10) Stepper("Response window: \(game.responseSeconds)s", value: $game.responseSeconds, in: 3...10) Toggle("Seated-friendly prompts", isOn: $game.seatedOnly) Toggle("Record video & audio clips", isOn: $game.recordingMode) Toggle("Delete clips at session end", isOn: $game.deleteAtEnd) PromptDeliveryView(game: game) } } Button("Start Party Session") { game.beginSession() } .disabled(game.players.count < 2) .frame(maxWidth: .infinity) } }} struct HostDashboard: View { @Bindable var game: GameSessionStore @State private var replay: Attempt? var body: some View { VStack(alignment: .leading, spacing: RetroTheme.spaceL) { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { HStack { VStack(alignment: .leading) { Text("Host: \(game.host.name)").font(.subheadline).foregroundStyle(RetroTheme.ink.opacity(0.7)) Text("Player: \(game.player.name)").font(RetroTheme.heading(.title2)) } Spacer() RetroTag(title: "\(min(game.scoredThisTurn + 1, game.challengesPerTurn)) / \(game.challengesPerTurn)", color: RetroTheme.yellow) } HStack(spacing: RetroTheme.spaceM) { Label("Manual judging", systemImage: "person.fill.checkmark").font(.caption.bold()) Label(game.phase.title, systemImage: "flag.fill").font(.caption) } } } PlayerSelectionView(game: game) if game.phase == .finished { RetroPanel(color: RetroTheme.green) { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { Text("Every player finished a turn.").font(RetroTheme.heading()) ForEach(game.players) { player in LabeledContent(player.name, value: "\(game.score(player)) pts") } Button("Start Another Round") { game.beginSession() }.frame(maxWidth: .infinity) } } } else { if game.recordingMode { framing } ActionLibraryPickerView(game: game) CustomActionSectionView(game: game) PromptDeliveryView(game: game) review } if !game.message.isEmpty { RetroPanel(color: RetroTheme.yellow) { Label(game.message, systemImage: "info.circle.fill").font(.footnote.bold()) } } } .sheet(item: $replay) { ClipPlayerView(game: game, attempt: $0).retroAppearance() } } private var framing: some View { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { RetroTag(title: "FRAMING & CONSENT") PreviewImage(image: game.frame).frame(height: 190).clipShape(.rect(cornerRadius: RetroTheme.radius)) .overlay(RoundedRectangle(cornerRadius: RetroTheme.radius).stroke(RetroTheme.ink, lineWidth: RetroTheme.border)) Picker("Camera facing player", selection: $game.cameraID) { Text("Select camera").tag("") ForEach(game.cameras, id: \.uniqueID) { Text($0.localizedName).tag($0.uniqueID) } }.disabled(game.phase.active) Toggle("\(game.player.name) agrees to video & audio recording for this turn", isOn: Binding( get: { game.consent }, set: { game.setRecordingConsent($0) })).disabled(game.phase.active) Button(game.isPreparingCamera ? "Checking Camera…" : "Confirm Framing & Ready") { Task { await game.prepareCamera() } }.disabled(!game.consent || game.cameraID.isEmpty || game.phase.active || game.isPreparingCamera) .frame(maxWidth: .infinity) } } } @ViewBuilder private var review: some View { if let attempt = game.current, game.phase == .awaitingReview || game.phase == .completed { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { RetroTag(title: "HOST VERDICT", color: RetroTheme.pink) Text(attempt.snapshot.resolvedActionSummary).font(.subheadline.bold()).foregroundStyle(RetroTheme.ink.opacity(0.75)) Text(attempt.snapshot.instruction).font(RetroTheme.heading(.title3)) if let cue = attempt.snapshot.visualCue { Text("Visual S/Z · Cue: \(cue.letter) (\(cue.requiresAction ? "perform" : "refrain")) · \(String(format: "%.2g", cue.duration))s") .font(RetroTheme.mono()) } else if let delivery = attempt.snapshot.delivery { Text("Spoken \(delivery.speed.title) · \(delivery.resolvedPlacement.title) placement") .font(RetroTheme.mono()) } HStack { Button("Correct") { game.judge(.correct) } .buttonStyle(RetroButtonStyle(color: RetroTheme.green)) Button("Wrong") { game.judge(.wrong) } .buttonStyle(RetroButtonStyle(color: RetroTheme.pink)) Button("Retry / No Penalty") { game.voidAttempt() } .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)) .disabled(game.isMediaInUse(attempt.id)) } if attempt.rawFile != nil { Button("Preview & Share Clip", systemImage: "play.rectangle") { replay = attempt } .buttonStyle(RetroButtonStyle(color: RetroTheme.yellow)) .frame(maxWidth: .infinity) } if game.phase == .completed { Button("Next Challenge") { game.next() }.frame(maxWidth: .infinity) } } } } }} struct ActionLibraryPickerView: View { @Bindable var game: GameSessionStore @Environment(\.dynamicTypeSize) private var dynamicTypeSize private var groupColumns: [GridItem] { dynamicTypeSize.isAccessibilitySize ? [GridItem(.flexible())] : [GridItem(.adaptive(minimum: 220), spacing: RetroTheme.spaceM)] } private var actionColumns: [GridItem] { dynamicTypeSize.isAccessibilitySize ? [GridItem(.flexible())] : [GridItem(.adaptive(minimum: 210), spacing: RetroTheme.spaceM)] } var body: some View { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { HStack(alignment: .center) { RetroTag(title: "ACTION LIBRARY") Spacer() Toggle("Seated-friendly", isOn: $game.seatedOnly) .labelsHidden() .disabled(!game.canEditChallengeSelection) Text("Seated only") .font(.caption.bold()) .foregroundStyle(RetroTheme.ink.opacity(0.75)) } if game.visiblePrompts.isEmpty { RetroEmptyState( title: "No actions match current filters", systemImage: "slider.horizontal.3", message: "Adjust Seated-friendly mode or enable more action groups to see built-in actions." ) Button("Reset Action Filters") { game.resetActionFilters() } .buttonStyle(RetroButtonStyle(color: RetroTheme.yellow)) .disabled(!game.canEditChallengeSelection) .frame(maxWidth: .infinity) } else if let category = game.selectedCategory { groupActionGrid(for: category) } else { categoryCardGrid } selectedActionSummary } } .disabled(!game.canEditChallengeSelection) } private var categoryCardGrid: some View { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { Text("1. Choose an action group") .font(RetroTheme.heading(.headline)) .accessibilityIdentifier("chooseGroupHeading") LazyVGrid(columns: groupColumns, spacing: RetroTheme.spaceM) { ForEach(game.availableCategories) { category in let count = game.availableActionCount(for: category) Button { game.selectGroup(category) } label: { VStack(alignment: .leading, spacing: RetroTheme.spaceXS) { HStack(alignment: .top) { Label(category.title, systemImage: category.symbol) .font(RetroTheme.heading(.title3)) Spacer() RetroTag( title: "\(count) \(count == 1 ? "ACTION" : "ACTIONS")", color: count > 0 ? RetroTheme.yellow : RetroTheme.surface ) } Text(category.summary) .font(.footnote) .foregroundStyle(RetroTheme.ink.opacity(0.8)) .multilineTextAlignment(.leading) if let note = category.comfortNote { Text(note) .font(.caption2) .foregroundStyle(RetroTheme.ink.opacity(0.65)) .multilineTextAlignment(.leading) } } .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)) .accessibilityIdentifier("categoryCard.\(category.rawValue)") } } } } @ViewBuilder private func groupActionGrid(for category: ActionCategory) -> some View { let groupPrompts = game.prompts(in: category) VStack(alignment: .leading, spacing: RetroTheme.spaceM) { HStack(alignment: .center, spacing: RetroTheme.spaceS) { Button { game.selectGroup(nil) } label: { Label("All Groups", systemImage: "chevron.left") } .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)) .accessibilityIdentifier("allGroupsButton") Spacer() Text("Actions / \(category.title)") .font(RetroTheme.mono(.caption)) .foregroundStyle(RetroTheme.ink.opacity(0.75)) .accessibilityIdentifier("groupBreadcrumb") } HStack(alignment: .firstTextBaseline) { Label(category.title, systemImage: category.symbol) .font(RetroTheme.heading(.title2)) Spacer() RetroTag( title: "\(groupPrompts.count) AVAILABLE", color: groupPrompts.isEmpty ? RetroTheme.surface : RetroTheme.yellow ) .accessibilityIdentifier("groupAvailableCount") } if let comfortNote = category.comfortNote { HStack(alignment: .top, spacing: RetroTheme.spaceXS) { Image(systemName: "figure.walk.motion") Text(comfortNote) .font(.footnote.bold()) } .padding(RetroTheme.spaceS) .frame(maxWidth: .infinity, alignment: .leading) .background(RetroTheme.yellow.opacity(0.35)) .clipShape(RoundedRectangle(cornerRadius: RetroTheme.radius)) .overlay(RoundedRectangle(cornerRadius: RetroTheme.radius).stroke(RetroTheme.ink, lineWidth: 1.5)) .accessibilityIdentifier("balanceComfortNote") } if groupPrompts.isEmpty { RetroEmptyState( title: "No \(category.title.lowercased()) available with Seated only", systemImage: category.symbol, message: "Turn off Seated-friendly mode to use standing \(category.title.lowercased()), or go back to All Groups." ) HStack(spacing: RetroTheme.spaceS) { Button("Include Standing Actions") { game.seatedOnly = false } .buttonStyle(RetroButtonStyle(color: RetroTheme.yellow)) Button("All Groups") { game.selectGroup(nil) } .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)) } } else { LazyVGrid(columns: actionColumns, spacing: RetroTheme.spaceS) { ForEach(groupPrompts) { prompt in let isSelected = game.actionSource == .catalog && game.selectedPrompt?.id == prompt.id Button { game.selectPrompt(prompt) } label: { VStack(alignment: .leading, spacing: RetroTheme.spaceXS) { HStack(alignment: .top) { Text(prompt.title) .font(RetroTheme.heading(.headline)) .multilineTextAlignment(.leading) Spacer() if isSelected { Image(systemName: "checkmark.circle.fill") .font(.headline) .accessibilityLabel("Selected") } } Text(prompt.displaySentence(fingers: game.fingers, hand: game.hand)) .font(.caption) .foregroundStyle(RetroTheme.ink.opacity(0.85)) .multilineTextAlignment(.leading) HStack(spacing: RetroTheme.spaceXS) { RetroTag(title: prompt.shortFramingHint.uppercased(), color: RetroTheme.surface) RetroTag(title: prompt.judgingSupport.badgeTitle.uppercased(), color: RetroTheme.blue) } } .frame(maxWidth: .infinity, alignment: .leading) } .buttonStyle(RetroButtonStyle(color: isSelected ? RetroTheme.yellow : RetroTheme.surface)) .accessibilityAddTraits(isSelected ? .isSelected : []) .accessibilityIdentifier("actionCard.\(prompt.canonicalSuggestedID)") } } } } } @ViewBuilder private var selectedActionSummary: some View { if game.actionSource == .catalog, let prompt = game.selectedPrompt { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { Rectangle().fill(RetroTheme.ink).frame(height: 1.5) HStack(spacing: RetroTheme.spaceXS) { RetroTag(title: "SELECTED ACTION", color: RetroTheme.green) RetroTag(title: prompt.shortFramingHint.uppercased(), color: RetroTheme.yellow) RetroTag(title: prompt.judgingSupport.badgeTitle.uppercased(), color: RetroTheme.blue) } Text(prompt.title) .font(RetroTheme.heading(.headline)) Text(prompt.displaySentence(fingers: game.fingers, hand: game.hand)) .font(RetroTheme.mono(.subheadline)) .accessibilityIdentifier("selectedActionWording") Text("\(prompt.shortFramingHint) · \(prompt.framing) · \(prompt.judgingSupport.detailText)") .font(.caption) .foregroundStyle(RetroTheme.ink.opacity(0.75)) if prompt.supportsFingerParameters { fingerParameterControls } if let comfort = prompt.comfortNote, game.selectedCategory != .balances { Label(comfort, systemImage: "figure.stand") .font(.caption.bold()) .foregroundStyle(RetroTheme.ink.opacity(0.85)) } if let recommendation = prompt.durationRecommendation(configuredSeconds: game.responseSeconds) { Label(recommendation, systemImage: "clock.badge.exclamationmark") .font(.caption.bold()) .foregroundStyle(RetroTheme.ink.opacity(0.85)) .accessibilityIdentifier("durationRecommendation") } } } else if game.actionSource == .catalog && game.selectedCategory != nil { VStack(alignment: .leading, spacing: RetroTheme.spaceXS) { Rectangle().fill(RetroTheme.ink).frame(height: 1.5) Label("Choose an action in this group to enable Send.", systemImage: "hand.tap.fill") .font(.footnote.bold()) .foregroundStyle(RetroTheme.ink.opacity(0.8)) .accessibilityIdentifier("selectActionPrompt") } } } private var fingerParameterControls: some View { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { Text("Finger Count (1–5)") .font(RetroTheme.mono(.caption)) Picker("Finger count", selection: Binding( get: { game.fingers }, set: { game.setFingerCount($0) } )) { ForEach(1...5, id: \.self) { count in Text("\(count)").tag(count) } } .pickerStyle(.segmented) .accessibilityIdentifier("fingerCountPicker") Text("Hand Choice") .font(RetroTheme.mono(.caption)) Picker("Hand choice", selection: Binding( get: { game.hand }, set: { game.setHandSelection($0) } )) { ForEach(HandSelection.allCases) { option in Text(option.title).tag(option) } } .pickerStyle(.segmented) .accessibilityIdentifier("handSelectionPicker") } .padding(RetroTheme.spaceS) .background(RetroTheme.surface) .clipShape(RoundedRectangle(cornerRadius: RetroTheme.radius)) .overlay(RoundedRectangle(cornerRadius: RetroTheme.radius).stroke(RetroTheme.ink, lineWidth: 1.5)) }} struct CustomActionSectionView: View { @Bindable var game: GameSessionStore private var hasDraft: Bool { !game.customText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var body: some View { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { HStack { RetroTag( title: "CUSTOM ACTION", color: game.actionSource == .custom && hasDraft ? RetroTheme.green : RetroTheme.yellow ) Spacer() RetroTag( title: game.actionSource == .custom && hasDraft ? "ACTIVE SOURCE: CUSTOM" : "ACTIVE SOURCE: CATALOG", color: RetroTheme.surface ) .accessibilityIdentifier("activeActionSourceTag") } HStack { TextField("Optional custom action (manual only)", text: $game.customText) .textFieldStyle(.roundedBorder) .disabled(!game.canEditChallengeSelection) if !game.customText.isEmpty { Button("Clear") { game.clearCustomSuggestion() } .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)) .disabled(!game.canEditChallengeSelection) } } if game.actionSource == .catalog && hasDraft { HStack { Text("Custom draft saved (“\(game.customText)”). Built-in catalog action is currently active.") .font(.caption) .foregroundStyle(RetroTheme.ink.opacity(0.75)) Spacer() Button("Use Custom") { game.activateCustomAction() } .buttonStyle(RetroButtonStyle(color: RetroTheme.yellow)) .disabled(!game.canEditChallengeSelection) .accessibilityIdentifier("useCustomActionButton") } } else if game.actionSource == .custom && hasDraft { Text("Wording: \(game.selectedActionDisplayWording) · Manual judging") .font(RetroTheme.mono(.caption)) .foregroundStyle(RetroTheme.ink.opacity(0.8)) } if let validation = game.customValidation { Text(validation) .font(.footnote.bold()) .foregroundStyle(RetroTheme.error) } } } }} struct PlayerSelectionView: View { @Bindable var game: GameSessionStore var body: some View { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { HStack { RetroTag(title: "ACTIVE PLAYER") Spacer() if !game.canSelectPlayer { Text("Locked during attempt").font(RetroTheme.mono(.caption)).foregroundStyle(RetroTheme.ink.opacity(0.6)) } } ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: RetroTheme.spaceS) { ForEach(game.players) { candidate in let selected = candidate.id == game.player.id Button { game.selectPlayer(candidate.id) } label: { HStack(spacing: 6) { Image(systemName: selected ? "Checkmark.circle.fill".lowercased() : "person.crop.circle") Text(candidate.name).lineLimit(1) Text("\(game.score(candidate))p").font(RetroTheme.mono(.caption)) } } .buttonStyle(RetroButtonStyle(color: selected ? RetroTheme.yellow : RetroTheme.surface)) .disabled(!game.canSelectPlayer) .accessibilityIdentifier("playerChip.\(candidate.name)") } }.padding(.vertical, 2) } } } }} struct PromptDeliveryView: View { @Bindable var game: GameSessionStore var body: some View { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { RetroTag(title: "PROMPT DELIVERY") Picker("Cue Mode", selection: $game.cueMode) { ForEach(CueMode.allCases) { Text($0.title).tag($0) } }.pickerStyle(.segmented).disabled(game.phase.active) VStack(alignment: .leading, spacing: RetroTheme.spaceXS) { HStack { Text("Speech speed") Spacer() Text(game.speed.title).font(RetroTheme.mono()) } Slider(value: Binding( get: { Double(game.speed.rawValue) }, set: { game.speed = SpeechSpeed(rawValue: Int($0)) ?? .normal } ), in: 0...3, step: 1).disabled(game.phase.active).tint(RetroTheme.ink) } if game.cueMode == .spoken { Picker("Simon says placement", selection: $game.placement) { ForEach(CuePlacement.allCases) { Text($0.title).tag($0) } }.disabled(game.phase.active) Text("Listen to the whole instruction. Simon says can appear at the beginning, middle, or end.") .font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.75)) } else { Picker("Visual cue duration", selection: $game.cueDuration) { ForEach(CueDuration.allCases) { Text($0.title).tag($0) } }.disabled(game.phase.active) Text(game.visualAvailable ? "Outer display ready. S = perform action; Z = do not perform action." : "Visual S/Z requires recording mode and an active outer camera display. Switch to Spoken Simon Says on single-screen devices.") .font(.footnote).foregroundStyle(game.visualAvailable ? RetroTheme.ink.opacity(0.75) : RetroTheme.error) } Button(game.isTestingVoice ? "Stop Voice Test" : "Test Voice", systemImage: "speaker.wave.2") { if game.isTestingVoice { game.stopVoiceTest() } else { game.testVoice() } } .buttonStyle(RetroButtonStyle(color: RetroTheme.surface)) .disabled(game.phase.active) } } }} struct ScoreboardView: View { let game: GameSessionStore var body: some View { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { RetroSectionHeader(title: "Scoreboard") Text("Current game standings update after each confirmed verdict.").font(.caption) ForEach(game.scoreboard) { entry in let active = entry.player.id == game.player.id RetroPanel(color: active ? RetroTheme.yellow : RetroTheme.surface) { HStack(spacing: RetroTheme.spaceM) { Text("#\(entry.rank)").font(RetroTheme.heading(.title2)) VStack(alignment: .leading, spacing: 4) { HStack { Text(entry.player.name).font(RetroTheme.heading(.headline)) if active { RetroTag(title: "UP NOW", color: RetroTheme.green) } } Text("Correct \(entry.correct) · Wrong \(entry.wrong) · Completed \(entry.completed) · Rate \(entry.successRate)") .font(.caption).foregroundStyle(RetroTheme.ink.opacity(0.75)) } Spacer() Text("\(entry.points) pts").font(RetroTheme.mono(.title3)) } } } } }} struct GameActionBar: View { @Bindable var game: GameSessionStore var body: some View { RetroBottomBar { if !game.canSend && !game.phase.active { Text(game.activeValidationError ?? (game.actionSource == .catalog && game.selectedPrompt == nil ? "Choose a group and select an action before sending." : (game.cueMode == .visual && !game.visualAvailable ? "Visual S/Z needs an active outer display, or switch to Spoken mode." : "Confirm framing and enter or select a valid action."))) .font(.caption.bold()).foregroundStyle(RetroTheme.ink.opacity(0.75)) } HStack(spacing: RetroTheme.spaceS) { Button(game.cueMode == .visual ? "SEND S" : "SIMON SAYS") { game.send(simonSays: true) } .buttonStyle(RetroButtonStyle(color: RetroTheme.green)) .disabled(!game.canSend) .frame(maxWidth: .infinity) Button(game.cueMode == .visual ? "SEND Z" : "SEND") { game.send(simonSays: false) } .buttonStyle(RetroButtonStyle(color: RetroTheme.yellow)) .disabled(!game.canSend) .frame(maxWidth: .infinity) } Button("Stop Challenge", role: .destructive) { game.interrupt("Stopped by host or participant. No penalty.") } .buttonStyle(RetroButtonStyle(color: RetroTheme.pink)) .disabled(!game.phase.active) .frame(maxWidth: .infinity) } }}