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}