fc696bed432a5ef279bdaae0f5e3ec47c6a7af5e
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
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