initial commit
This commit is contained in:
124
IrisCompanion/iris/Views/BleStatusView.swift
Normal file
124
IrisCompanion/iris/Views/BleStatusView.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
//
|
||||
// BleStatusView.swift
|
||||
// iris
|
||||
//
|
||||
// Created by Codex.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct BleStatusView: View {
|
||||
@EnvironmentObject private var ble: BlePeripheralManager
|
||||
@EnvironmentObject private var orchestrator: ContextOrchestrator
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("GlassNow BLE")
|
||||
.font(.title2.bold())
|
||||
Text("Bluetooth: \(bluetoothStateText)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Toggle(isOn: $ble.advertisingEnabled) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Advertising")
|
||||
.font(.headline)
|
||||
Text(ble.isAdvertising ? "On" : "Off")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onChange(of: ble.advertisingEnabled) { _ in
|
||||
ble.start()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Connection")
|
||||
.font(.headline)
|
||||
Text("Subscribed: \(ble.isSubscribed ? "Yes" : "No")")
|
||||
.font(.subheadline)
|
||||
Text("Subscribers: \(ble.subscribedCount)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Telemetry")
|
||||
.font(.headline)
|
||||
Text("Last msgId: \(ble.lastMsgIdSent)")
|
||||
.font(.subheadline)
|
||||
Text("Last ping: \(ble.lastPingAt.map { timeOnly(from: $0) } ?? "Never")")
|
||||
.font(.subheadline)
|
||||
Text("Last data: \(ble.lastDataAt.map { timeOnly(from: $0) } ?? "Never")")
|
||||
.font(.subheadline)
|
||||
Text("Notify queue: \(ble.notifyQueueDepth)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if ble.droppedNotifyPackets > 0 {
|
||||
Text("Dropped notify packets: \(ble.droppedNotifyPackets)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Last notify: \(ble.lastNotifyAt.map { timeOnly(from: $0) } ?? "Never")")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if let cmd = ble.lastCommand, !cmd.isEmpty {
|
||||
Text("Last control: \(cmd)")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Button("Send Fixture Feed Now") {
|
||||
orchestrator.sendFixtureFeedNow()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button("Copy UUIDs") {
|
||||
ble.copyUUIDsToPasteboard()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("UUIDs")
|
||||
.font(.headline)
|
||||
Text("Service: \(BlePeripheralManager.serviceUUID.uuidString)\nFEED_TX: \(BlePeripheralManager.feedTxUUID.uuidString)\nCONTROL_RX: \(BlePeripheralManager.controlRxUUID.uuidString)")
|
||||
.font(.caption)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.padding(.horizontal, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear { ble.start() }
|
||||
}
|
||||
|
||||
private var bluetoothStateText: String {
|
||||
switch ble.bluetoothState {
|
||||
case .unknown: return "Unknown"
|
||||
case .resetting: return "Resetting"
|
||||
case .unsupported: return "Unsupported"
|
||||
case .unauthorized: return "Unauthorized"
|
||||
case .poweredOff: return "Powered Off"
|
||||
case .poweredOn: return "Powered On"
|
||||
@unknown default: return "Other"
|
||||
}
|
||||
}
|
||||
|
||||
private func timeOnly(from date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
struct BleStatusView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
Text("Preview unavailable (requires EnvironmentObjects).")
|
||||
}
|
||||
}
|
||||
120
IrisCompanion/iris/Views/CandidatesView.swift
Normal file
120
IrisCompanion/iris/Views/CandidatesView.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// CandidatesView.swift
|
||||
// iris
|
||||
//
|
||||
// Created by Codex.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CandidatesView: View {
|
||||
@StateObject private var model = CandidatesViewModel()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("Source") {
|
||||
LabeledContent("Location") {
|
||||
Text("London (demo)")
|
||||
}
|
||||
if let updated = model.lastUpdatedAt {
|
||||
LabeledContent("Last update") { Text(timeOnly(from: updated)) }
|
||||
} else {
|
||||
LabeledContent("Last update") { Text("Never") }
|
||||
}
|
||||
if let error = model.lastError {
|
||||
Text(error)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !model.diagnostics.isEmpty {
|
||||
Section("Diagnostics") {
|
||||
ForEach(model.diagnostics.keys.sorted(), id: \.self) { key in
|
||||
LabeledContent(key) {
|
||||
Text(model.diagnostics[key] ?? "")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Candidates (\(model.candidates.count))") {
|
||||
if model.candidates.isEmpty {
|
||||
Text(model.isLoading ? "Loading…" : "No candidates")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(model.candidates, id: \.id) { candidate in
|
||||
CandidateRow(candidate: candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Candidates")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button(model.isLoading ? "Refreshing…" : "Refresh") {
|
||||
model.refresh()
|
||||
}
|
||||
.disabled(model.isLoading)
|
||||
}
|
||||
}
|
||||
.onAppear { model.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
private func timeOnly(from date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .none
|
||||
formatter.timeStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CandidateRow: View {
|
||||
let candidate: Candidate
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(candidate.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(candidate.type.rawValue)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(candidate.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text(String(format: "conf %.2f", candidate.confidence))
|
||||
Text("ttl \(candidate.ttlSec)s")
|
||||
Text(expiresText(now: Int(Date().timeIntervalSince1970)))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func expiresText(now: Int) -> String {
|
||||
let expiresAt = candidate.createdAt + candidate.ttlSec
|
||||
let remaining = expiresAt - now
|
||||
if remaining <= 0 { return "expired" }
|
||||
if remaining < 60 { return "in \(remaining)s" }
|
||||
return "in \(remaining / 60)m"
|
||||
}
|
||||
}
|
||||
|
||||
struct CandidatesView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CandidatesView()
|
||||
}
|
||||
}
|
||||
176
IrisCompanion/iris/Views/OrchestratorView.swift
Normal file
176
IrisCompanion/iris/Views/OrchestratorView.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// 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() }
|
||||
}
|
||||
|
||||
Section("Winner") {
|
||||
if let env = orchestrator.lastWinner {
|
||||
Text(env.winner.title)
|
||||
.font(.headline)
|
||||
Text(env.winner.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("type \(env.winner.type.rawValue) • prio \(String(format: "%.2f", env.winner.priority)) • ttl \(env.winner.ttlSec)s")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("No winner yet")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Candidates (\(orchestrator.lastCandidates.count))") {
|
||||
if orchestrator.lastCandidates.isEmpty {
|
||||
Text("No candidates")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(orchestrator.lastCandidates, id: \.id) { c in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(c.title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(c.type.rawValue)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(c.subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Text("conf \(String(format: "%.2f", c.confidence)) • ttl \(c.ttlSec)s")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !orchestrator.lastWeatherDiagnostics.isEmpty {
|
||||
Section("Weather Diagnostics") {
|
||||
ForEach(orchestrator.lastWeatherDiagnostics.keys.sorted(), id: \.self) { key in
|
||||
LabeledContent(key) {
|
||||
Text(orchestrator.lastWeatherDiagnostics[key] ?? "")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user