import SwiftUI struct SourceDetailView: View { private enum StageStatus { case pending case inProgress case completed } private struct StageRow: Identifiable { let id: String let title: String let detail: String let status: StageStatus let progress: Double? let showsIndeterminateProgress: Bool } let source: MinecraftSource var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 8) { Text(source.displayName) .font(.largeTitle.weight(.semibold)) Text(sourceSummary) .font(.subheadline) .foregroundStyle(.secondary) } if showsStatusSection { sourceStatusSection } sourceSection(title: "Overview", rows: overviewRows) sourceSection(title: "Contents", rows: contentRows) sourceSection(title: "Location", rows: locationRows) if !technicalRows.isEmpty { sourceSection(title: "Technical Details", rows: technicalRows) } } .frame(maxWidth: 760, alignment: .leading) .padding(28) } } private var sourceSummary: String { switch source.origin { case .localFolder: return "Local filesystem source" case .connectedDevice(let device, let container): return "\(device.name) • \(container.appName)" } } private var showsStatusSection: Bool { if source.isScanning || source.scanError != nil || source.availability != .available { return true } guard !source.scanStatus.isEmpty else { return false } if source.scanStatus == "No Minecraft items found." { return false } if source.scanStatus.hasPrefix("Loaded ") { return false } return true } private var statusTitle: String { if !source.isScanning, source.availability != .available { return SourcePresentation.availabilityDisplayText(for: source) } if let scanError = source.scanError, !scanError.isEmpty { return "Scan Failed" } if source.isScanning { return SourcePresentation.liveScanStatusTitle(for: source) } if !source.scanStatus.isEmpty { return source.scanStatus } return "Scanning Minecraft library..." } private var statusDetail: String? { if !source.isScanning, source.availability != .available { if let scanDiagnostic = source.scanDiagnostic, !scanDiagnostic.isEmpty { return scanDiagnostic } if let cachedAvailabilityDetailText = SourcePresentation.cachedAvailabilityDetailText(for: source) { return cachedAvailabilityDetailText } if let lastScanDate = source.lastScanDate { return "Cached from \(lastScanDate.formatted(date: .abbreviated, time: .shortened))" } return nil } if let scanError = source.scanError, !scanError.isEmpty { return scanError } if let diagnostic = source.scanDiagnostic, !diagnostic.isEmpty { return diagnostic } if source.isScanning { return nil } return "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed" } @ViewBuilder private var sourceStatusSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Status") .font(.headline) VStack(alignment: .leading, spacing: 12) { HStack(alignment: .center, spacing: 10) { if SourcePresentation.showsIndeterminateScanActivityIndicator(for: source) { ProgressView() .controlSize(.small) } else if !source.isScanning { sourceStatusIcon } Text(statusTitle) .font(.subheadline.weight(.semibold)) } if let statusDetail, !statusDetail.isEmpty { Text(statusDetail) .font(.subheadline) .foregroundStyle(.secondary) } if source.isScanning { Divider() .padding(.vertical, 2) VStack(alignment: .leading, spacing: 10) { ForEach(stageRows) { stage in sourceStageRow(stage) } } } } .padding(18) .appCardSurface(.primaryPanel) } } @ViewBuilder private var sourceStatusIcon: some View { if source.availability == .limited { Image(systemName: "lock.circle.fill") .foregroundStyle(Color.appAccent) } else if source.availability != .available { Image(systemName: source.isOfflineCached ? "externaldrive.badge.exclamationmark" : "slash.circle") .foregroundStyle(.secondary) } else if source.scanError != nil { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } else { Image(systemName: "info.circle") .foregroundStyle(.secondary) } } private var overviewRows: [(String, String)] { var rows: [(String, String)] = [ ("Type", sourceTypeLabel), ("Availability", availabilityLabel) ] if let lastScanDate = source.lastScanDate { rows.append(("Last Successful Scan", lastScanDate.formatted(date: .abbreviated, time: .shortened))) } switch source.origin { case .localFolder: break case .connectedDevice(let device, let container): rows.append(("Connection", device.connection == .network ? "Network" : "USB")) rows.append(("App Container", container.appName)) if let osVersion = device.osVersion, !osVersion.isEmpty { rows.append(("OS Version", osVersion)) } } return rows } private var contentRows: [(String, String)] { [ ("Total Items", source.items.count.formatted(.number)), ("Worlds", itemCount(for: .world).formatted(.number)), ("Behavior Packs", itemCount(for: .behaviorPack).formatted(.number)), ("Resource Packs", itemCount(for: .resourcePack).formatted(.number)), ("Skin Packs", itemCount(for: .skinPack).formatted(.number)), ("World Templates", itemCount(for: .worldTemplate).formatted(.number)) ] } private var locationRows: [(String, String)] { switch source.origin { case .localFolder: return [("Filesystem Path", source.folderURL.path)] case .connectedDevice(_, let container): var rows: [(String, String)] = [ ("Source Identifier", source.folderURL.absoluteString) ] if let relativePath = container.minecraftFolderRelativePath, !relativePath.isEmpty { rows.append(("Minecraft Path", relativePath)) } return rows } } private var technicalRows: [(String, String)] { switch source.origin { case .localFolder: return [] case .connectedDevice(let device, let container): var rows: [(String, String)] = [ ("UDID", device.udid), ("App ID", container.appID), ("Access Mode", container.accessMode.rawValue) ] if let productType = device.productType, !productType.isEmpty { rows.append(("Product Type", productType)) } rows.append(("Trust State", device.trustState.rawValue.capitalized)) return rows } } private var sourceTypeLabel: String { switch source.origin { case .localFolder: return "Local Folder" case .connectedDevice: return "Connected Device" } } private var availabilityLabel: String { switch source.availability { case .unknown: return "Unknown" case .available: return "Available" case .disconnected: return "Disconnected" case .limited: return "Limited" case .unavailable: return "Unavailable" } } private var stageRows: [StageRow] { [previewStageRow, sizeStageRow] } private var previewStageRow: StageRow { let total = source.indexedItemCount let progress = total > 0 ? min(Double(source.previewLoadedCount) / Double(total), 1) : 0 let hasPreviewWorkStarted = source.previewLoadedCount > 0 || source.scanStatus.contains("Loading previews") let previewsAreFullyLoaded = total > 0 && source.previewLoadedCount >= total let shouldShowIndeterminatePreviewProgress = hasPreviewWorkStarted && source.isScanning && (source.scanProgress ?? 0) < 0.65 let status: StageStatus let scanPhase = SourcePresentation.scanPhase(for: source) if previewsAreFullyLoaded || scanPhase == .sizing || scanPhase == .completed { status = .completed } else if hasPreviewWorkStarted { status = .inProgress } else { status = .pending } let detail: String switch status { case .completed: detail = finishedStageDetail(duration: source.previewStageDuration ?? source.previewStageElapsed) case .inProgress: detail = stageProgressDetail( completed: source.previewLoadedCount, total: total, unit: "previews loaded", elapsed: source.previewStageElapsed ) case .pending: detail = "Waiting for discovery to finish" } return StageRow( id: "previews", title: "Previews", detail: detail, status: status, progress: status == .completed ? 1 : progress, showsIndeterminateProgress: shouldShowIndeterminatePreviewProgress ) } private var sizeStageRow: StageRow { let total = source.indexedItemCount let progress = total > 0 ? min(Double(source.sizeLoadedCount) / Double(total), 1) : 0 let previewsAreFullyLoaded = total > 0 && source.previewLoadedCount >= total let status: StageStatus switch SourcePresentation.scanPhase(for: source) { case .sizing: status = .inProgress case .completed: status = .completed case .discovering, .metadata, .previews, .idle: status = .pending } let detail: String switch status { case .completed: detail = finishedStageDetail(duration: source.sizeStageDuration) case .inProgress: detail = stageProgressDetail( completed: source.sizeLoadedCount, total: total, unit: "sizes calculated", elapsed: source.sizeStageElapsed ) case .pending: detail = previewsAreFullyLoaded ? "Preparing size calculations..." : "Waiting for previews to finish loading" } return StageRow( id: "sizes", title: "Sizes", detail: detail, status: status, progress: status == .completed ? 1 : progress, showsIndeterminateProgress: false ) } @ViewBuilder private func sourceStageRow(_ stage: StageRow) -> some View { VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 10) { Image(systemName: stageIconName(for: stage.status)) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(stageIconColor(for: stage.status)) .frame(width: 14) Text(stage.title) .font(.subheadline.weight(.semibold)) Spacer() Text(stage.detail) .font(.caption) .foregroundStyle(.secondary) } if stage.status == .completed { EmptyView() } else if stage.showsIndeterminateProgress { ProgressView() .progressViewStyle(.linear) .controlSize(.small) .tint(Color.appAccent) } else if let progress = stage.progress { ProgressView(value: progress, total: 1) .tint(stage.status == .pending ? .secondary.opacity(0.35) : Color.appAccent) .opacity(stage.status == .pending ? 0.55 : 1) } } } private func stageIconName(for status: StageStatus) -> String { switch status { case .pending: return "circle" case .inProgress: return "clock" case .completed: return "checkmark.circle.fill" } } private func stageIconColor(for status: StageStatus) -> Color { switch status { case .pending: return .secondary.opacity(0.7) case .inProgress: return Color.appAccent case .completed: return .green } } private func stageProgressDetail( completed: Int, total: Int, unit: String, elapsed: TimeInterval? ) -> String { guard total > 0 else { return "Starting..." } var detail = "\(completed) of \(total) \(unit)" if let remainingEstimate = estimateRemainingTime( completed: completed, total: total, elapsed: elapsed ) { detail += " • about \(friendlyDuration(remainingEstimate)) left" } return detail } private func finishedStageDetail(duration: TimeInterval?) -> String { guard let duration else { return "Finished" } return "Finished in \(friendlyDuration(duration))" } private func estimateRemainingTime( completed: Int, total: Int, elapsed: TimeInterval? ) -> TimeInterval? { guard let elapsed, elapsed >= 10, completed >= 10, total > 0, completed < total else { return nil } let completionFraction = Double(completed) / Double(total) guard completionFraction >= 0.05 else { return nil } let secondsPerItem = elapsed / Double(completed) let remaining = max(Double(total - completed) * secondsPerItem, 1) guard remaining >= 60 else { return nil } return remaining } private func friendlyDuration(_ duration: TimeInterval) -> String { let formatter = DateComponentsFormatter() formatter.unitsStyle = .full formatter.maximumUnitCount = duration >= 3600 ? 2 : 1 formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : duration >= 60 ? [.minute] : [.second] formatter.includesApproximationPhrase = false formatter.includesTimeRemainingPhrase = false return formatter.string(from: duration) ?? "a moment" } private func itemCount(for type: MinecraftContentType) -> Int { source.items.filter { $0.contentType == type }.count } @ViewBuilder private func sourceSection(title: String, rows: [(String, String)]) -> some View { VStack(alignment: .leading, spacing: 12) { Text(title) .font(.headline) VStack(alignment: .leading, spacing: 12) { ForEach(Array(rows.enumerated()), id: \.offset) { _, row in HStack(alignment: .top, spacing: 16) { Text(row.0) .font(.subheadline.weight(.semibold)) .foregroundStyle(.secondary) .frame(width: 170, alignment: .leading) Text(row.1) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } } } .padding(18) .appCardSurface(.primaryPanel) } } }