Skip to content
RPRichard PruittTech & Cyber
Navigation
repo://simon-says-duo/MyApp/ContentView.swift

MyApp/ContentView.swift

35 KB · 728 lines · swift · Download from the repository page

MyApp/ContentView.swift
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)        }    }}