Files
libnovel/ios/LibNovelV2/Views/Downloads/DownloadsView.swift
Admin 7413313100
All checks were successful
CI / Scraper / Lint (push) Successful in 10s
CI / Scraper / Test (push) Successful in 14s
Release / Scraper / Test (push) Successful in 18s
CI / Scraper / Lint (pull_request) Successful in 18s
Release / UI / Build (push) Successful in 23s
CI / Scraper / Test (pull_request) Successful in 15s
CI / UI / Build (pull_request) Successful in 32s
Release / Scraper / Docker (push) Successful in 55s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
CI / Scraper / Docker Push (push) Successful in 1m5s
Release / UI / Docker (push) Successful in 1m12s
iOS CI / Build (push) Successful in 4m18s
iOS CI / Build (pull_request) Successful in 4m25s
iOS CI / Test (push) Successful in 8m11s
iOS CI / Test (pull_request) Successful in 8m21s
fix: update integration_test.go to match server.New signature (version, commit args)
2026-03-14 14:25:46 +05:00

360 lines
12 KiB
Swift

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
}