// // ModelCards.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// Shared download-state footer: Download button that flips into a live /// progress card (bar, MB/s, ETA, pause/cancel) and into Installed state. struct DownloadStateFooter: View { let repoID: String /// Known download size (from catalog); live search cards fetch on demand. var sizeHint: String? @Environment(AppModel.self) private var app private var installed: Bool { app.store.model(for: repoID) != nil } private var task: DownloadTask? { app.downloads.task(for: repoID) } var body: some View { if installed { HStack(spacing: ZyquoTheme.Spacing.xs) { Label("Installed", systemImage: "checkmark.circle.fill") .font(ZyquoTheme.caption.weight(.medium)) .foregroundStyle(ZyquoTheme.success) Spacer() Button("Load") { Task { await app.loadModel(repoID: repoID) } } .buttonStyle(.bordered) .controlSize(.small) } } else if let task, task.state == .downloading || task.state == .verifying || task.state == .queued { VStack(spacing: 4) { ProgressView(value: task.fractionCompleted) .tint(ZyquoTheme.accent) .animation(.linear(duration: 0.3), value: task.fractionCompleted) HStack(spacing: ZyquoTheme.Spacing.s) { Text("\(formatBytes(task.receivedBytes)) / \(formatBytes(task.totalBytes))") if let speed = app.downloads.speeds[repoID] { Text(formatSpeed(speed)) } if let eta = app.downloads.eta(for: repoID) { Text(formatETA(eta)) } Spacer() Button { app.downloads.pause(repoID: repoID) } label: { Image(systemName: "pause.fill") } .buttonStyle(.plain) .help("Pause") Button { app.downloads.cancel(repoID: repoID) } label: { Image(systemName: "xmark") } .buttonStyle(.plain) .help("Cancel and remove partial files") } .font(ZyquoTheme.caption.monospacedDigit()) .foregroundStyle(ZyquoTheme.textTertiary) } } else if let task, task.state == .paused { HStack { Text("Paused at \(Int(task.fractionCompleted * 100))%") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) Spacer() Button("Resume") { app.downloads.resume(repoID: repoID) } .buttonStyle(.borderedProminent) .tint(ZyquoTheme.accent) .controlSize(.small) Button("Cancel") { app.downloads.cancel(repoID: repoID) } .buttonStyle(.bordered) .controlSize(.small) } } else if let task, task.state == .failed { HStack { Text(task.errorDescription ?? "Download failed") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.danger) .lineLimit(2) Spacer() Button("Retry") { app.downloads.resume(repoID: repoID) } .buttonStyle(.bordered) .controlSize(.small) } } else { HStack { if let sizeHint { Text(sizeHint) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } Spacer() Button { Task { await app.downloads.download(repoID: repoID) } } label: { Label("Download", systemImage: "arrow.down.circle") .font(ZyquoTheme.bodyEmphasis) } .buttonStyle(.borderedProminent) .tint(ZyquoTheme.accent) .controlSize(.small) } } } } /// One curated catalog entry, editorial style. struct FeaturedModelCard: View { let model: CatalogModel @Environment(AppModel.self) private var app var body: some View { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { HStack(spacing: ZyquoTheme.Spacing.xs) { Text(shortModelName(model.repoID)) .font(ZyquoTheme.bodyEmphasis) .foregroundStyle(ZyquoTheme.textPrimary) InfoBadge(text: model.params) InfoBadge(text: model.quant) Spacer() VerdictBadge(verdict: model.verdict) } Text(model.blurb) .font(ZyquoTheme.body) .foregroundStyle(ZyquoTheme.textSecondary) DownloadStateFooter( repoID: model.repoID, sizeHint: String(format: "%.1f GB download · needs %d GB+ Mac", model.sizeGB, model.minRAMGB) ) } .padding(ZyquoTheme.Spacing.m) .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) } } /// One live Hub search result. struct HubModelCard: View { let summary: HubService.ModelSummary @Environment(AppModel.self) private var app @State private var totalBytes: Int64? var body: some View { VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { HStack(spacing: ZyquoTheme.Spacing.xs) { VStack(alignment: .leading, spacing: 1) { Text(shortModelName(summary.id)) .font(ZyquoTheme.bodyEmphasis) .foregroundStyle(ZyquoTheme.textPrimary) .lineLimit(1) Text(summary.id.split(separator: "/").first.map(String.init) ?? "") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } Spacer() if let downloads = summary.downloads { Label("\(downloads.formatted(.number.notation(.compactName)))", systemImage: "arrow.down.circle") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } if summary.isGated { Label("Gated", systemImage: "lock") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.warning) .help("Requires an approved Hugging Face token (Settings ▸ Models & Storage)") } if !summary.isSupportedArchitecture { Label("Unsupported", systemImage: "exclamationmark.triangle") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.danger) .help("Architecture “\(summary.architecture ?? "?")” is not supported by the MLX engine") } if let totalBytes { VerdictBadge(verdict: MemoryAdvisor.verdict(weightsBytes: totalBytes)) } } DownloadStateFooter(repoID: summary.id, sizeHint: totalBytes.map(formatBytes)) } .padding(ZyquoTheme.Spacing.m) .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) .task(id: summary.id) { // Fetch true download size lazily per card. guard totalBytes == nil else { return } let hub = HubService(token: app.settings.hfToken.isEmpty ? nil : app.settings.hfToken) totalBytes = try? await hub.requiredFiles(of: summary.id).totalBytes } } } /// Bottom drawer listing all active/queued/paused downloads. struct DownloadsDrawer: View { @Environment(AppModel.self) private var app var body: some View { VStack(spacing: 0) { Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: ZyquoTheme.Spacing.s) { ForEach(app.downloads.tasks.filter { $0.state != .completed && $0.state != .cancelled }) { task in HStack(spacing: ZyquoTheme.Spacing.xs) { VStack(alignment: .leading, spacing: 2) { Text(shortModelName(task.repoID)) .font(ZyquoTheme.caption.weight(.medium)) .lineLimit(1) ProgressView(value: task.fractionCompleted) .tint(ZyquoTheme.accent) .frame(width: 140) } switch task.state { case .downloading, .queued, .verifying: Button { app.downloads.pause(repoID: task.repoID) } label: { Image(systemName: "pause.fill").font(.system(size: 10)) } .buttonStyle(.plain) case .paused, .failed: Button { app.downloads.resume(repoID: task.repoID) } label: { Image(systemName: "play.fill").font(.system(size: 10)) } .buttonStyle(.plain) default: EmptyView() } Button { app.downloads.cancel(repoID: task.repoID) } label: { Image(systemName: "xmark").font(.system(size: 10)) } .buttonStyle(.plain) } .padding(ZyquoTheme.Spacing.xs) .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) } } .padding(ZyquoTheme.Spacing.s) } } .background(ZyquoTheme.background) } }