new app store assets ready for relese ui updates
This commit is contained in:
+19
-5
@@ -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
@@ -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")
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user