fc696bed432a5ef279bdaae0f5e3ec47c6a7af5e

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

Add adventure log UI with push-to-talk recording and playback

Diff

This diff is truncated to protect this page.

  1diff --git a/Resources/Info.plist b/Resources/Info.plist
  2new file mode 100644
  3index 0000000000000000000000000000000000000000..f710f692bfee1fcaed38bceaa1576681636eec3e
  4--- /dev/null
  5+++ b/Resources/Info.plist
  6@@ -0,0 +1,8 @@
  7+<?xml version="1.0" encoding="UTF-8"?>
  8+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
  9+<plist version="1.0">
 10+<dict>
 11+    <key>NSMicrophoneUsageDescription</key>
 12+    <string>jp records your voice for push-to-talk practice.</string>
 13+</dict>
 14+</plist>
 15diff --git a/Sources/japanese/APIClient.swift b/Sources/japanese/APIClient.swift
 16index c4b81f80adf6ba734a7ca917517d170dbf3d3968..0620b3d0874d9e0c479b2bc6430afc5a7745df39 100644
 17--- a/Sources/japanese/APIClient.swift
 18+++ b/Sources/japanese/APIClient.swift
 19@@ -38,6 +38,20 @@ struct TurnResponse: Decodable, Sendable {
 20     let judgeError: String?
 21 }
 22 
 23+extension TurnResponse {
 24+    var entry: LogEntry {
 25+        LogEntry(
 26+            action: action,
 27+            desc: desc,
 28+            romaji: romaji,
 29+            english: english,
 30+            hasSpeech: hasSpeech,
 31+            judge: judge,
 32+            ask: ask
 33+        )
 34+    }
 35+}
 36+
 37 private struct CreateSessionResponse: Decodable, Sendable {
 38     let id: String
 39 }
 40diff --git a/Sources/japanese/ContentView.swift b/Sources/japanese/ContentView.swift
 41index acf0f4539062d32bf44913f2b6a5083c3ff6524b..261f6234a812321b449aebf2b69944af5fd22736 100644
 42--- a/Sources/japanese/ContentView.swift
 43+++ b/Sources/japanese/ContentView.swift
 44@@ -1,13 +1,17 @@
 45 import SwiftUI
 46 
 47 struct ContentView: View {
 48+    @State private var settings = SettingsStore()
 49+
 50     var body: some View {
 51-        VStack {
 52-            Image(systemName: "globe")
 53-                .imageScale(.large)
 54-                .foregroundStyle(.tint)
 55-            Text("Hello, world!")
 56+        if settings.configuration == nil {
 57+            NavigationStack {
 58+                SettingsView(settings: settings)
 59+            }
 60+        } else {
 61+            NavigationStack {
 62+                GameView(settings: settings)
 63+            }
 64         }
 65-        .padding()
 66     }
 67 }
 68diff --git a/Sources/japanese/EntryView.swift b/Sources/japanese/EntryView.swift
 69new file mode 100644
 70index 0000000000000000000000000000000000000000..0aa7496e7941c478f5e6a43c7840ae963806cf4b
 71--- /dev/null
 72+++ b/Sources/japanese/EntryView.swift
 73@@ -0,0 +1,93 @@
 74+import SwiftUI
 75+
 76+struct EntryView: View {
 77+    let entry: LogEntry
 78+
 79+    @State private var isRevealed = false
 80+
 81+    var body: some View {
 82+        VStack(alignment: .leading, spacing: 10) {
 83+            if let action = entry.action {
 84+                Text("> \(action)")
 85+                    .font(.callout.monospaced())
 86+                    .foregroundStyle(.secondary)
 87+            }
 88+            if let desc = entry.desc {
 89+                Text(desc)
 90+            }
 91+            if entry.hasSpeech {
 92+                speechCard
 93+            }
 94+            if let judge = entry.judge {
 95+                if let said = judge.romaji, !said.isEmpty {
 96+                    Text("said: \(said)")
 97+                        .font(.callout)
 98+                        .foregroundStyle(.secondary)
 99+                }
100+                section("Judge") {
101+                    row("Score", "\(judge.score)/10")
102+                    if let feedback = judge.feedback, !feedback.isEmpty {
103+                        row("Feedback", feedback)
104+                    }
105+                }
106+            }
107+            if let ask = entry.ask {
108+                section("Ask") {
109+                    if let romaji = ask.romaji, !romaji.isEmpty {
110+                        row("Romaji", romaji)
111+                    }
112+                    if let translation = ask.translation, !translation.isEmpty {
113+                        row("Translation", translation)
114+                    }
115+                    if let breakdown = ask.breakdown, !breakdown.isEmpty {
116+                        row("Breakdown", breakdown)
117+                    }
118+                }
119+            }
120+        }
121+    }
122+
123+    private var speechCard: some View {
124+        Button {
125+            isRevealed.toggle()
126+        } label: {
127+            VStack(alignment: .leading, spacing: 4) {
128+                if isRevealed {
129+                    Text("» \(entry.romaji ?? "")")
130+                    if let english = entry.english, !english.isEmpty {
131+                        Text(english)
132+                            .font(.footnote)
133+                            .foregroundStyle(.secondary)
134+                    }
135+                } else {
136+                    Label("Tap to reveal", systemImage: "eye.slash")
137+                        .font(.footnote)
138+                        .foregroundStyle(.secondary)
139+                }
140+            }
141+            .frame(maxWidth: .infinity, alignment: .leading)
142+            .padding(12)
143+            .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 12))
144+        }
145+        .buttonStyle(.plain)
146+    }
147+
148+    private func section(_ title: String, @ViewBuilder rows: () -> some View) -> some View {
149+        VStack(alignment: .leading, spacing: 4) {
150+            Text(title)
151+                .font(.footnote.weight(.semibold))
152+                .foregroundStyle(.secondary)
153+            rows()
154+        }
155+    }
156+
157+    private func row(_ key: String, _ value: String) -> some View {
158+        HStack(alignment: .firstTextBaseline, spacing: 12) {
159+            Text(key)
160+                .foregroundStyle(.secondary)
161+                .frame(width: 84, alignment: .leading)
162+            Text(value)
163+        }
164+        .font(.footnote)
165+    }
166+}
167diff --git a/Sources/japanese/GameStore.swift b/Sources/japanese/GameStore.swift
168new file mode 100644
169index 0000000000000000000000000000000000000000..31a0bdb9fa7e2b18aff9afab440858aef788bfeb
170--- /dev/null
171+++ b/Sources/japanese/GameStore.swift
172@@ -0,0 +1,208 @@
173+import Foundation
174+import Observation
175+import japaneseShared
176+
177+@MainActor
178+@Observable
179+final class GameStore {
180+    enum Phase {
181+        case idle
182+        case recording
183+        case processing
184+    }
185+
186+    private static let sessionIDKey = "sessionID"
187+    private static let recordCap: Duration = .seconds(15)
188+    private static let openingAction = "Look around."
189+
190+    private let settings: SettingsStore
191+    private let api = APIClient()
192+    private let recorder = Recorder()
193+    private let player = Player()
194+    private var capTask: Task<Void, Never>?
195+
196+    private(set) var sessionID: String?
197+    private(set) var entries: [LogEntry] = []
198+    private(set) var phase: Phase = .idle
199+    private(set) var status: String?
200+    private(set) var lastAudio: Data?
201+
202+    init(settings: SettingsStore) {
203+        self.settings = settings
204+        sessionID = UserDefaults.standard.string(forKey: Self.sessionIDKey)
205+    }
206+
207+    var hasAudio: Bool { lastAudio != nil }
208+
209+    // Reconnect to a persisted session. Sessions are in-memory on the server,
210+    // so any failure means starting fresh.
211+    func connect() async {
212+        guard let configuration = settings.configuration else {
213+            return
214+        }
215+        entries = []
216+        guard let sessionID else {
217+            return
218+        }
219+        do {
220+            entries = try await api.fetchLog(sessionID: sessionID, using: configuration)
221+        } catch {
222+            setSession(nil)
223+        }
224+    }
225+
226+    func newGame() async {
227+        guard phase == .idle, let configuration = settings.configuration else {
228+            return
229+        }
230+        setPhase(.processing)
231+        defer { setPhase(.idle) }
232+        do {
233+            let id = try await api.createSession(using: configuration)
234+            setSession(id)
235+            apply(try await api.performAction(sessionID: id, action: Self.openingAction, using: configuration))
236+        } catch {
237+            status = "turn failed: \(error.localizedDescription)"
238+        }
239+    }
240+
241+    func send(_ rawAction: String) async {
242+        let action = rawAction.trimmingCharacters(in: .whitespacesAndNewlines)
243+        guard phase == .idle, Self.isSendable(action), let configuration = settings.configuration, let sessionID else {
244+            return
245+        }
246+        setPhase(.processing)
247+        defer { setPhase(.idle) }
248+        do {
249+            if action.hasPrefix("!") {
250+                let added = try await api.addFlashcards(
251+                    sessionID: sessionID,
252+                    instructions: String(action.dropFirst()),
253+                    using: configuration
254+                )
255+                status = flashcardStatus(added)
256+            } else {
257+                apply(try await api.performAction(sessionID: sessionID, action: action, using: configuration))
258+            }
259+        } catch {
260+            status = "turn failed: \(error.localizedDescription)"
261+        }
262+    }
263+
264+    func toggleTalk() {
265+        switch phase {
266+        case .idle:
267+            beginRecording()
268+        case .recording:
269+            finishRecording()
270+        case .processing:
271+            break
272diff --git a/Sources/japanese/GameView.swift b/Sources/japanese/GameView.swift
273new file mode 100644
274index 0000000000000000000000000000000000000000..3b8e51b3eb1f421e9e023d37626c1d179a80e4f4
275--- /dev/null
276+++ b/Sources/japanese/GameView.swift
277@@ -0,0 +1,144 @@
278+import SwiftUI
279+
280+struct GameView: View {
281+    let settings: SettingsStore
282+
283+    @State private var store: GameStore
284+    @State private var input = ""
285+
286+    init(settings: SettingsStore) {
287+        self.settings = settings
288+        _store = State(initialValue: GameStore(settings: settings))
289+    }
290+
291+    var body: some View {
292+        Group {
293+            if store.sessionID == nil && store.entries.isEmpty {
294+                emptyState
295+            } else {
296+                logList
297+            }
298+        }
299+        .navigationTitle("Adventure")
300+        .navigationBarTitleDisplayMode(.inline)
301+        .toolbar { toolbarContent }
302+        .safeAreaInset(edge: .bottom, spacing: 0) { inputBar }
303+        .task(id: settings.serverURL + settings.token) {
304+            await store.connect()
305+        }
306+    }
307+
308+    @ToolbarContentBuilder
309+    private var toolbarContent: some ToolbarContent {
310+        ToolbarItem(placement: .topBarTrailing) {
311+            Button(action: store.replay) {
312+                Image(systemName: "speaker.wave.2")
313+            }
314+            .accessibilityLabel("Replay last line")
315+            .disabled(!store.hasAudio || store.phase != .idle)
316+
317+            Button {
318+                Task { await store.newGame() }
319+            } label: {
320+                if store.phase == .processing && store.entries.isEmpty {
321+                    ProgressView()
322+                } else {
323+                    Image(systemName: "book")
324+                }
325+            }
326+            .accessibilityLabel("New game")
327+            .disabled(store.phase != .idle)
328+
329+            NavigationLink {
330+                SettingsView(settings: settings)
331+            } label: {
332+                Image(systemName: "gear")
333+            }
334+            .accessibilityLabel("Settings")
335+        }
336+    }
337+
338+    private var emptyState: some View {
339+        ContentUnavailableView {
340+            Label("New Adventure", systemImage: "book")
341+        } description: {
342+            Text("Start a new game to begin.")
343+        } actions: {
344+            Button {
345+                Task { await store.newGame() }
346+            } label: {
347+                if store.phase == .processing {
348+                    ProgressView()
349+                } else {
350+                    Text("Start")
351+                }
352+            }
353+            .buttonStyle(.borderedProminent)
354+        }
355+    }
356+
357+    private var logList: some View {
358+        List {
359+            ForEach(Array(store.entries.enumerated()), id: \.offset) { _, entry in
360+                EntryView(entry: entry)
361+            }
362+        }
363+        .defaultScrollAnchor(.bottom)
364+        .overlay {
365+            if store.phase == .processing && store.entries.isEmpty {
366+                ProgressView()
367+            }
368+        }
369+    }
370+
371+    private var inputBar: some View {
372+        VStack(spacing: 8) {
373+            if let status = store.status {
374+                Text(status)
375+                    .font(.footnote)
376+                    .foregroundStyle(.secondary)
377diff --git a/Sources/japanese/Player.swift b/Sources/japanese/Player.swift
378new file mode 100644
379index 0000000000000000000000000000000000000000..9feca9d86b19e4ab71ae0521d2a995236e70c94e
380--- /dev/null
381+++ b/Sources/japanese/Player.swift
382@@ -0,0 +1,22 @@
383+import AVFoundation
384+
385+final class Player {
386+    private var player: AVAudioPlayer?
387+
388+    func play(_ data: Data) throws {
389+        stop()
390+        #if os(iOS)
391+        let session = AVAudioSession.sharedInstance()
392+        try session.setCategory(.playback, mode: .spokenAudio)
393+        try session.setActive(true)
394+        #endif
395+        let player = try AVAudioPlayer(data: data)
396+        player.play()
397+        self.player = player
398+    }
399+
400+    func stop() {
401+        player?.stop()
402+        player = nil
403+    }
404+}
405diff --git a/Sources/japanese/Recorder.swift b/Sources/japanese/Recorder.swift
406new file mode 100644
407index 0000000000000000000000000000000000000000..d96b641601c61d8bb7e6bbf92aa82dd612b6da43
408--- /dev/null
409+++ b/Sources/japanese/Recorder.swift
410@@ -0,0 +1,63 @@
411+import AVFoundation
412+
413+// Records 16 kHz mono PCM WAV, matching what the TUI's arecord capture produces.
414+final class Recorder {
415+    private var recorder: AVAudioRecorder?
416+    private var fileURL: URL?
417+
418+    func start() throws {
419+        guard recorder == nil else {
420+            return
421+        }
422+
423+        try configureSession()
424+
425+        let url = FileManager.default.temporaryDirectory
426+            .appendingPathComponent(UUID().uuidString)
427+            .appendingPathExtension("wav")
428+        let settings: [String: Any] = [
429+            AVFormatIDKey: kAudioFormatLinearPCM,
430+            AVSampleRateKey: 16_000.0,
431+            AVNumberOfChannelsKey: 1,
432+            AVLinearPCMBitDepthKey: 16,
433+        ]
434+        let recorder = try AVAudioRecorder(url: url, settings: settings)
435+        recorder.record()
436+        self.recorder = recorder
437+        self.fileURL = url
438+    }
439+
440+    func stop() throws -> Data {
441+        defer {
442+            recorder?.stop()
443+            recorder = nil
444+            if let fileURL {
445+                try? FileManager.default.removeItem(at: fileURL)
446+                self.fileURL = nil
447+            }
448+        }
449+        guard let fileURL else {
450+            throw RecorderError.notRecording
451+        }
452+        return try Data(contentsOf: fileURL)
453+    }
454+
455+    private func configureSession() throws {
456+        #if os(iOS)
457+        let session = AVAudioSession.sharedInstance()
458+        try session.setCategory(.playAndRecord, mode: .measurement, options: [.defaultToSpeaker])
459+        try session.setActive(true)
460+        #endif
461+    }
462+}
463+
464+enum RecorderError: LocalizedError {
465+    case notRecording
466+
467+    var errorDescription: String? {
468+        switch self {
469+        case .notRecording:
470+            "The microphone is not recording."
471+        }
472+    }
473+}
474diff --git a/Sources/japanese/SettingsView.swift b/Sources/japanese/SettingsView.swift
475new file mode 100644
476index 0000000000000000000000000000000000000000..8087ed2b886ccf5a0f2253e9bdf4e4f181b36ea9
477--- /dev/null
478+++ b/Sources/japanese/SettingsView.swift
479@@ -0,0 +1,81 @@
480+import SwiftUI
481+
482+struct SettingsView: View {
483+    @Bindable var settings: SettingsStore
484+
485+    @State private var serverURL: String
486+    @State private var token: String
487+    @State private var errorMessage: String?
488+
489+    init(settings: SettingsStore) {
490+        self.settings = settings
491+        _serverURL = State(initialValue: settings.serverURL)
492+        _token = State(initialValue: settings.token)
493+    }
494+
495+    var body: some View {
496+        Form {
497+            Section("Server") {
498+                serverURLField
499+
500+                tokenField
501+            }
502+
503+            Section {
504+                Button("Save settings", action: save)
505+                    .buttonStyle(.borderedProminent)
506+            } footer: {
507+                Text("The app connects only to HTTPS servers. The token is stored in the device keychain.")
508+            }
509+        }
510+        .navigationTitle("Settings")
511+        .navigationBarTitleDisplayMode(.inline)
512+        .alert(
513+            "Could not save settings",
514+            isPresented: Binding(
515+                get: { errorMessage != nil },
516+                set: { if !$0 { errorMessage = nil } }
517+            )
518+        ) {
519+            Button("OK", role: .cancel) {}
520+        } message: {
521+            Text(errorMessage ?? "")
522+        }
523+    }
524+
525+    private var serverURLField: some View {
526+        let field = TextField("https://japanese.example.com", text: $serverURL)
527+
528+#if os(iOS)
529+        return field
530+            .textInputAutocapitalization(.never)
531+            .autocorrectionDisabled()
532+            .keyboardType(.URL)
533+            .textContentType(.URL)
534+#else
535+        return field
536+#endif
537+    }
538+
539+    private var tokenField: some View {
540+        let field = SecureField("API token", text: $token)
541+
542+#if os(iOS)
543+        return field
544+            .textInputAutocapitalization(.never)
545+            .autocorrectionDisabled()
546+#else
547+        return field
548+#endif
549+    }
550+
551+    private func save() {
552+        do {
553+            try settings.save(serverURL: serverURL, token: token)
554+            serverURL = settings.serverURL
555+            token = settings.token
556+        } catch {
557+            errorMessage = error.localizedDescription
558+        }
559+    }
560+}
561diff --git a/xtool.yml b/xtool.yml
562index 0363a0b7f6a04dcaff066b3ff53844ec743cdef9..7ac1a2a4f3e4db2b2115626bb66c10025de2ac42 100644
563--- a/xtool.yml
564+++ b/xtool.yml
565@@ -1,2 +1,3 @@
566 version: 1
567 bundleID: com.example.japanese
568+infoPath: Resources/Info.plist