| 1 | import SwiftUI |
| 2 | import AVFoundation |
| 3 | import UniformTypeIdentifiers |
| 4 | |
| 5 | @main struct CodewhalePetMobileApp: App { |
| 6 | @StateObject private var host = PetHost(points: WHALE_POINTS) |
| 7 | @Environment(\.scenePhase) private var phase |
| 8 | @State private var pendingSource: PetSource? |
| 9 | @State private var confirmLeave = false |
| 10 | @State private var exporting = false |
| 11 | @State private var joining = false |
| 12 | @State private var recording: PetRecordingDocument? |
| 13 | @State private var notice = "" |
| 14 | var body: some Scene { |
| 15 | WindowGroup { |
| 16 | VStack(alignment: .leading, spacing: 22) { |
| 17 | Text("CODEWHALE / WHALESONG").font(.caption.monospaced()).foregroundStyle(.secondary) |
| 18 | Text("A living field.").font(.largeTitle.weight(.medium)) |
| 19 | Text("A whale at rest. Knots, strands and branching paths as it works. The dots make the activity visible.").font(.subheadline).foregroundStyle(.secondary) |
| 20 | PetHabitatView(host: host).frame(minHeight: 300, maxHeight: .infinity) |
| 21 | Picker("World", selection: Binding(get: { host.source }, set: { next in |
| 22 | if !host.selectSource(next) { pendingSource = next; confirmLeave = true } |
| 23 | })) { ForEach(PetSource.allCases) { Text($0.label).tag($0) } }.pickerStyle(.segmented) |
| 24 | Toggle("Still", isOn: $host.still) |
| 25 | Menu("Earlier recordings") { |
| 26 | ForEach(host.archives, id: \.self) { name in |
| 27 | Button(petArchiveLabel(name)) { |
| 28 | do { recording = PetRecordingDocument(data: try host.archivedRecording(name)); exporting = true } |
| 29 | catch { notice = error.localizedDescription } |
| 30 | } |
| 31 | } |
| 32 | }.disabled(host.archives.isEmpty) |
| 33 | Button("Join shared pet…") { joining = true } |
| 34 | Button("Save recording…") { |
| 35 | if host.source == .live { Task { do { recording = PetRecordingDocument(data: try await host.shared.export()); exporting = true } catch { notice = error.localizedDescription } }; return } |
| 36 | do { recording = PetRecordingDocument(data: try host.recordingForExport()); exporting = true } |
| 37 | catch { notice = error.localizedDescription } |
| 38 | }.disabled(host.core == nil && host.shared.frame == nil) |
| 39 | if !notice.isEmpty { Text(notice).font(.caption).foregroundStyle(.secondary) } |
| 40 | Text(host.source == .file ? "File study: pet-state in this app’s Documents folder." : "Hollow dots mean missing telemetry. Sleep is a separate dimmer.") |
| 41 | .font(.caption).foregroundStyle(.secondary) |
| 42 | }.padding(24).preferredColorScheme(.dark) |
| 43 | .onAppear { |
| 44 | host.setLiveFile(FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("pet-state")) |
| 45 | do { try AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default); try AVAudioSession.sharedInstance().setActive(true) } |
| 46 | catch { /* Visual world remains available; audio output reports start errors. */ } |
| 47 | } |
| 48 | .onChange(of: phase) { _, value in host.suspend(value != .active) } |
| 49 | .fileImporter(isPresented: $joining, allowedContentTypes: [.json]) { result in |
| 50 | do { |
| 51 | let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access {url.stopAccessingSecurityScopedResource()} } |
| 52 | let file = try FileHandle(forReadingFrom:url); defer {try? file.close()} |
| 53 | let data = try file.read(upToCount:4097) ?? Data(); guard data.count <= 4096 else {throw PetCoreError.invalid("Invalid connection file.")} |
| 54 | let connection = try JSONDecoder().decode(PetConnection.self,from:data);try connection.validate() |
| 55 | guard host.save() else {throw PetCoreError.invalid("Export the current world before leaving it.")} |
| 56 | let target = FileManager.default.urls(for:.documentDirectory,in:.userDomainMask)[0].appendingPathComponent("pet-connection.json") |
| 57 | try data.write(to:target,options:.atomic);host.selectSource(.live);host.shared.stop();host.shared.start() |
| 58 | } catch {notice = error.localizedDescription} |
| 59 | } |
| 60 | .fileExporter(isPresented: $exporting, document: recording, contentType: .json, defaultFilename: "codewhale-pet.json") { result in |
| 61 | switch result { |
| 62 | case .success: notice = "Recording saved. Files over 8 MiB open in the browser." |
| 63 | case .failure(let error): notice = error.localizedDescription |
| 64 | } |
| 65 | recording = nil |
| 66 | } |
| 67 | .alert("This world has not been saved", isPresented: $confirmLeave) { |
| 68 | Button("Keep this world", role: .cancel) { pendingSource = nil } |
| 69 | Button("Leave without saving", role: .destructive) { |
| 70 | if let next = pendingSource { host.selectSource(next, discardingUnsaved: true) } |
| 71 | pendingSource = nil |
| 72 | } |
| 73 | } message: { Text("Keep this world and use Save recording to retain the current visit. Leaving opens the other world and discards this visit’s unsaved progress.") } |
| 74 | } |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | private struct PetRecordingDocument: FileDocument { |
| 79 | static var readableContentTypes: [UTType] { [.json] } |
| 80 | let data: Data |
| 81 | init(data: Data) { self.data = data } |
| 82 | init(configuration: ReadConfiguration) throws { |
| 83 | guard let data = configuration.file.regularFileContents, data.count <= 64 * 1024 * 1024 else { throw PetCoreError.invalid("Invalid pet recording file.") } |
| 84 | self.data = data |
| 85 | } |
| 86 | func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) } |
| 87 | } |
| 88 |