Parent directory

GameView.swift

6018 bytes
  1import SwiftUI
  2
  3struct GameView: View {
  4    let settings: SettingsStore
  5
  6    @State private var store: GameStore
  7    @State private var input = ""
  8    @State private var confirmingNewGame = false
  9
 10    init(settings: SettingsStore) {
 11        self.settings = settings
 12        _store = State(initialValue: GameStore(settings: settings))
 13    }
 14
 15    var body: some View {
 16        Group {
 17            if store.sessionID == nil && store.entries.isEmpty {
 18                emptyState
 19            } else {
 20                logList
 21            }
 22        }
 23        .navigationTitle("Adventure")
 24        .navigationBarTitleDisplayMode(.inline)
 25        .toolbar { toolbarContent }
 26        .safeAreaInset(edge: .bottom, spacing: 0) { inputBar }
 27        .task(id: settings.serverURL + settings.token) {
 28            await store.connect()
 29        }
 30    }
 31
 32    @ToolbarContentBuilder
 33    private var toolbarContent: some ToolbarContent {
 34        ToolbarItem(placement: .topBarTrailing) {
 35            Button(action: store.replay) {
 36                Image(systemName: "speaker.wave.2")
 37            }
 38            .accessibilityLabel("Replay last line")
 39            .disabled(!store.hasAudio || store.phase != .idle)
 40
 41            Button(action: startNewGame) {
 42                if store.phase == .processing && store.entries.isEmpty {
 43                    ProgressView()
 44                } else {
 45                    Image(systemName: "book")
 46                }
 47            }
 48            .accessibilityLabel("New adventure")
 49            .disabled(store.phase != .idle)
 50            .confirmationDialog(
 51                "Start a new adventure?",
 52                isPresented: $confirmingNewGame,
 53                titleVisibility: .visible
 54            ) {
 55                Button("Start") {
 56                    Task { await store.newGame() }
 57                }
 58                Button("Cancel", role: .cancel) {}
 59            } message: {
 60                Text("The current adventure will be discarded.")
 61            }
 62
 63            NavigationLink {
 64                SettingsView(settings: settings)
 65            } label: {
 66                Image(systemName: "gear")
 67            }
 68            .accessibilityLabel("Settings")
 69        }
 70    }
 71
 72    private var emptyState: some View {
 73        ContentUnavailableView {
 74            Label("New Adventure", systemImage: "book")
 75        } description: {
 76            Text("Start a new game to begin.")
 77        } actions: {
 78            Button {
 79                Task { await store.newGame() }
 80            } label: {
 81                if store.phase == .processing {
 82                    ProgressView()
 83                } else {
 84                    Text("Start")
 85                }
 86            }
 87            .buttonStyle(.glassProminent)
 88
 89            NavigationLink {
 90                SettingsView(settings: settings)
 91            } label: {
 92                Text("Settings")
 93            }
 94        }
 95    }
 96
 97    private var logList: some View {
 98        List {
 99            ForEach(Array(store.entries.enumerated()), id: \.offset) { _, entry in
100                EntryView(entry: entry)
101            }
102        }
103        .defaultScrollAnchor(.bottom)
104        .overlay {
105            if store.phase == .processing && store.entries.isEmpty {
106                ProgressView()
107            }
108        }
109    }
110
111    private var inputBar: some View {
112        VStack(spacing: 8) {
113            if let status = store.status {
114                Text(status)
115                    .font(.footnote)
116                    .foregroundStyle(.secondary)
117                    .frame(maxWidth: .infinity, alignment: .leading)
118            }
119            GlassEffectContainer(spacing: 8) {
120                HStack(spacing: 12) {
121                    TextField("What do you do?", text: $input)
122                        .textFieldStyle(.plain)
123                        .frame(maxWidth: .infinity)
124                        .frame(minHeight: 44)
125                        .padding(.horizontal, 16)
126                        .glassEffect()
127                        .disabled(store.phase != .idle)
128                        .onSubmit(submit)
129
130                    Button(action: store.toggleTalk) {
131                        Image(systemName: store.phase == .recording ? "stop.fill" : "mic")
132                            .foregroundStyle(store.phase == .recording ? Color.red : Color.primary)
133                            .frame(width: 44, height: 44)
134                            .glassEffect(.regular.interactive(), in: .circle)
135                            .contentShape(.interaction, .circle)
136                    }
137                    .buttonStyle(.plain)
138                    .disabled(store.phase != .idle && store.phase != .recording)
139                    .accessibilityLabel(store.phase == .recording ? "Stop recording" : "Start recording")
140
141                    if store.phase == .processing {
142                        ProgressView()
143                            .frame(width: 44, height: 44)
144                    } else {
145                        Button(action: submit) {
146                            Image(systemName: "arrow.up")
147                                .frame(width: 44, height: 44)
148                                .glassEffect(.regular.interactive(), in: .circle)
149                                .contentShape(.interaction, .circle)
150                        }
151                        .buttonStyle(.plain)
152                        .disabled(!GameStore.isSendable(input.trimmingCharacters(in: .whitespacesAndNewlines)))
153                    }
154                }
155            }
156        }
157        .padding(.horizontal, 20)
158        .padding(.bottom, 8)
159    }
160
161    private func startNewGame() {
162        if store.entries.isEmpty {
163            Task { await store.newGame() }
164        } else {
165            confirmingNewGame = true
166        }
167    }
168
169    private func submit() {
170        let action = input.trimmingCharacters(in: .whitespacesAndNewlines)
171        guard GameStore.isSendable(action) else {
172            return
173        }
174        input = ""
175        Task { await store.send(action) }
176    }
177}