2026-01-08 19:16:32 +00:00
|
|
|
//
|
|
|
|
|
// OrchestratorView.swift
|
|
|
|
|
// iris
|
|
|
|
|
//
|
|
|
|
|
// Created by Codex.
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
import CoreLocation
|
|
|
|
|
import Foundation
|
|
|
|
|
import MusicKit
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
@available(iOS 16.0, *)
|
|
|
|
|
struct OrchestratorView: View {
|
|
|
|
|
@EnvironmentObject private var orchestrator: ContextOrchestrator
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
NavigationStack {
|
|
|
|
|
List {
|
|
|
|
|
Section("Location") {
|
|
|
|
|
LabeledContent("Auth") { Text(authText(orchestrator.authorization)) }
|
|
|
|
|
if let loc = orchestrator.lastLocation {
|
|
|
|
|
LabeledContent("Lat/Lon") {
|
|
|
|
|
Text("\(format(loc.coordinate.latitude, 5)), \(format(loc.coordinate.longitude, 5))")
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
}
|
|
|
|
|
LabeledContent("Accuracy") { Text("\(Int(loc.horizontalAccuracy)) m") }
|
|
|
|
|
LabeledContent("Speed") { Text(speedText(loc.speed)) }
|
|
|
|
|
} else {
|
|
|
|
|
Text("No location yet")
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Section("Recompute") {
|
|
|
|
|
LabeledContent("Last reason") { Text(orchestrator.lastRecomputeReason ?? "—") }
|
|
|
|
|
LabeledContent("Last time") { Text(orchestrator.lastRecomputeAt.map(timeOnly) ?? "—") }
|
|
|
|
|
LabeledContent("Elapsed") { Text(orchestrator.lastPipelineElapsedMs.map { "\($0) ms" } ?? "—") }
|
|
|
|
|
LabeledContent("Fetch failed") { Text(orchestrator.lastFetchFailed ? "Yes" : "No") }
|
|
|
|
|
if let err = orchestrator.lastError {
|
|
|
|
|
Text(err)
|
|
|
|
|
.font(.footnote)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
Button("Recompute Now") { orchestrator.recomputeNow() }
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 19:35:55 +00:00
|
|
|
Section("Winner") {
|
2026-01-10 00:25:36 +00:00
|
|
|
if let feed = orchestrator.lastFeed, let winner = feed.winnerItem() {
|
|
|
|
|
Text(winner.title)
|
2026-01-08 19:16:32 +00:00
|
|
|
.font(.headline)
|
2026-01-10 00:25:36 +00:00
|
|
|
if !winner.subtitle.isEmpty {
|
|
|
|
|
Text(winner.subtitle)
|
|
|
|
|
.font(.subheadline)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
2026-01-10 19:35:55 +00:00
|
|
|
LabeledContent("Type") { Text(winner.type.rawValue) }
|
|
|
|
|
LabeledContent("Bucket") { Text(winner.bucket.rawValue) }
|
|
|
|
|
LabeledContent("Priority") { Text(String(format: "%.2f", winner.priority)) }
|
|
|
|
|
LabeledContent("TTL") { Text("\(winner.ttlSec)s") }
|
|
|
|
|
if let poiType = winner.poiType {
|
|
|
|
|
LabeledContent("POI type") { Text(poiType.rawValue) }
|
|
|
|
|
}
|
|
|
|
|
if let startsAt = winner.startsAt {
|
|
|
|
|
LabeledContent("Starts at") { Text("\(startsAt)") }
|
2026-01-10 00:25:36 +00:00
|
|
|
}
|
2026-01-10 19:35:55 +00:00
|
|
|
LabeledContent("ID") {
|
|
|
|
|
Text(winner.id)
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
Text("No winner yet")
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-10 00:25:36 +00:00
|
|
|
|
2026-01-10 19:35:55 +00:00
|
|
|
Section("Feed") {
|
|
|
|
|
if let feed = orchestrator.lastFeed {
|
|
|
|
|
if feed.feed.isEmpty {
|
|
|
|
|
Text("No feed items yet")
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
} else {
|
|
|
|
|
ForEach(feed.feed, id: \.id) { item in
|
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
|
|
|
HStack {
|
|
|
|
|
Text(item.title)
|
|
|
|
|
.font(.headline)
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
Spacer()
|
|
|
|
|
Text(item.type.rawValue)
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
if !item.subtitle.isEmpty {
|
|
|
|
|
Text(item.subtitle)
|
|
|
|
|
.font(.subheadline)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
}
|
|
|
|
|
Text("bucket \(item.bucket.rawValue) • prio \(String(format: "%.2f", item.priority)) • ttl \(item.ttlSec)s")
|
2026-01-10 00:25:36 +00:00
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
2026-01-10 19:35:55 +00:00
|
|
|
.padding(.vertical, 4)
|
2026-01-10 00:25:36 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-08 19:16:32 +00:00
|
|
|
} else {
|
2026-01-10 00:25:36 +00:00
|
|
|
Text("No feed yet")
|
2026-01-08 19:16:32 +00:00
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Section("Now Playing") {
|
|
|
|
|
LabeledContent("Music auth") { Text(musicAuthText(orchestrator.musicAuthorization)) }
|
|
|
|
|
if let snapshot = orchestrator.nowPlaying {
|
|
|
|
|
Text(snapshot.title)
|
|
|
|
|
.font(.headline)
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
Text(nowPlayingSubtitle(snapshot))
|
|
|
|
|
.font(.subheadline)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
Text(String(describing: snapshot.playbackStatus))
|
|
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
} else {
|
|
|
|
|
Text(orchestrator.musicAuthorization == .authorized ? "Nothing playing" : "Not authorized")
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 00:25:36 +00:00
|
|
|
if !orchestrator.lastWeatherDiagnostics.isEmpty {
|
|
|
|
|
Section("Weather Diagnostics") {
|
|
|
|
|
ForEach(orchestrator.lastWeatherDiagnostics.keys.sorted(), id: \.self) { key in
|
|
|
|
|
LabeledContent(key) {
|
|
|
|
|
Text(orchestrator.lastWeatherDiagnostics[key] ?? "")
|
2026-01-08 19:16:32 +00:00
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(.secondary)
|
2026-01-10 00:25:36 +00:00
|
|
|
.textSelection(.enabled)
|
2026-01-08 19:16:32 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 00:25:36 +00:00
|
|
|
if !orchestrator.lastCalendarDiagnostics.isEmpty {
|
|
|
|
|
Section("Calendar Diagnostics") {
|
|
|
|
|
ForEach(orchestrator.lastCalendarDiagnostics.keys.sorted(), id: \.self) { key in
|
2026-01-08 19:16:32 +00:00
|
|
|
LabeledContent(key) {
|
2026-01-10 00:25:36 +00:00
|
|
|
Text(orchestrator.lastCalendarDiagnostics[key] ?? "")
|
2026-01-08 19:16:32 +00:00
|
|
|
.font(.caption)
|
|
|
|
|
.foregroundStyle(.secondary)
|
|
|
|
|
.textSelection(.enabled)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Section("Test") {
|
|
|
|
|
Button("Send Fixture Feed Now") { orchestrator.sendFixtureFeedNow() }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.navigationTitle("Orchestrator")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func authText(_ s: CLAuthorizationStatus) -> String {
|
|
|
|
|
switch s {
|
|
|
|
|
case .notDetermined: return "Not Determined"
|
|
|
|
|
case .restricted: return "Restricted"
|
|
|
|
|
case .denied: return "Denied"
|
|
|
|
|
case .authorizedAlways: return "Always"
|
|
|
|
|
case .authorizedWhenInUse: return "When In Use"
|
|
|
|
|
@unknown default: return "Other"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func timeOnly(_ date: Date) -> String {
|
|
|
|
|
let formatter = DateFormatter()
|
|
|
|
|
formatter.dateStyle = .none
|
|
|
|
|
formatter.timeStyle = .medium
|
|
|
|
|
return formatter.string(from: date)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func speedText(_ speed: CLLocationSpeed) -> String {
|
|
|
|
|
guard speed >= 0 else { return "—" }
|
|
|
|
|
return "\(String(format: "%.1f", speed)) m/s"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func musicAuthText(_ status: MusicAuthorization.Status) -> String {
|
|
|
|
|
switch status {
|
|
|
|
|
case .notDetermined: return "Not Determined"
|
|
|
|
|
case .denied: return "Denied"
|
|
|
|
|
case .restricted: return "Restricted"
|
|
|
|
|
case .authorized: return "Authorized"
|
|
|
|
|
@unknown default: return "Other"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func nowPlayingSubtitle(_ snapshot: NowPlayingSnapshot) -> String {
|
|
|
|
|
let parts = [snapshot.artist, snapshot.album]
|
|
|
|
|
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
|
|
|
.filter { !$0.isEmpty }
|
|
|
|
|
return parts.isEmpty ? "Apple Music" : parts.joined(separator: " • ")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func format(_ value: Double, _ precision: Int) -> String {
|
|
|
|
|
String(format: "%.\(precision)f", value)
|
|
|
|
|
}
|
|
|
|
|
}
|