414 lines
25 KiB
Swift
414 lines
25 KiB
Swift
import SwiftUI
|
||
import Charts
|
||
import UniformTypeIdentifiers
|
||
|
||
@main struct TarnaxApp: App {
|
||
@StateObject private var model = AppModel()
|
||
var body: some Scene {
|
||
WindowGroup {
|
||
RootView().environmentObject(model).tint(.indigo)
|
||
}
|
||
}
|
||
}
|
||
struct RootView: View {
|
||
@EnvironmentObject var model: AppModel
|
||
@SceneStorage("selectedSection") private var selected = "Home"
|
||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||
@State private var add = false
|
||
@Environment(\.scenePhase) var phase
|
||
let pages = ["Home", "Library", "Server", "Settings"]
|
||
let icons = ["Home": "arrow.down.circle.fill", "Library": "square.stack.fill", "Server": "server.rack", "Settings": "gearshape.fill"]
|
||
var body: some View {
|
||
Group {
|
||
if model.locked {
|
||
if model.unlockNeedsRetry {
|
||
ContentUnavailableView { Label("Unlock Tarnax", systemImage: "lock.shield") } description: {
|
||
Text(model.unlockStatus ?? "Use Face ID or your device passcode to continue.")
|
||
} actions: {
|
||
Button("Unlock") { Task { await model.unlock() } }.buttonStyle(.borderedProminent)
|
||
}
|
||
} else {
|
||
TarnaxSplashView(status: "Unlocking securely…")
|
||
}
|
||
} else if model.restoringSession {
|
||
TarnaxSplashView()
|
||
} else if !model.connected {
|
||
NavigationStack { ConnectionView() }
|
||
} else {
|
||
Group {
|
||
if horizontalSizeClass == .compact {
|
||
TabView(selection: $selected) {
|
||
ForEach(pages, id: \.self) { page in
|
||
NavigationStack { section(page) }
|
||
.tabItem { Label(page, systemImage: icons[page]!) }
|
||
.tag(page)
|
||
}
|
||
}
|
||
} else {
|
||
NavigationSplitView {
|
||
List(pages, id: \.self, selection: Binding<String?>(get: { selected }, set: { if let page = $0 { selected = page } })) { page in
|
||
Label(page, systemImage: icons[page]!)
|
||
}
|
||
.navigationTitle("Tarnax")
|
||
.safeAreaInset(edge: .bottom) {
|
||
Label("qBittorrent \(model.version)", systemImage: "checkmark.shield")
|
||
.font(.caption).foregroundStyle(.secondary).padding()
|
||
}
|
||
} detail: {
|
||
NavigationStack { section(selected) }.id(selected)
|
||
}
|
||
}
|
||
}
|
||
.sheet(isPresented: $add) { AddView() }
|
||
}
|
||
}
|
||
.onChange(of: phase, initial: true) { _, value in
|
||
Task { await model.updateScene(value) }
|
||
}
|
||
.task(id: model.locked) { await model.restoreSessionIfNeeded() }
|
||
.alert("Tarnax", isPresented: Binding(get: { model.error != nil }, set: { if !$0 { model.error = nil } })) { Button("OK") { model.error = nil } } message: { Text(model.error ?? "") }
|
||
.task(id: "\(phase)-\(model.connected)-\(model.locked)") {
|
||
guard phase == .active, model.connected, !model.locked else { return }
|
||
while !Task.isCancelled {
|
||
await model.refresh()
|
||
do { try await Task.sleep(for: .seconds(3)) } catch { break }
|
||
}
|
||
}
|
||
}
|
||
@ViewBuilder private func section(_ page: String) -> some View {
|
||
Group {
|
||
switch page {
|
||
case "Library": LibraryView(home: false)
|
||
case "Server": ServerView()
|
||
case "Settings": SettingsView()
|
||
default: LibraryView(home: true)
|
||
}
|
||
}
|
||
.toolbar {
|
||
if page == "Home" || page == "Library" {
|
||
ToolbarItem(placement: .primaryAction) {
|
||
Button("Add torrent", systemImage: "plus") { add = true }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
struct TarnaxSplashView: View {
|
||
var status: LocalizedStringKey = "Connecting securely…"
|
||
var body: some View {
|
||
ZStack {
|
||
Color(red: 0, green: 0, blue: 0.055).ignoresSafeArea()
|
||
ScrollView { VStack(spacing: 24) {
|
||
Image("TarnaxBrand").resizable().scaledToFit().frame(width: 152, height: 152)
|
||
.clipShape(RoundedRectangle(cornerRadius: 34, style: .continuous))
|
||
.accessibilityHidden(true)
|
||
VStack(spacing: 10) {
|
||
Text("Tarnax").font(.largeTitle.bold()).foregroundStyle(.white)
|
||
Text("Your downloads. Your server.").font(.subheadline).foregroundStyle(.white.opacity(0.75))
|
||
}
|
||
ProgressView(status).font(.body).tint(.white).foregroundStyle(.white)
|
||
.padding(.top, 12)
|
||
}.padding(32).frame(maxWidth: .infinity) }.defaultScrollAnchor(.center)
|
||
}
|
||
}
|
||
}
|
||
struct ConnectionView: View {
|
||
@EnvironmentObject var model: AppModel
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
Label("Tarnax", systemImage: "arrow.down.circle.fill").font(.largeTitle.bold()).padding(.vertical)
|
||
Text("Your downloads. Your server.").foregroundStyle(.secondary)
|
||
}
|
||
Section("qBittorrent Web UI") {
|
||
TextField("HTTPS server URL", text: $model.credentials.server).textContentType(.URL).keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||
TextField("Username", text: $model.credentials.username).textContentType(.username).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||
SecureField("Password", text: $model.credentials.password).textContentType(.password)
|
||
}
|
||
Section("Reverse proxy Basic authentication · optional") {
|
||
TextField("Proxy username", text: $model.credentials.proxyUser).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||
SecureField("Proxy password", text: $model.credentials.proxyPassword)
|
||
}
|
||
Section {
|
||
Button { Task { await model.connect() } } label: {
|
||
HStack { Spacer(); if model.busy { ProgressView() }; Text(model.busy ? "Connecting…" : "Connect securely").bold(); Spacer() }.frame(minHeight: 44)
|
||
}.disabled(model.busy || model.credentials.username.isEmpty)
|
||
} footer: { Text("Credentials are stored only in this device’s Keychain. Use the public HTTPS address, including any proxy subpath. Trusted TLS certificates are required.") }
|
||
}.navigationTitle("Connect").frame(maxWidth: 760)
|
||
}
|
||
}
|
||
struct TorrentRow: View {
|
||
@EnvironmentObject private var model: AppModel
|
||
let torrent: Torrent
|
||
var statusOverride: TorrentStatus? = nil
|
||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
(dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout(alignment: .top))) {
|
||
Image(systemName: torrent.complete ? "checkmark.circle.fill" : "arrow.down.circle.fill").foregroundStyle(model.color(for: statusOverride ?? torrent.status)).font(.title2)
|
||
Text(torrent.name).font(.headline).lineLimit(dynamicTypeSize.isAccessibilitySize ? nil : 2)
|
||
if !dynamicTypeSize.isAccessibilitySize { Spacer() }
|
||
Text(torrent.progress, format: .percent.precision(.fractionLength(1))).font(.subheadline.monospacedDigit()).foregroundStyle(.secondary)
|
||
}
|
||
if let position = torrent.queuePosition {
|
||
Text("Queue #\(position)").font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
ProgressView(value: torrent.complete ? 1 : min(max(torrent.progress, 0), 1)).tint(model.color(for: statusOverride ?? torrent.status)).accessibilityLabel("Download progress")
|
||
(dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout())) {
|
||
Label(bytes(torrent.dlspeed) + "/s", systemImage: "arrow.down").accessibilityLabel("Download speed").accessibilityValue(bytes(torrent.dlspeed) + " per second")
|
||
Label(bytes(torrent.upspeed) + "/s", systemImage: "arrow.up").accessibilityLabel("Upload speed").accessibilityValue(bytes(torrent.upspeed) + " per second")
|
||
if !dynamicTypeSize.isAccessibilitySize { Spacer() }; Text(eta(torrent.eta)).accessibilityLabel("Time remaining").accessibilityValue(eta(torrent.eta))
|
||
}.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||
(dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 6)) : AnyLayout(HStackLayout())) { Text(torrent.state); if !dynamicTypeSize.isAccessibilitySize { Spacer() }; Text(bytes(Int64(Double(torrent.size) * torrent.progress)) + " / " + bytes(torrent.size)) }.font(.caption2).foregroundStyle(.secondary)
|
||
}.padding(.vertical, 8)
|
||
}
|
||
}
|
||
struct LibraryView: View {
|
||
@EnvironmentObject var model: AppModel
|
||
let home: Bool
|
||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||
@State private var search = ""
|
||
@State private var filter = "All"
|
||
@AppStorage("torrentLibrarySort") private var sort = "Queue order"
|
||
let filters = ["All", "Downloading", "Seeding", "Completed", "Paused", "Stalled", "Queued", "Error"]
|
||
var filtered: [Torrent] {
|
||
model.torrents.filter { t in
|
||
let matches: Bool
|
||
switch filter {
|
||
case "Downloading": matches = t.active
|
||
case "Seeding": matches = ["uploading", "stalledUP", "forcedUP"].contains(t.state)
|
||
case "Completed": matches = t.complete
|
||
case "Paused": matches = t.state.hasPrefix("paused") || t.state.hasPrefix("stopped")
|
||
case "Stalled": matches = t.state.hasPrefix("stalled")
|
||
case "Queued": matches = t.state.hasPrefix("queued")
|
||
case "Error": matches = ["error", "missingFiles"].contains(t.state)
|
||
default: matches = true
|
||
}
|
||
return (home ? t.active : matches) && (search.isEmpty || (t.name + " " + t.category + " " + t.tags).localizedCaseInsensitiveContains(search))
|
||
}.sorted {
|
||
switch sort {
|
||
case "Progress": return $0.progress > $1.progress
|
||
case "Speed": return $0.dlspeed > $1.dlspeed
|
||
case "Size": return $0.size > $1.size
|
||
case "Queue order":
|
||
let left = $0.queuePosition ?? Int.max
|
||
let right = $1.queuePosition ?? Int.max
|
||
if left != right { return left < right }
|
||
let nameOrder = $0.name.localizedStandardCompare($1.name)
|
||
return nameOrder == .orderedSame ? $0.hash < $1.hash : nameOrder == .orderedAscending
|
||
case "ETA": return $0.eta < $1.eta
|
||
default: return $0.name.localizedStandardCompare($1.name) == .orderedAscending
|
||
}
|
||
}
|
||
}
|
||
var body: some View {
|
||
List {
|
||
if home {
|
||
Section {
|
||
(dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 16)) : AnyLayout(HStackLayout())) {
|
||
metric("Download", bytes((model.stats["dl_info_speed"] as? NSNumber)?.int64Value ?? 0) + "/s", "arrow.down")
|
||
if !dynamicTypeSize.isAccessibilitySize { Spacer() }
|
||
metric("Upload", bytes((model.stats["up_info_speed"] as? NSNumber)?.int64Value ?? 0) + "/s", "arrow.up")
|
||
}.padding(.vertical, 8)
|
||
TransferHistoryChart(history: model.transferHistory, updatedAt: model.lastRefresh)
|
||
LabeledContent("Free space", value: bytes((model.stats["free_space_on_disk"] as? NSNumber)?.int64Value ?? 0))
|
||
}
|
||
} else {
|
||
Picker("Status", selection: $filter) { ForEach(filters, id: \.self) { Text($0) } }
|
||
if sort == "Queue order" {
|
||
Text("Server queue order · unnumbered torrents appear last. Queueing must be enabled in qBittorrent for positions to be available.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
DisclosureGroup("Status colours") {
|
||
ForEach(TorrentStatus.allCases) { status in
|
||
Label { Text(status.title) } icon: {
|
||
Image(systemName: "circle.fill").foregroundStyle(model.color(for: status))
|
||
}
|
||
}
|
||
NavigationLink("Customise colours") { StatusColorsView() }
|
||
}.font(.subheadline)
|
||
}
|
||
Section(home ? "Actively downloading · \(filtered.count)" : "Torrents · \(filtered.count)") {
|
||
ForEach(filtered) { torrent in
|
||
NavigationLink(value: torrent) {
|
||
VStack(alignment: .leading) {
|
||
TorrentRow(torrent: torrent, statusOverride: !home && filter == "Completed" ? .completed : nil)
|
||
if model.pendingActions.contains(torrent.hash) { ProgressView("Applying action…") }
|
||
}
|
||
}
|
||
.contextMenu {
|
||
Button("Resume", systemImage: "play") { Task { await model.action("resume", hash: torrent.hash) } }.disabled(!torrent.paused || model.pendingActions.contains(torrent.hash))
|
||
Button("Pause", systemImage: "pause") { Task { await model.action("pause", hash: torrent.hash) } }.disabled(torrent.paused || model.pendingActions.contains(torrent.hash))
|
||
}
|
||
}
|
||
if filtered.isEmpty { ContentUnavailableView(home ? "No active downloads" : "No matching torrents", systemImage: home ? "checkmark.circle" : "magnifyingglass", description: Text(home ? "Queued, paused, stalled and completed torrents are in Library." : "Try another search or filter.")) }
|
||
}
|
||
if let feedback = model.actionFeedback { Text(feedback).font(.callout).accessibilityAddTraits(.updatesFrequently) }
|
||
if let error = model.historyError { Text(error).foregroundStyle(.red) }
|
||
if let date = model.lastRefresh { Text("Updated \(date.formatted(date: .omitted, time: .standard)) · refreshes every 3 seconds").font(.caption2).foregroundStyle(.secondary) }
|
||
}
|
||
.navigationTitle(home ? "Downloading" : "Library")
|
||
.searchable(text: $search, prompt: "Name, category or tag")
|
||
.refreshable { await model.refresh() }
|
||
.toolbar { Menu { Picker("Sort by", selection: $sort) { ForEach(["Queue order", "Name", "Progress", "Speed", "Size", "ETA"], id: \.self) { Text($0) } } } label: { Label("Sort", systemImage: "arrow.up.arrow.down") } }
|
||
.navigationDestination(for: Torrent.self) { DetailView(torrent: $0) }
|
||
}
|
||
func metric(_ title: String, _ value: String, _ icon: String) -> some View {
|
||
VStack(alignment: .leading, spacing: 5) { Label(title, systemImage: icon).font(.caption).foregroundStyle(.secondary); Text(value).font(.title3.bold().monospacedDigit()).foregroundStyle(title == "Download" ? Color.blue : Color.teal) }
|
||
}
|
||
}
|
||
struct AddView: View {
|
||
@EnvironmentObject var model: AppModel
|
||
@Environment(\.dismiss) var dismiss
|
||
@State private var magnet = ""
|
||
@State private var path = ""
|
||
@State private var category = ""
|
||
@State private var tags = ""
|
||
@State private var paused = false
|
||
@State private var importing = false
|
||
@State private var file: Data?
|
||
@State private var filename = ""
|
||
@State private var busy = false
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section("Magnet link") { TextField("magnet:?xt=urn:btih:…", text: $magnet, axis: .vertical).textInputAutocapitalization(.never).autocorrectionDisabled() }
|
||
Section("Or a torrent file") { Button(filename.isEmpty ? "Choose .torrent file" : filename) { importing = true } }
|
||
Section("Download options") {
|
||
TextField("Save path · server default if empty", text: $path).textInputAutocapitalization(.never).autocorrectionDisabled()
|
||
TextField("Category", text: $category); TextField("Tags · comma separated", text: $tags)
|
||
Toggle("Add paused", isOn: $paused)
|
||
}
|
||
Button(busy ? "Adding…" : "Add torrent") { Task { await add() } }.disabled(busy || (file == nil && !magnet.hasPrefix("magnet:?")))
|
||
}.navigationTitle("Add Torrent").toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } }
|
||
.interactiveDismissDisabled(busy)
|
||
.fileImporter(isPresented: $importing, allowedContentTypes: [UTType(filenameExtension: "torrent") ?? .data]) { result in
|
||
do {
|
||
let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access { url.stopAccessingSecurityScopedResource() } }
|
||
guard url.pathExtension.lowercased() == "torrent" else { throw RemoteError.message("Choose a .torrent file.") }
|
||
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? 0
|
||
guard size <= 25_000_000 else { throw RemoteError.message("Torrent file exceeds 25 MB.") }
|
||
file = try Data(contentsOf: url); filename = url.lastPathComponent
|
||
} catch { model.error = error.localizedDescription }
|
||
}
|
||
}
|
||
}
|
||
func add() async {
|
||
guard let client = model.client else { return }; busy = true; defer { busy = false }
|
||
var fields = ["category": category, "tags": tags, "paused": paused ? "true" : "false", "stopped": paused ? "true" : "false"]
|
||
if !path.isEmpty { fields["savepath"] = path }
|
||
if file == nil { fields["urls"] = magnet }
|
||
do {
|
||
let data = try await client.request("torrents/add", fields, multipart: file.map { ($0, filename) })
|
||
let response = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard response.isEmpty || response == "Ok." else { throw RemoteError.message("Server rejected the torrent: \(response.prefix(120))") }
|
||
await model.refresh(); dismiss()
|
||
} catch { model.error = error.localizedDescription }
|
||
}
|
||
}
|
||
|
||
struct TransferHistoryChart: View {
|
||
let history: TransferHistory
|
||
let updatedAt: Date?
|
||
@State private var minutes = 1440
|
||
@State private var selection: Date?
|
||
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||
|
||
private var end: Date { Date() }
|
||
private var start: Date { end.addingTimeInterval(-Double(minutes * 60)) }
|
||
private var samples: [TransferSample] { history.samples(from: start, through: end) }
|
||
private var selectedSample: TransferSample? {
|
||
guard let selection else { return nil }
|
||
return samples.min { abs($0.date.timeIntervalSince(selection)) < abs($1.date.timeIntervalSince(selection)) }
|
||
}
|
||
private var maximum: Double {
|
||
max(1024, Double(samples.map { max($0.download, $0.upload) }.max() ?? 0) * 1.15)
|
||
}
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
(dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 12)) : AnyLayout(HStackLayout())) {
|
||
Text("Transfer history").font(.subheadline.weight(.semibold))
|
||
if !dynamicTypeSize.isAccessibilitySize { Spacer() }
|
||
Picker("History duration", selection: $minutes) {
|
||
Text("1m").tag(1)
|
||
Text("5m").tag(5)
|
||
Text("15m").tag(15)
|
||
Text("24h").tag(1440)
|
||
}.pickerStyle(.segmented).frame(maxWidth: 270)
|
||
}
|
||
(dynamicTypeSize.isAccessibilitySize ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) : AnyLayout(HStackLayout(spacing: 20))) {
|
||
Label("Download", systemImage: "arrow.down.circle.fill").foregroundStyle(.blue)
|
||
Label("Upload · dashed", systemImage: "arrow.up.circle.fill").foregroundStyle(.teal)
|
||
}.font(.caption)
|
||
if samples.isEmpty {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "chart.xyaxis.line").font(.title2).foregroundStyle(.secondary)
|
||
Text("Waiting for transfer history").font(.subheadline)
|
||
Text("The graph fills as speeds update.").font(.caption).foregroundStyle(.secondary)
|
||
}.frame(maxWidth: .infinity, minHeight: 160)
|
||
} else {
|
||
Chart {
|
||
ForEach(samples) { sample in
|
||
AreaMark(x: .value("Time", sample.date), yStart: .value("Baseline", 0.0), yEnd: .value("Download speed", Double(sample.download)), series: .value("Area", "Download-fill-\(sample.segment)"))
|
||
.foregroundStyle(LinearGradient(colors: [Color.indigo.opacity(0.35), Color.blue.opacity(0.03)], startPoint: .top, endPoint: .bottom))
|
||
.interpolationMethod(.monotone)
|
||
AreaMark(x: .value("Time", sample.date), yStart: .value("Baseline", 0.0), yEnd: .value("Upload speed", Double(sample.upload)), series: .value("Area", "Upload-fill-\(sample.segment)"))
|
||
.foregroundStyle(LinearGradient(colors: [Color.teal.opacity(0.28), Color.mint.opacity(0.02)], startPoint: .top, endPoint: .bottom))
|
||
.interpolationMethod(.monotone)
|
||
LineMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.download)), series: .value("Series", "Download-\(sample.segment)"))
|
||
.foregroundStyle(LinearGradient(colors: [.blue, .indigo, .purple], startPoint: .leading, endPoint: .trailing))
|
||
.lineStyle(StrokeStyle(lineWidth: 3, lineCap: .round)).interpolationMethod(.monotone)
|
||
LineMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.upload)), series: .value("Series", "Upload-\(sample.segment)"))
|
||
.foregroundStyle(.teal).lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, dash: [5, 4])).interpolationMethod(.monotone)
|
||
if samples.count == 1 || sample.id == samples.last?.id || sample.id == selectedSample?.id {
|
||
PointMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.download))).foregroundStyle(.purple).symbolSize(28)
|
||
PointMark(x: .value("Time", sample.date), y: .value("Bytes per second", Double(sample.upload))).foregroundStyle(.teal).symbolSize(28)
|
||
}
|
||
}
|
||
if let selectedSample {
|
||
RuleMark(x: .value("Selected time", selectedSample.date)).foregroundStyle(.secondary.opacity(0.5))
|
||
}
|
||
}
|
||
.chartXScale(domain: start...end)
|
||
.chartYScale(domain: 0...maximum)
|
||
.chartXAxis {
|
||
AxisMarks(values: .automatic(desiredCount: 3)) { value in
|
||
AxisGridLine().foregroundStyle(.secondary.opacity(0.12))
|
||
AxisValueLabel(format: minutes == 1 ? Date.FormatStyle.dateTime.minute().second() : Date.FormatStyle.dateTime.hour().minute())
|
||
}
|
||
}
|
||
.chartYAxis {
|
||
AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { value in
|
||
AxisGridLine().foregroundStyle(.secondary.opacity(0.15))
|
||
AxisValueLabel {
|
||
if let speed = value.as(Double.self) {
|
||
Text(bytes(Int64(min(max(0, speed), Double(Int64.max / 2)))) + "/s").font(.caption2)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.chartPlotStyle { plot in
|
||
plot.background(LinearGradient(colors: [Color.indigo.opacity(0.06), Color.teal.opacity(0.02)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||
}
|
||
.chartLegend(.hidden)
|
||
.chartXSelection(value: $selection)
|
||
.frame(height: dynamicTypeSize.isAccessibilitySize ? 240 : 170)
|
||
.accessibilityLabel("Upload and download speed history")
|
||
.accessibilityHint("Explore the chart to hear recorded transfer speeds.")
|
||
if let sample = selectedSample {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(sample.date, format: .dateTime.hour().minute().second())
|
||
Text("Download \(bytes(sample.download))/s · Upload \(bytes(sample.upload))/s")
|
||
}.font(.caption.monospacedDigit()).accessibilityElement(children: .combine)
|
||
} else {
|
||
Text("24-hour local history · gaps indicate no measurements")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
.onChange(of: minutes) { _, _ in selection = nil }
|
||
}
|
||
}
|