new app store assets ready for relese ui updates

This commit is contained in:
Mark Gregory
2026-11-05 10:05:39 +00:00
parent 6e89965c38
commit 6643ba72c8
6 changed files with 78 additions and 21 deletions
+19 -5
View File
@@ -141,16 +141,20 @@ struct ConnectionView: View {
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: torrent.status)).font(.title2)
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)
}
ProgressView(value: torrent.complete ? 1 : min(max(torrent.progress, 0), 1)).tint(model.color(for: torrent.status)).accessibilityLabel("Download progress")
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")
@@ -166,7 +170,7 @@ struct LibraryView: View {
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var search = ""
@State private var filter = "All"
@State private var sort = "Name"
@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
@@ -187,6 +191,12 @@ struct LibraryView: View {
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
}
@@ -206,6 +216,10 @@ struct LibraryView: View {
}
} 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: {
@@ -219,7 +233,7 @@ struct LibraryView: View {
ForEach(filtered) { torrent in
NavigationLink(value: torrent) {
VStack(alignment: .leading) {
TorrentRow(torrent: torrent)
TorrentRow(torrent: torrent, statusOverride: !home && filter == "Completed" ? .completed : nil)
if model.pendingActions.contains(torrent.hash) { ProgressView("Applying action…") }
}
}
@@ -237,7 +251,7 @@ struct LibraryView: View {
.navigationTitle(home ? "Downloading" : "Library")
.searchable(text: $search, prompt: "Name, category or tag")
.refreshable { await model.refresh() }
.toolbar { Menu { Picker("Sort by", selection: $sort) { ForEach(["Name", "Progress", "Speed", "Size", "ETA"], id: \.self) { Text($0) } } } label: { Label("Sort", systemImage: "arrow.up.arrow.down") } }
.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 {
+19 -7
View File
@@ -41,8 +41,11 @@ struct Torrent: Decodable, Identifiable, Hashable {
var hash: String; var name: String; var state: String
var progress: Double; var size: Int64; var dlspeed: Int64; var upspeed: Int64; var eta: Int64
var ratio: Double; var category: String; var tags: String
var priority: Int? = nil
var queuePosition: Int? { guard let priority, priority > 0 else { return nil }; return priority }
var id: String { hash }
var complete: Bool { progress >= 1 || ["uploading", "stalledUP", "forcedUP", "queuedUP", "pausedUP", "stoppedUP"].contains(state) }
// Completion must reflect measured progress, never an inferred state suffix.
var complete: Bool { progress.isFinite && progress >= 1 }
var status: TorrentStatus {
if ["error", "missingFiles"].contains(state) { return .error }
if state.hasPrefix("stalled") { return .stalled }
@@ -320,7 +323,7 @@ struct AppLogEntry: Codable, Identifiable {
}
@MainActor final class AppModel: ObservableObject {
let logs = AppLogStore()
let logs: AppLogStore
@Published private(set) var statusColors: [String: SavedStatusColor] = {
guard let data = UserDefaults.standard.data(forKey: "torrentStatusColors"),
let colors = try? JSONDecoder().decode([String: SavedStatusColor].self, from: data) else { return [:] }
@@ -365,7 +368,7 @@ struct AppLogEntry: Codable, Identifiable {
@Published private(set) var preferences: [String: Any] = [:]
@Published private(set) var preferencesLoading = false
private var preferencesTask: Task<Void, Never>?
private var refreshing = false
private var refreshTask: Task<Void, Never>?
var client: Transport?
var historyStore = HistoryStore()
@Published var historyError: String?
@@ -374,8 +377,9 @@ struct AppLogEntry: Codable, Identifiable {
private let notificationPresenter = CompletionNotificationPresenter()
@Published var completionNotifications = UserDefaults.standard.bool(forKey: "completionNotifications")
private var observedCompletion: [String: Bool] = [:]
init() {
logs.record("App launched.")
init(logs: AppLogStore? = nil) {
self.logs = logs ?? AppLogStore()
self.logs.record("App launched.")
#if os(iOS)
UNUserNotificationCenter.current().delegate = notificationPresenter
#endif
@@ -478,8 +482,16 @@ struct AppLogEntry: Codable, Identifiable {
} catch is CancellationError {} catch let e as URLError where e.code == .cancelled {} catch { if connectionAttempt == attempt { logs.failure(error, context: "Connection"); self.error = error.localizedDescription } }
}
func refresh() async {
guard let client, connected, !locked, !refreshing else { return }
refreshing = true; defer { refreshing = false }
if let task = refreshTask { await task.value; return }
guard connected, !locked else { return }
// Pull-to-refresh waits for in-flight polling instead of returning immediately.
let task = Task { @MainActor in await self.fetchSnapshot() }
refreshTask = task
await task.value
refreshTask = nil
}
private func fetchSnapshot() async {
guard let client, connected, !locked else { return }
do {
async let list: [Torrent] = client.get("torrents/info")
async let info = client.object("sync/maindata")
+1 -1
View File
@@ -44,7 +44,7 @@ struct DetailView: View {
}
}
}.navigationTitle(live.name).navigationBarTitleDisplayMode(.inline)
.refreshable { await load() }
.refreshable { await model.refresh(); await load() }
.task { while !Task.isCancelled { await load(); do { try await Task.sleep(for: .seconds(5)) } catch { break } } }
.confirmationDialog("Delete this torrent?", isPresented: $deleting, titleVisibility: .visible) {
Button("Remove torrent only", role: .destructive) { Task { await remove(false) } }
+2 -2
View File
@@ -50,7 +50,7 @@ struct SettingsView: View {
NavigationLink {
List(keys, id: \.self) { key in NavigationLink { PreferenceEditor(key: key, original: preferences[key]!) { await load() } } label: {
VStack(alignment: .leading) { Text(key.replacingOccurrences(of: "_", with: " ").capitalized); Text(key).font(.caption2).foregroundStyle(.secondary); Text(StatFormat.display(preferences[key]!, key: key, preference: true)).font(.subheadline).foregroundStyle(.secondary) }
} }.navigationTitle(group)
} }.navigationTitle(group).refreshable { await load() }
} label: { LabeledContent(group, value: "\(keys.count)") }
}
}
@@ -69,7 +69,7 @@ struct SettingsView: View {
Link("qBittorrent Web API documentation", destination: URL(string: "https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-5.0)")!)
}
}.navigationTitle("Settings").searchable(text: $search, prompt: "Find a preference key")
.task { await load() }.refreshable { await load() }
.task { await load() }.refreshable { await model.refresh(); await load() }
.confirmationDialog("Forget credentials on this device?", isPresented: $disconnect, titleVisibility: .visible) { Button("Forget and disconnect", role: .destructive) { model.disconnect(forget: true) } }
}
func load() async {
+25 -6
View File
@@ -30,6 +30,9 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
return data
}
@MainActor static func main() async throws {
let testLogURL = URL(fileURLWithPath: ProcessInfo.processInfo.environment["TARNAX_SCRATCH_ROOT"]!).appendingPathComponent("contract-logs-" + UUID().uuidString + ".json")
defer { try? FileManager.default.removeItem(at: testLogURL) }
for server in ["http://example.test", "https://u:p@example.test", "https://example.test/?password=x"] {
do { _ = try Transport(Credentials(server: server)); fatalError("Insecure URL accepted") } catch { check(true, "Reject unsafe URL: \(server)") }
}
@@ -89,7 +92,7 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
transport.session.configuration.httpCookieStorage?.setCookie(cookie)
let another = try Transport(Credentials(server: "https://example.test"))
check(another.session.configuration.httpCookieStorage?.cookies?.contains(where: { $0.name == "SID" }) != true, "Cookie jar isolated from other connections")
let model = AppModel()
let model = AppModel(logs: AppLogStore(url: testLogURL))
model.client = transport; model.connected = true; model.locked = false
let gate = DispatchSemaphore(value: 0)
var preferenceRequests = 0
@@ -132,7 +135,7 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
check(model.error == nil, "Retired session cannot raise a global alert")
model.disconnect()
check(model.preferences.isEmpty && !model.preferencesLoading, "Disconnect clears session preferences")
let startup = AppModel()
let startup = AppModel(logs: AppLogStore(url: testLogURL))
startup.credentials = Credentials(server: "https://example.test")
startup.locked = false
check(!startup.canRestoreSession, "Default server without a saved user does not auto-login")
@@ -148,13 +151,13 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
startup.error = nil
await startup.restoreSessionIfNeeded()
check(startup.error == nil, "Failed automatic login is not retried on view updates")
let disconnected = AppModel()
let disconnected = AppModel(logs: AppLogStore(url: testLogURL))
disconnected.locked = false
disconnected.credentials = Credentials(server: "http://invalid.test", username: "test-user")
disconnected.disconnect()
await disconnected.restoreSessionIfNeeded()
check(disconnected.error == nil && !disconnected.connected, "Explicit disconnect suppresses automatic login for this launch")
let protection = AppModel()
let protection = AppModel(logs: AppLogStore(url: testLogURL))
protection.locked = true; protection.biometric = true
var authCalls = 0
protection.authenticate = { _ in authCalls += 1; return true }
@@ -198,7 +201,7 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
}
await protection.updateScene(.active)
check(!protection.locked, "Face ID success while system prompt is inactive unlocks the app")
let presentation = AppModel()
let presentation = AppModel(logs: AppLogStore(url: testLogURL))
presentation.biometric = true; presentation.locked = true
check(!presentation.unlockNeedsRetry, "Fresh protected launch shows branding instead of lock fallback")
presentation.authenticate = { _ in
@@ -236,7 +239,7 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
check(dense.samples.count == TransferHistory.maximumSamples, "History storage remains bounded during frequent refreshes")
check(model.transferHistory.samples.isEmpty, "Disconnected session has no previous server history")
let historyTransport = try Transport(Credentials(server: "https://example.test"), configuration: config)
let liveHistory = AppModel()
let liveHistory = AppModel(logs: AppLogStore(url: testLogURL))
liveHistory.historyStore = HistoryStore(url: URL(fileURLWithPath: ProcessInfo.processInfo.environment["TARNAX_SCRATCH_ROOT"]!).appendingPathComponent("test-history.json"))
defer { try? liveHistory.historyStore.clear() }
liveHistory.client = historyTransport; liveHistory.connected = true; liveHistory.locked = false
@@ -270,6 +273,22 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
check(StatFormat.display(120, key: "max_seeding_time", preference: true) == "2 hr", "Seeding preferences convert API minutes correctly")
check(StatFormat.display(42, key: "total_peers") == "42", "Unit metadata leaves counters untouched")
check(StatFormat.display(1024, key: "dl_info_speed").hasSuffix("/s"), "Speeds retain per-second units")
let queueJSON = "[{\"hash\":\"queued\",\"name\":\"Queued torrent\",\"state\":\"queuedDL\",\"progress\":0.5,\"size\":100,\"dlspeed\":0,\"upspeed\":0,\"eta\":10,\"ratio\":0,\"category\":\"\",\"tags\":\"\",\"priority\":3}]"
let queue = try JSONDecoder().decode([Torrent].self, from: Data(queueJSON.utf8))
check(queue.first?.queuePosition == 3, "Queue position decodes from server priority")
let unnumbered = try JSONDecoder().decode([Torrent].self, from: Data(queueJSON.replacingOccurrences(of: "3}", with: "-1}").utf8))
check(unnumbered.first?.queuePosition == nil, "Unnumbered server priority is not presented as a queue position")
let noPriority = try JSONDecoder().decode([Torrent].self, from: Data(queueJSON.replacingOccurrences(of: ",\"priority\":3", with: "").utf8))
check(noPriority.first?.queuePosition == nil, "Older servers without priority still decode")
var completionFixture = queue[0]
for state in ["uploading", "stalledUP", "forcedUP", "queuedUP", "pausedUP", "stoppedUP"] {
completionFixture.state = state; completionFixture.progress = 0.75
check(!completionFixture.complete, "Incomplete progress is excluded even for \(state)")
}
completionFixture.progress = 1
check(completionFixture.complete, "Full measured progress is completed")
completionFixture.progress = 0.9999
check(!completionFixture.complete, "Rounded near-100-percent progress is not completed")
another.close(); transport.close(); print("\(checks) contract checks passed.")
}
}
+12
View File
@@ -119,3 +119,15 @@ Settings → Status colours now provides native colour pickers for downloading,
Added Settings → Logs with newest-first timestamped activity/error entries and a confirmed Clear logs action that deletes the saved archive. Logs restore across launches, retain at most 1,000 entries and group adjacent identical events within a minute. Connection, preference loading, server actions and completion notification events are recorded. Reportable network failures include safe error codes; decoding failures are identified without saving payloads. Credentials, request fields, response bodies, server URLs and torrent names are excluded. Storage failures are shown in Logs. Privacy text updated.
Unsigned generic iOS Debug build succeeded. Standalone executable log checks passed for persistence/reload, duplicate grouping, capacity, privacy of network error userInfo, permanent clear/reload and failed-write feedback. The log checks are included in Tests/run.sh. Build/test temporary files are under ~/Developer/Scratch/Tarnax. Device UI verification remains outstanding.
### Queue ordering and pull-to-refresh — 5 November 2026
Library defaults to persisted Queue order sorting, using qBittorrent priority positions and putting unnumbered entries last with deterministic name/hash ordering. Rows display queue positions. Queueing must be enabled server-side for numbered priorities. Missing priority remains compatible with older server responses. Home still filters only actively downloading states.
Pull-to-refresh now awaits any in-flight dashboard refresh instead of silently skipping it. Dashboard requests are session-owned and coalesced. Detail refresh updates the main torrent snapshot as well as properties/files/trackers/peers; Settings refresh updates dashboard and preferences, with pull-to-refresh also available in preference groups. Server already refreshes dashboard, transfer and build information. Unsigned generic iOS Debug build succeeded; 94 executable contracts, standalone log checks and three profile checks passed. Temporary files/logs remain under ~/Developer/Scratch/Tarnax.
Production readiness: this update has not undergone signed Release validation, physical-device/live-server acceptance or installation. Completion notifications still require foreground observation; immediate suspended/killed-app delivery requires server push and is not implemented.
### Completed filter and selected colour correction — 5 November 2026
Completion now requires finite progress >= 1 instead of assuming UP states are completed. This keeps incomplete or rounded-near-100% entries out of Completed and avoids falsely filling their bars. Rows displayed in Library → Completed explicitly use the user's saved Completed colour for icon and progress; other Library filters/detail rows retain state-specific colours. Regression checks cover six upload-related states with incomplete progress, exact completion and near-completion. Unsigned iOS Debug build succeeded; 102 contract checks, log checks and three profile checks passed. Temporary files are under ~/Developer/Scratch/Tarnax. No device installation performed.