PadXcode-iPad/Build/BuildHistoryStore.swift
2026-04-12 22:07:35 -07:00

221 lines
7.7 KiB
Swift

import Foundation
import SwiftUI
// MARK: - Build Record
struct BuildRecord: Identifiable, Codable {
let id: UUID
let projectName: String
let scheme: String
let date: Date
let duration: TimeInterval
let action: String
let configuration: String
let destination: String
let outcome: Outcome
let errorCount: Int
let warningCount: Int
let logSnippet: String
enum Outcome: String, Codable {
case success, failure, cancelled
var icon: String {
switch self {
case .success: return "checkmark.circle.fill"
case .failure: return "xmark.circle.fill"
case .cancelled: return "stop.circle.fill"
}
}
var color: Color {
switch self {
case .success: return .green
case .failure: return .red
case .cancelled: return .secondary
}
}
}
var formattedDuration: String {
if duration < 60 { return String(format: "%.1fs", duration) }
let m = Int(duration / 60); let s = Int(duration) % 60
return "\(m)m \(s)s"
}
var formattedDate: String {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
return f.localizedString(for: date, relativeTo: Date())
}
}
// MARK: - Store
@MainActor
final class BuildHistoryStore: ObservableObject {
@Published var records: [BuildRecord] = []
private let maxRecords = 50
private let key = "padxcode.buildHistory"
init() { load() }
func append(_ record: BuildRecord) {
records.insert(record, at: 0)
if records.count > maxRecords { records = Array(records.prefix(maxRecords)) }
persist()
}
func clear() { records = []; persist() }
func delete(id: UUID) { records.removeAll { $0.id == id }; persist() }
private func persist() {
if let d = try? JSONEncoder().encode(records) {
UserDefaults.standard.set(d, forKey: key)
}
}
private func load() {
guard let d = UserDefaults.standard.data(forKey: key),
let v = try? JSONDecoder().decode([BuildRecord].self, from: d) else { return }
records = v
}
}
// MARK: - Views (defined exactly once)
struct BuildHistoryView: View {
@ObservedObject var historyStore: BuildHistoryStore
@State private var showClearAlert = false
@State private var selectedRecord: BuildRecord?
var body: some View {
NavigationStack {
Group {
if historyStore.records.isEmpty {
ContentUnavailableView(
"No Build History",
systemImage: "clock.badge.xmark",
description: Text("Successful and failed builds appear here.")
)
} else {
List {
ForEach(historyStore.records) { record in
BuildRecordRow(record: record)
.contentShape(Rectangle())
.onTapGesture { selectedRecord = record }
.swipeActions {
Button(role: .destructive) {
historyStore.delete(id: record.id)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
}
}
.navigationTitle("Build History")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(role: .destructive) { showClearAlert = true } label: {
Image(systemName: "trash")
}
.disabled(historyStore.records.isEmpty)
}
}
.alert("Clear Build History?", isPresented: $showClearAlert) {
Button("Clear All", role: .destructive) { historyStore.clear() }
Button("Cancel", role: .cancel) {}
}
.sheet(item: $selectedRecord) { record in
BuildRecordDetailView(record: record)
}
}
}
}
struct BuildRecordRow: View {
let record: BuildRecord
var body: some View {
HStack(spacing: 12) {
Image(systemName: record.outcome.icon)
.foregroundStyle(record.outcome.color)
.font(.title3)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
HStack {
Text(record.scheme).font(.subheadline.bold())
Text("·").foregroundStyle(.tertiary)
Text(record.configuration).font(.subheadline).foregroundStyle(.secondary)
}
HStack(spacing: 6) {
Text(record.destination).font(.caption).foregroundStyle(.secondary)
Text("·").foregroundStyle(.tertiary)
Text(record.formattedDuration).font(.caption).foregroundStyle(.secondary)
if record.errorCount > 0 {
Text("·").foregroundStyle(.tertiary)
Text("\(record.errorCount) error(s)").font(.caption).foregroundStyle(.red)
}
}
}
Spacer()
Text(record.formattedDate).font(.caption2).foregroundStyle(.tertiary)
}
.padding(.vertical, 4)
}
}
struct BuildRecordDetailView: View {
let record: BuildRecord
@Environment(\.dismiss) var dismiss
var body: some View {
NavigationStack {
List {
Section("Result") {
LabeledContent("Outcome") {
Label(record.outcome.rawValue.capitalized,
systemImage: record.outcome.icon)
.foregroundStyle(record.outcome.color)
}
LabeledContent("Duration", value: record.formattedDuration)
LabeledContent("Date", value: record.date.formatted())
}
Section("Project") {
LabeledContent("Project", value: record.projectName)
LabeledContent("Scheme", value: record.scheme)
LabeledContent("Configuration", value: record.configuration)
LabeledContent("Destination", value: record.destination)
}
if record.errorCount > 0 || record.warningCount > 0 {
Section("Diagnostics") {
LabeledContent("Errors", value: "\(record.errorCount)")
LabeledContent("Warnings", value: "\(record.warningCount)")
}
}
Section("Log Tail") {
ScrollView {
Text(record.logSnippet)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 300)
}
}
.navigationTitle("Build Detail")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.presentationDetents([.large])
}
}