import SwiftUI // MARK: - DownloadsView // Shows active downloads (in-progress), downloaded chapters grouped by book, and storage usage. // Purely local — no network calls needed. struct DownloadsView: View { @ObservedObject private var downloadService = AudioDownloadService.shared @Environment(\.dismiss) private var dismiss // Completed chapters grouped by slug, sorted alphabetically private var groupedDownloads: [(slug: String, keys: [String])] { let slugs = downloadService.offlineBookSlugs() return slugs.map { slug in let keys = downloadService.downloadedChapters .filter { $0.hasPrefix("\(slug)::") } .sorted { lhs, rhs in let lhsChapter = chapterNumber(from: lhs) let rhsChapter = chapterNumber(from: rhs) return lhsChapter < rhsChapter } return (slug: slug, keys: keys) } } private var activeDownloads: [(key: String, progress: DownloadProgress)] { downloadService.downloads .sorted { $0.key < $1.key } .map { (key: $0.key, progress: $0.value) } } private var storageFormatted: String { ByteCountFormatter.string( fromByteCount: downloadService.totalStorageUsed(), countStyle: .file ) } private var hasAnyContent: Bool { !downloadService.downloadedChapters.isEmpty || !downloadService.downloads.isEmpty } var body: some View { NavigationStack { Group { if hasAnyContent { contentList } else { emptyState } } .background(Color(uiColor: UIColor(red: 0.094, green: 0.094, blue: 0.106, alpha: 1))) .navigationTitle("Downloads") .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { UIImpactFeedbackGenerator(style: .light).impactOccurred() dismiss() } .foregroundStyle(Color.amber) } } } } // MARK: - Content list private var contentList: some View { List { // Storage info storageSection // Active downloads if !activeDownloads.isEmpty { Section { ForEach(activeDownloads, id: \.key) { item in ActiveDownloadRow(key: item.key, progress: item.progress) } } header: { Text("Downloading") .font(.subheadline.bold()) .foregroundStyle(Color.amber) .textCase(nil) } } // Completed, grouped by book ForEach(groupedDownloads, id: \.slug) { group in Section { ForEach(group.keys, id: \.self) { key in DownloadedChapterRow(key: key) } } header: { HStack(spacing: 6) { Image(systemName: "book.closed.fill") .font(.caption) .foregroundStyle(.secondary) Text(group.slug) .font(.subheadline.bold()) .foregroundStyle(.primary) .textCase(nil) Spacer() Text("\(group.keys.count) ch.") .font(.caption) .foregroundStyle(.secondary) .textCase(nil) } } } // Delete all if !downloadService.downloadedChapters.isEmpty { Section { Button(role: .destructive) { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() try? downloadService.deleteAllDownloads() } label: { HStack { Spacer() Label("Delete All Downloads", systemImage: "trash.fill") .font(.subheadline.bold()) Spacer() } } .accessibilityLabel("Delete all downloaded audio chapters") } } } .scrollContentBackground(.hidden) .listStyle(.insetGrouped) } // MARK: - Storage section private var storageSection: some View { Section { HStack(spacing: 12) { Image(systemName: "internaldrive.fill") .font(.body) .foregroundStyle(Color.amber) .frame(width: 28) VStack(alignment: .leading, spacing: 2) { Text("Storage Used") .font(.subheadline) Text(storageFormatted) .font(.caption) .foregroundStyle(.secondary) } Spacer() Text("\(downloadService.downloadedChapters.count) chapters") .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 4) } } // MARK: - Empty state private var emptyState: some View { VStack(spacing: 0) { Spacer() EmptyStateView( icon: "arrow.down.circle", title: "No Downloads", message: "Downloaded audio chapters appear here for offline listening." ) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } // MARK: - Helpers private func chapterNumber(from key: String) -> Int { let parts = key.split(separator: "::") guard parts.count >= 2, let n = Int(parts[1]) else { return 0 } return n } } // MARK: - ActiveDownloadRow private struct ActiveDownloadRow: View { let key: String let progress: DownloadProgress @ObservedObject private var downloadService = AudioDownloadService.shared var body: some View { HStack(spacing: 12) { // Icon with status ZStack { Circle() .fill(statusColor.opacity(0.15)) .frame(width: 36, height: 36) Image(systemName: statusIcon) .font(.subheadline.bold()) .foregroundStyle(statusColor) .contentTransition(.symbolEffect(.replace.downUp)) } VStack(alignment: .leading, spacing: 3) { Text("Chapter \(progress.chapter)") .font(.subheadline.bold()) .lineLimit(1) HStack(spacing: 4) { Text(progress.slug) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) Text("·") .font(.caption) .foregroundStyle(.tertiary) Text(formatVoice(progress.voice)) .font(.caption) .foregroundStyle(.secondary) } } Spacer() // Progress or error indicator if case .failed(let msg) = progress.status { VStack(alignment: .trailing, spacing: 2) { Image(systemName: "exclamationmark.triangle.fill") .font(.subheadline) .foregroundStyle(.red) .symbolEffect(.pulse) Text("Failed") .font(.caption2) .foregroundStyle(.red) } .accessibilityLabel("Download failed: \(msg)") } else { VStack(alignment: .trailing, spacing: 4) { Text("\(Int(progress.progress * 100))%") .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) ProgressView(value: progress.progress) .tint(Color.amber) .frame(width: 64) } } // Cancel button (only while downloading) if progress.status == .downloading { Button { UIImpactFeedbackGenerator(style: .light).impactOccurred() downloadService.cancelDownload( slug: progress.slug, chapter: progress.chapter, voice: progress.voice ) } label: { Image(systemName: "xmark.circle.fill") .font(.title3) .foregroundStyle(.secondary) } .buttonStyle(.plain) .frame(minWidth: 44, minHeight: 44) .accessibilityLabel("Cancel download for chapter \(progress.chapter)") } } .padding(.vertical, 4) } private var statusColor: Color { if case .failed = progress.status { return .red } return Color.amber } private var statusIcon: String { if case .failed = progress.status { return "exclamationmark.triangle" } return "arrow.down" } } // MARK: - DownloadedChapterRow private struct DownloadedChapterRow: View { let key: String @ObservedObject private var downloadService = AudioDownloadService.shared // Parse "slug::chapterNumber::voice" — v2 keys use "::" separator private var components: (slug: String, chapter: Int, voice: String) { let parts = key.split(separator: "::") guard parts.count == 3, let chapter = Int(parts[1]) else { return ("", 0, "") } return (String(parts[0]), chapter, String(parts[2])) } var body: some View { let c = components HStack(spacing: 12) { // Checkmark badge ZStack { Circle() .fill(Color.green.opacity(0.15)) .frame(width: 36, height: 36) Image(systemName: "checkmark") .font(.caption.bold()) .foregroundStyle(.green) } .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { Text("Chapter \(c.chapter)") .font(.subheadline.bold()) .lineLimit(1) Text(formatVoice(c.voice)) .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "waveform") .font(.caption) .foregroundStyle(.tertiary) } .padding(.vertical, 4) .accessibilityLabel("Chapter \(c.chapter), voice \(formatVoice(c.voice)), downloaded") .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button(role: .destructive) { UIImpactFeedbackGenerator(style: .medium).impactOccurred() try? downloadService.deleteDownload( slug: c.slug, chapter: c.chapter, voice: c.voice ) } label: { Label("Delete", systemImage: "trash") } } } } // MARK: - Shared voice formatter private func formatVoice(_ voice: String) -> String { let parts = voice.split(separator: "_") guard parts.count == 2 else { return voice } let prefix = String(parts[0]) let name = String(parts[1]).capitalized let gender = prefix.hasSuffix("f") ? "F" : prefix.hasSuffix("m") ? "M" : "" let accent = prefix.hasPrefix("af") || prefix.hasPrefix("am") ? "US" : prefix.hasPrefix("bf") || prefix.hasPrefix("bm") ? "UK" : "" if !gender.isEmpty && !accent.isEmpty { return "\(name) (\(accent) \(gender))" } if !gender.isEmpty { return "\(name) (\(gender))" } return name }