MyApp/GuidedSetupView.swift
11 KB · 184 lines · swift · Download from the repository page
import SwiftUI struct GuidedSetupView: View { @Bindable var flow: GameFlowStore @Environment(\.openURL) private var openURL var body: some View { NavigationStack { RetroScreen { RetroStepIndicator(step: flow.stage == .consent ? 1 : 2) RetroSectionHeader(title: flow.stage == .consent ? "Before We Play" : "Build Your Game") if flow.stage == .consent { consent } else { SetupSections(flow: flow) } if !flow.error.isEmpty { Label(flow.error, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(RetroTheme.error).font(.body.bold()).accessibilityIdentifier("flowError") } } .safeAreaInset(edge: .bottom) { RetroBottomBar { if !flow.error.isEmpty { Label(flow.error, systemImage: "exclamationmark.triangle.fill") .font(.footnote.bold()).foregroundStyle(RetroTheme.error) } if flow.stage == .consent { Button("AGREE & CONTINUE") { Task { await flow.continueConsent(recording: true) } } .disabled(!flow.acknowledged || flow.busy) .frame(maxWidth: .infinity) } else { Button(flow.isResuming ? "RESUME GAME" : "START GAME") { flow.startGame() } .accessibilityIdentifier("startGame").disabled(flow.busy) .frame(maxWidth: .infinity) Button("Back") { flow.game.stopVoiceTest(); flow.back() }.buttonStyle(RetroButtonStyle(color: RetroTheme.surface)) .frame(maxWidth: .infinity) } } } .retroTitle("Simon Says Duo") .sheet(isPresented: $flow.libraryPresented) { GalleryView(game: flow.game, libraryOnly: true) .retroAppearance() } }.retroAppearance() } private var consent: some View { VStack(alignment: .leading, spacing: RetroTheme.spaceL) { if flow.canResume && !flow.isResuming { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { RetroTag(title: "GAME SAVED") Text("Your scores and clips are still here.").font(.headline) Button("Resume Game") { flow.resume() }.frame(maxWidth: .infinity) Button("New Game") { flow.newGame() }.buttonStyle(RetroButtonStyle(color: RetroTheme.yellow)).frame(maxWidth: .infinity) } } } RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { Label("Everyone gets a say.", systemImage: "video.fill").font(RetroTheme.heading(.title2)) Text("Challenges record the participant’s camera video and microphone audio. Clips stay locally in this app. You can replay, delete, or explicitly share them.") Text("Exported copies follow their destination’s storage and sync settings. Supported analysis runs on-device; this version uses host judgment.") Text("Every participant must agree before being recorded. Anyone can stop or decline without a penalty. Ask a guardian where appropriate.") Toggle("I understand and will confirm each participant’s agreement", isOn: $flow.acknowledged) .accessibilityIdentifier("recordingAcknowledgement") Text("Camera permissions are separate from participants’ consent. No camera or recording starts in setup.").font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.7)) } } if flow.permissionDenied { Button("Open Settings") { if let url = URL(string: UIApplication.openSettingsURLString) { openURL(url) } }.frame(maxWidth: .infinity) } Button("Play without recording") { Task { await flow.continueConsent(recording: false) } } .disabled(flow.busy).buttonStyle(RetroButtonStyle(color: RetroTheme.yellow)).frame(maxWidth: .infinity) Text("Nonrecording play uses spoken prompts and host judgment. No camera, outer preview, Visual S/Z, or saved video.").font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.7)) Button("Saved Clips", systemImage: "photo.stack") { flow.libraryPresented = true } .buttonStyle(RetroButtonStyle(color: RetroTheme.pink)).frame(maxWidth: .infinity) } }}struct SetupSections: View { @Bindable var flow: GameFlowStore var body: some View { VStack(alignment: .leading, spacing: RetroTheme.spaceL) { RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { RetroTag(title: "01 / PLAYERS") Text("The cast").font(RetroTheme.heading()) ForEach($flow.draft.players) { $player in VStack(alignment: .leading, spacing: RetroTheme.spaceS) { HStack { Image(systemName: player.avatar).font(.title2) TextField("Player name", text: $player.name).textFieldStyle(.roundedBorder) .disabled(flow.isResuming) if !flow.isResuming && flow.draft.players.count > 2 { Button("Remove", systemImage: "minus.circle") { flow.removePlayer(player.id) } .labelStyle(.iconOnly).buttonStyle(RetroButtonStyle(color: RetroTheme.surface)) } } Text("Player ID \(player.id.uuidString.prefix(4))").font(.caption.monospaced()) if flow.draft.recording { Toggle("\(player.name) agrees to recording", isOn: Binding( get: { flow.draft.assents[player.id] != nil }, set: { flow.assent(player.id, agrees: $0) })) } Rectangle().fill(RetroTheme.ink).frame(height: 1) } } if !flow.isResuming { Button("Add Player", systemImage: "plus") { flow.addPlayer() }.disabled(flow.draft.players.count >= 12) Picker("Playing first", selection: $flow.draft.activePlayerID) { Text("First in roster").tag(nil as UUID?) ForEach(flow.draft.players) { Text("\($0.name) · \($0.id.uuidString.prefix(4))").tag(Optional($0.id)) } } Picker("Holding the phone", selection: $flow.draft.hostID) { Text("Last in roster").tag(nil as UUID?) ForEach(flow.draft.players) { Text("\($0.name) · \($0.id.uuidString.prefix(4))").tag(Optional($0.id)) } } } } } RetroPanel { VStack(alignment: .leading, spacing: RetroTheme.spaceM) { RetroTag(title: "02 / GAME DEFAULTS") Stepper("\(flow.draft.challenges) challenges per turn", value: $flow.draft.challenges, in: 1...10) Stepper("\(flow.draft.response)-second response window", value: $flow.draft.response, in: 3...10) Label("Manual judging", systemImage: "person.fill.checkmark") Text("The host confirms every result. Automatic recognition has not been validated.").font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.7)) Toggle("Seated-friendly prompts", isOn: $flow.draft.seated) DisclosureGroup("Action groups (\(flow.draft.categorySet.count) of \(ActionCategory.allCases.count))") { VStack(alignment: .leading, spacing: RetroTheme.spaceXS) { ForEach(ActionCategory.allCases) { category in Toggle(isOn: Binding( get: { flow.draft.categorySet.contains(category) }, set: { flow.toggleCategory(category, enabled: $0) } )) { Label(category.title, systemImage: category.symbol) } } }.padding(.top, RetroTheme.spaceXS) } DraftDeliveryView(flow: flow) DisclosureGroup("Recording & retention") { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { Text(flow.draft.recording ? "Recording enabled with participant assent." : "Nonrecording mode") Toggle("Delete media at session end", isOn: $flow.draft.deleteAtEnd) Text("Otherwise clips expire after seven days, checked when the app runs. Scores remain saved.").font(.footnote).foregroundStyle(RetroTheme.ink.opacity(0.7)) }.padding(.top, RetroTheme.spaceXS) } }.disabled(flow.isResuming) } RetroPanel(color: RetroTheme.yellow) { Text("\(flow.draft.players.count) PLAYERS / \(flow.draft.challenges) CHALLENGES EACH TURN / \(flow.draft.cueMode.title.uppercased()) / \(flow.draft.speed.title.uppercased()) VOICE") .font(RetroTheme.mono(.headline)) } } }}struct DraftDeliveryView: View { @Bindable var flow: GameFlowStore var body: some View { VStack(alignment: .leading, spacing: RetroTheme.spaceS) { Picker("Speech speed", selection: $flow.draft.speed) { ForEach(SpeechSpeed.allCases) { Text($0.title).tag($0) } } Picker("Cue Mode", selection: $flow.draft.cueMode) { ForEach(CueMode.allCases) { Text($0.title).tag($0) } } if flow.draft.cueMode == .spoken { Picker("Simon says placement", selection: $flow.draft.placement) { ForEach(CuePlacement.allCases) { Text($0.title).tag($0) } } Text("Listen to the whole instruction. Simon says can appear at the beginning, middle, or end.").font(.footnote) } else { Picker("Visual cue duration", selection: $flow.draft.cueDuration) { ForEach(CueDuration.allCases) { Text($0.title).tag($0) } } Text("After speech, the outside display shows S = perform or Z = refrain. Availability is checked in Game.").font(.footnote) } Button(flow.game.isTestingVoice ? "Stop Voice Test" : "Test Voice") { if flow.game.isTestingVoice { flow.game.stopVoiceTest() } else { flow.testVoice() } } }.onChange(of: flow.draft.speed) { _, _ in flow.game.stopVoiceTest() } }}