From 3c0080aefa8773795590a423c577706abaf4312e Mon Sep 17 00:00:00 2001 From: austin-smith Date: Sun, 8 Mar 2026 19:58:20 -0700 Subject: [PATCH 01/11] improve iOS main layout button arrangement --- BitDream/Views/iOS/iOSContentView.swift | 54 ++++++++++++++----------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/BitDream/Views/iOS/iOSContentView.swift b/BitDream/Views/iOS/iOSContentView.swift index a4cae60..321afeb 100644 --- a/BitDream/Views/iOS/iOSContentView.swift +++ b/BitDream/Views/iOS/iOSContentView.swift @@ -126,18 +126,16 @@ private extension iOSContentView { } var serverToolbarItem: some ToolbarContent { - ToolbarItem(placement: .automatic) { + ToolbarItem(placement: .topBarLeading) { Menu { - serverSelectionMenu - Divider() - Button(action: { store.setup.toggle() }, label: { - Label("Add", systemImage: "plus") + Button(action: pauseAllTorrents, label: { + Label("Pause All", systemImage: "pause") }) - Button(action: { store.editServers.toggle() }, label: { - Label("Edit", systemImage: "square.and.pencil") + Button(action: resumeAllTorrents, label: { + Label("Resume All", systemImage: "play") }) } label: { - Image(systemName: "server.rack") + Image(systemName: "ellipsis.circle") } } } @@ -145,29 +143,38 @@ private extension iOSContentView { var actionToolbarItems: some ToolbarContent { ToolbarItemGroup(placement: .automatic) { Menu { - filterMenu - sortMenu + serverSelectionMenu Divider() - Button(action: pauseAllTorrents, label: { - Label("Pause All", systemImage: "pause") - }) - Button(action: resumeAllTorrents, label: { - Label("Resume All", systemImage: "play") + Button(action: { store.setup.toggle() }, label: { + Label("Add", systemImage: "plus") }) - Divider() - Button(action: { - store.showSettings.toggle() - }, label: { - Label("Settings", systemImage: "gear") + Button(action: { store.editServers.toggle() }, label: { + Label("Edit", systemImage: "square.and.pencil") }) } label: { - Image(systemName: "ellipsis.circle") + Image(systemName: "server.rack") } + + Button(action: { + store.showSettings.toggle() + }, label: { + Image(systemName: "gear") + }) } } var bottomToolbarItems: some ToolbarContent { Group { + ToolbarItem(placement: .bottomBar) { + Menu { + filterMenu + sortMenu + } label: { + Image(systemName: "slider.horizontal.3") + } + } + + ToolbarSpacer(.flexible, placement: .bottomBar) DefaultToolbarItem(kind: .search, placement: .bottomBar) ToolbarSpacer(.flexible, placement: .bottomBar) @@ -175,9 +182,8 @@ private extension iOSContentView { Button(action: { store.isShowingAddAlert.toggle() }, label: { - Label("Add Torrent", systemImage: "plus") + Image(systemName: "plus") }) - .foregroundStyle(.tint) } } } @@ -225,7 +231,7 @@ private extension iOSContentView { } } label: { Text("Filter By") - Image(systemName: "slider.horizontal.3") + Image(systemName: "line.3.horizontal.decrease") } .environment(\.menuOrder, .fixed) } From 0131bc3fa535a0b0de4b96e79df77e0be5a2af8b Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Sun, 8 Mar 2026 22:28:30 -0700 Subject: [PATCH 02/11] add action sheets and toolbar to torrent detail for iOS Introduce rename/move/label UI for torrent detail: add @State properties for dialog visibility and inputs, replace TorrentDetailToolbar with a new detailToolbar that presents an IOSTorrentActionsMenu, and wire .sheet modifiers to rename/move/label sheet builders. Implement sheet view factories (renameSheet, moveSheet, labelSheet), helper show* functions to prefill inputs, and a presentError helper. Also change visibility of IOSTorrentActionsMenu, IOSTorrentRenameSheet, and IOSTorrentMoveSheet from private to internal so they can be reused from the detail view. --- BitDream/Views/iOS/iOSTorrentDetail.swift | 86 +++++++++++++++++++++- BitDream/Views/iOS/iOSTorrentListRow.swift | 6 +- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index a0ded51..3e9c2b8 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -10,6 +10,13 @@ struct iOSTorrentDetail: View { @StateObject private var supplementalStore = TorrentDetailSupplementalStore() @State private var showingDeleteConfirmation = false + @State private var labelDialog = false + @State private var labelInput: String = "" + @State private var renameDialog = false + @State private var renameInput: String = "" + @State private var moveDialog = false + @State private var movePath: String = "" + @State private var moveShouldMove = true @State private var showingError = false @State private var errorMessage = "" @@ -36,7 +43,7 @@ struct iOSTorrentDetail: View { await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) } .toolbar { - TorrentDetailToolbar(torrent: torrent, store: store) + detailToolbar } .alert("Delete Torrent", isPresented: $showingDeleteConfirmation) { Button(role: .destructive) { @@ -52,6 +59,9 @@ struct iOSTorrentDetail: View { Text("Do you want to delete the file(s) from the disk?") } .transmissionErrorAlert(isPresented: $showingError, message: errorMessage) + .sheet(isPresented: $renameDialog, content: renameSheet) + .sheet(isPresented: $moveDialog, content: moveSheet) + .sheet(isPresented: $labelDialog, content: labelSheet) } private func performDelete(deleteLocalData: Bool) { @@ -72,6 +82,80 @@ struct iOSTorrentDetail: View { ) } + private var detailToolbar: some ToolbarContent { + ToolbarItem { + Menu { + IOSTorrentActionsMenu( + torrent: torrent, + store: store, + onShowMove: showMoveDialog, + onShowRename: showRenameDialog, + onShowLabels: showLabelDialog, + onShowDelete: { showingDeleteConfirmation = true }, + onError: presentError + ) + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + + private func renameSheet() -> some View { + NavigationView { + IOSTorrentRenameSheet( + torrent: torrent, + store: store, + renameInput: $renameInput, + isPresented: $renameDialog, + onError: presentError + ) + } + } + + private func moveSheet() -> some View { + NavigationView { + IOSTorrentMoveSheet( + torrent: torrent, + store: store, + movePath: $movePath, + moveShouldMove: $moveShouldMove, + isPresented: $moveDialog, + onError: presentError + ) + } + } + + private func labelSheet() -> some View { + NavigationView { + iOSLabelEditView( + labelInput: $labelInput, + existingLabels: torrent.labels, + store: store, + torrentId: torrent.id + ) + } + } + + private func showRenameDialog() { + renameInput = torrent.name + renameDialog = true + } + + private func showMoveDialog() { + movePath = store.defaultDownloadDir + moveDialog = true + } + + private func showLabelDialog() { + labelInput = torrent.labels.joined(separator: ", ") + labelDialog = true + } + + private func presentError(_ error: String) { + errorMessage = error + showingError = true + } + @MainActor private func applyCommittedFileStatsMutation( fileIndices: [Int], diff --git a/BitDream/Views/iOS/iOSTorrentListRow.swift b/BitDream/Views/iOS/iOSTorrentListRow.swift index 7f96d9e..2f5e704 100644 --- a/BitDream/Views/iOS/iOSTorrentListRow.swift +++ b/BitDream/Views/iOS/iOSTorrentListRow.swift @@ -177,7 +177,7 @@ struct iOSTorrentListRow: View { } @MainActor -private struct IOSTorrentActionsMenu: View { +struct IOSTorrentActionsMenu: View { let torrent: Torrent let store: TransmissionStore let onShowMove: () -> Void @@ -318,7 +318,7 @@ private struct IOSTorrentActionsMenu: View { } @MainActor -private struct IOSTorrentRenameSheet: View { +struct IOSTorrentRenameSheet: View { let torrent: Torrent let store: TransmissionStore @Binding var renameInput: String @@ -375,7 +375,7 @@ private struct IOSTorrentRenameSheet: View { } @MainActor -private struct IOSTorrentMoveSheet: View { +struct IOSTorrentMoveSheet: View { let torrent: Torrent let store: TransmissionStore @Binding var movePath: String From af73965c6dd483aefe79a91962129f2eb8bfd2bc Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 9 Mar 2026 04:46:11 -0700 Subject: [PATCH 03/11] align iOS pieces view/state with macOS Introduce a shared TorrentPiecesSectionState and move hasRenderablePieceData into TorrentDetailSupplementalPayload. Update iOS and macOS detail views to use the shared state, remove the macOS-specific enum, and wire a retry action that re-triggers supplementalStore.load on iOS. Add composed iOS views for pieces (loading/content/empty/failed) and unit tests covering resolve() behavior for the new state. --- BitDream/Views/Shared/TorrentDetail.swift | 30 ++++ BitDream/Views/iOS/iOSTorrentDetail.swift | 148 ++++++++++++++++-- BitDream/Views/macOS/macOSTorrentDetail.swift | 38 +---- .../TorrentDetailSupplementalStateTests.swift | 58 +++++++ 4 files changed, 222 insertions(+), 52 deletions(-) diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index accf7b3..f60fe1a 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -99,6 +99,36 @@ internal struct TorrentDetailSupplementalPayload: Sendable, Equatable { piecesHaveCount: piecesHaveCount ) } + + var hasRenderablePieceData: Bool { + pieceCount > 0 && !piecesHaveSet.isEmpty + } +} + +internal enum TorrentPiecesSectionState: Equatable { + case loading + case content(TorrentDetailSupplementalPayload) + case empty + case failed + + static func resolve( + status: TorrentDetailSupplementalLoadStatus, + payload: TorrentDetailSupplementalPayload, + shouldDisplayPayload: Bool + ) -> Self { + guard shouldDisplayPayload else { + return status == .failed ? .failed : .loading + } + + switch status { + case .failed: + return payload.hasRenderablePieceData ? .content(payload) : .failed + case .loaded: + return payload.hasRenderablePieceData ? .content(payload) : .empty + case .idle, .loading: + return payload.hasRenderablePieceData ? .content(payload) : .loading + } + } } internal struct TorrentDetailSupplementalState: Sendable { diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index 3e9c2b8..f4c6813 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -30,14 +30,30 @@ struct iOSTorrentDetail: View { var body: some View { let details = formatTorrentDetails(torrent: torrent) + let piecesSectionState = TorrentPiecesSectionState.resolve( + status: supplementalStore.status, + payload: supplementalPayload, + shouldDisplayPayload: shouldDisplaySupplementalPayload + ) IOSTorrentDetailContent( torrent: torrent, details: details, supplementalPayload: supplementalPayload, + piecesSectionState: piecesSectionState, filesDestination: filesDestination, peersDestination: peersDestination, - onDelete: { showingDeleteConfirmation = true } + onDelete: { showingDeleteConfirmation = true }, + onRetryPiecesLoad: { + Task { + await supplementalStore.load( + for: torrent.id, + using: store, + showingError: $showingError, + errorMessage: $errorMessage + ) + } + } ) .task(id: torrent.id) { await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) @@ -232,9 +248,11 @@ private struct IOSTorrentDetailContent Void + let onRetryPiecesLoad: () -> Void var body: some View { NavigationStack { @@ -315,22 +333,12 @@ private struct IOSTorrentDetailContent 0 && !supplementalPayload.piecesHaveSet.isEmpty { - Section(header: Text("Pieces")) { - VStack(alignment: .leading, spacing: 6) { - PiecesGridView( - piecesHaveSet: supplementalPayload.piecesHaveSet - ) - .frame(maxWidth: .infinity, alignment: .leading) - - Text( - "\(supplementalPayload.piecesHaveCount) of \(supplementalPayload.pieceCount) pieces • \(formatByteCount(supplementalPayload.pieceSize)) each" - ) - .font(.caption) - .foregroundColor(.gray) - } - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) - } + Section(header: Text("Pieces")) { + IOSTorrentPiecesSectionContent( + state: piecesSectionState, + onRetry: onRetryPiecesLoad + ) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) } Section(header: Text("Additional Info")) { @@ -375,4 +383,110 @@ private struct IOSTorrentDetailContent Void + + var body: some View { + switch state { + case .loading: + IOSTorrentPiecesLoadingView() + case .content(let payload): + VStack(alignment: .leading, spacing: 6) { + PiecesGridView( + piecesHaveSet: payload.piecesHaveSet + ) + .frame(maxWidth: .infinity, alignment: .leading) + + Text( + "\(payload.piecesHaveCount) of \(payload.pieceCount) pieces • \(formatByteCount(payload.pieceSize)) each" + ) + .font(.caption) + .foregroundColor(.secondary) + } + case .empty: + IOSTorrentPiecesMessageView( + title: "No Piece Data", + message: "Piece availability is not available for this torrent." + ) + case .failed: + IOSTorrentPiecesMessageView( + title: "Pieces Unavailable", + message: "BitDream couldn't load piece availability for this torrent.", + actionTitle: "Retry", + action: onRetry + ) + } + } +} + +private struct IOSTorrentPiecesLoadingView: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.secondary.opacity(0.12)) + .frame(maxWidth: .infinity) + .frame(height: 80) + .overlay(alignment: .topLeading) { + VStack(alignment: .leading, spacing: 6) { + RoundedRectangle(cornerRadius: 4) + .frame(width: 140, height: 8) + RoundedRectangle(cornerRadius: 4) + .frame(maxWidth: .infinity) + .frame(height: 8) + RoundedRectangle(cornerRadius: 4) + .frame(width: 200, height: 8) + } + .padding(12) + .foregroundStyle(.secondary.opacity(0.2)) + } + + RoundedRectangle(cornerRadius: 4) + .fill(Color.secondary.opacity(0.12)) + .frame(width: 220, height: 10) + } + .padding(.vertical, 4) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Loading pieces") + } +} + +private struct IOSTorrentPiecesMessageView: View { + let title: String + let message: String + let actionTitle: String? + let action: (() -> Void)? + + init( + title: String, + message: String, + actionTitle: String? = nil, + action: (() -> Void)? = nil + ) { + self.title = title + self.message = message + self.actionTitle = actionTitle + self.action = action + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.subheadline) + .fontWeight(.medium) + + Text(message) + .font(.caption) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if let actionTitle, let action { + Button(actionTitle, action: action) + .font(.caption.weight(.semibold)) + } + } + .padding(.vertical, 4) + } +} + #endif diff --git a/BitDream/Views/macOS/macOSTorrentDetail.swift b/BitDream/Views/macOS/macOSTorrentDetail.swift index f645f22..f48f186 100644 --- a/BitDream/Views/macOS/macOSTorrentDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentDetail.swift @@ -25,7 +25,7 @@ struct macOSTorrentDetail: View { var body: some View { let details = formatTorrentDetails(torrent: torrent) - let piecesSectionState = MacOSTorrentPiecesSectionState.resolve( + let piecesSectionState = TorrentPiecesSectionState.resolve( status: supplementalStore.status, payload: supplementalPayload, shouldDisplayPayload: shouldDisplaySupplementalPayload @@ -188,37 +188,11 @@ struct macOSTorrentDetail: View { } } -internal enum MacOSTorrentPiecesSectionState: Equatable { - case loading - case content(TorrentDetailSupplementalPayload) - case empty - case failed - - static func resolve( - status: TorrentDetailSupplementalLoadStatus, - payload: TorrentDetailSupplementalPayload, - shouldDisplayPayload: Bool - ) -> Self { - guard shouldDisplayPayload else { - return status == .failed ? .failed : .loading - } - - switch status { - case .failed: - return payload.hasRenderablePieceData ? .content(payload) : .failed - case .loaded: - return payload.hasRenderablePieceData ? .content(payload) : .empty - case .idle, .loading: - return payload.hasRenderablePieceData ? .content(payload) : .loading - } - } -} - private struct MacOSTorrentDetailContent: View { let torrent: Torrent let details: TorrentDetailsDisplay let supplementalPayload: TorrentDetailSupplementalPayload - let piecesSectionState: MacOSTorrentPiecesSectionState + let piecesSectionState: TorrentPiecesSectionState let onShowFiles: () -> Void let onShowPeers: () -> Void let onDelete: () -> Void @@ -331,7 +305,7 @@ private struct MacOSTorrentDetailContent: View { private struct MacOSTorrentPiecesSection: View { private static let contentMinHeight: CGFloat = 96 - let state: MacOSTorrentPiecesSectionState + let state: TorrentPiecesSectionState var body: some View { GroupBox { @@ -456,12 +430,6 @@ struct DetailRow: View { } } -private extension TorrentDetailSupplementalPayload { - var hasRenderablePieceData: Bool { - pieceCount > 0 && !piecesHaveSet.isEmpty - } -} - // Native macOS-style section header component struct macOSSectionHeader: View { let title: String diff --git a/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift b/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift index f7685e0..961a295 100644 --- a/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift +++ b/BitDreamTests/Views/TorrentDetailSupplementalStateTests.swift @@ -256,6 +256,64 @@ final class TorrentDetailSupplementalStateTests: XCTestCase { XCTAssertEqual(state.visiblePayload(for: 24).files.map(\.name), ["retained-file"]) XCTAssertEqual(state.visiblePayload(for: 25), .empty) } + + func testPiecesSectionStateResolvesLoadingWhenPayloadIsUnavailable() { + let state = TorrentPiecesSectionState.resolve( + status: .idle, + payload: .empty, + shouldDisplayPayload: false + ) + + XCTAssertEqual(state, .loading) + } + + func testPiecesSectionStateResolvesContentWhenPayloadHasRenderablePieces() { + let payload = TorrentDetailSupplementalPayload(snapshot: makeSnapshot(pieceCount: 3)) + + let state = TorrentPiecesSectionState.resolve( + status: .loaded, + payload: payload, + shouldDisplayPayload: true + ) + + XCTAssertEqual(state, .content(payload)) + } + + func testPiecesSectionStateResolvesEmptyWhenLoadedPayloadHasNoRenderablePieces() { + let payload = TorrentDetailSupplementalPayload( + snapshot: makeSnapshot(pieceCount: 0, piecesBitfieldBase64: "") + ) + + let state = TorrentPiecesSectionState.resolve( + status: .loaded, + payload: payload, + shouldDisplayPayload: true + ) + + XCTAssertEqual(state, .empty) + } + + func testPiecesSectionStateResolvesFailedWhenInitialLoadFailsWithoutPayload() { + let state = TorrentPiecesSectionState.resolve( + status: .failed, + payload: .empty, + shouldDisplayPayload: false + ) + + XCTAssertEqual(state, .failed) + } + + func testPiecesSectionStateKeepsContentVisibleWhenRefreshFailsAfterSuccessfulLoad() { + let payload = TorrentDetailSupplementalPayload(snapshot: makeSnapshot(pieceCount: 3)) + + let state = TorrentPiecesSectionState.resolve( + status: .failed, + payload: payload, + shouldDisplayPayload: true + ) + + XCTAssertEqual(state, .content(payload)) + } } private func makeSnapshot( From 2a4918fcc1770eae620db0bebbbfe4cf7d171aec Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 9 Mar 2026 05:00:33 -0700 Subject: [PATCH 04/11] switch to onChange load and add pieces grid id Encapsulate supplementalStore.load into a @MainActor helper (loadSupplementalDetails) and use .onChange(of: torrent.id, initial: true) to trigger loads (including retry) instead of .task. Pass torrentID into the pieces section and add a PiecesGridIdentity (torrentID, pieceCount, piecesHaveCount) as the PiecesGridView id so the grid updates correctly when torrent or piece data change. --- BitDream/Views/iOS/iOSTorrentDetail.swift | 36 ++++++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index f4c6813..9bfe566 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -28,6 +28,16 @@ struct iOSTorrentDetail: View { supplementalStore.shouldDisplayPayload(for: torrent.id) } + @MainActor + private func loadSupplementalDetails() async { + await supplementalStore.load( + for: torrent.id, + using: store, + showingError: $showingError, + errorMessage: $errorMessage + ) + } + var body: some View { let details = formatTorrentDetails(torrent: torrent) let piecesSectionState = TorrentPiecesSectionState.resolve( @@ -46,17 +56,14 @@ struct iOSTorrentDetail: View { onDelete: { showingDeleteConfirmation = true }, onRetryPiecesLoad: { Task { - await supplementalStore.load( - for: torrent.id, - using: store, - showingError: $showingError, - errorMessage: $errorMessage - ) + await loadSupplementalDetails() } } ) - .task(id: torrent.id) { - await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) + .onChange(of: torrent.id, initial: true) { _, _ in + Task { + await loadSupplementalDetails() + } } .toolbar { detailToolbar @@ -335,6 +342,7 @@ private struct IOSTorrentDetailContent Void @@ -396,6 +405,11 @@ private struct IOSTorrentPiecesSectionContent: View { PiecesGridView( piecesHaveSet: payload.piecesHaveSet ) + .id(PiecesGridIdentity( + torrentID: torrentID, + pieceCount: payload.pieceCount, + piecesHaveCount: payload.piecesHaveCount + )) .frame(maxWidth: .infinity, alignment: .leading) Text( @@ -420,6 +434,12 @@ private struct IOSTorrentPiecesSectionContent: View { } } +private struct PiecesGridIdentity: Hashable { + let torrentID: Int + let pieceCount: Int + let piecesHaveCount: Int +} + private struct IOSTorrentPiecesLoadingView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { From e74d733f911d08d125864fa7e7128ed9f6e0f555 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 9 Mar 2026 05:09:17 -0700 Subject: [PATCH 05/11] render pieces grid with Canvas and remove identity Replace the LazyVGrid + GeometryReader implementation in PiecesGridView with a Canvas-based renderer that computes columns from the canvas size and paints rounded rectangles directly (using a computed unit for spacing). This simplifies layout, avoids per-cell SwiftUI views, and keeps the same fixed height. Also remove the .id usage and the PiecesGridIdentity type from the iOS torrent detail view: the torrentID prop and identity struct were removed from IOSTorrentPiecesSectionContent since the explicit identity is no longer needed. --- BitDream/Views/Shared/PiecesGridView.swift | 27 ++++++++++------------ BitDream/Views/iOS/iOSTorrentDetail.swift | 13 ----------- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/BitDream/Views/Shared/PiecesGridView.swift b/BitDream/Views/Shared/PiecesGridView.swift index cf1dda7..56b57a6 100644 --- a/BitDream/Views/Shared/PiecesGridView.swift +++ b/BitDream/Views/Shared/PiecesGridView.swift @@ -11,25 +11,22 @@ struct PiecesGridView: View { var body: some View { let bitset = piecesHaveSet - GeometryReader { geometry in - let columnsCount = computeColumns(availableWidth: geometry.size.width, cellSize: cellSize, cellSpacing: cellSpacing) + Canvas { context, size in + let columnsCount = computeColumns(availableWidth: size.width, cellSize: cellSize, cellSpacing: cellSpacing) let totalCells = max(1, rows * columnsCount) let buckets = bucketize(bitset: bitset, totalBuckets: totalCells) + let unit = cellSize + cellSpacing - LazyVGrid( - columns: Array(repeating: GridItem(.fixed(cellSize), spacing: cellSpacing, alignment: .leading), count: columnsCount), - spacing: cellSpacing - ) { - ForEach(0.. Void @@ -405,11 +403,6 @@ private struct IOSTorrentPiecesSectionContent: View { PiecesGridView( piecesHaveSet: payload.piecesHaveSet ) - .id(PiecesGridIdentity( - torrentID: torrentID, - pieceCount: payload.pieceCount, - piecesHaveCount: payload.piecesHaveCount - )) .frame(maxWidth: .infinity, alignment: .leading) Text( @@ -434,12 +427,6 @@ private struct IOSTorrentPiecesSectionContent: View { } } -private struct PiecesGridIdentity: Hashable { - let torrentID: Int - let pieceCount: Int - let piecesHaveCount: Int -} - private struct IOSTorrentPiecesLoadingView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { From 66af6aed376fccfb0e2559b0b9de0e3fb0f911a3 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 9 Mar 2026 07:55:41 -0700 Subject: [PATCH 06/11] Add iOS connection banner and remove alert Replace the modal connection error alert on iOS with an inline banner. Added iOSConnectionBannerView to Shared views (status icon, title, periodic retry countdown, last error text and a Retry button). iOSContentView now conditionally shows the banner when a host is configured and the connection is not connected, with a top transition and animation. TransmissionStore no longer toggles showConnectionErrorAlert for iOS (set to false), and the previous alert presentation in iOSContentView was removed. The Retry button is disabled when reconnect attempts are not allowed. --- BitDream/TransmissionStore.swift | 14 +---- .../Views/iOS/iOSConnectionBannerView.swift | 59 +++++++++++++++++++ BitDream/Views/iOS/iOSContentView.swift | 19 +++--- .../macOS/macOSConnectionBannerView.swift | 45 ++++++++++++++ BitDream/Views/macOS/macOSContentDetail.swift | 41 +------------ 5 files changed, 115 insertions(+), 63 deletions(-) create mode 100644 BitDream/Views/iOS/iOSConnectionBannerView.swift create mode 100644 BitDream/Views/macOS/macOSConnectionBannerView.swift diff --git a/BitDream/TransmissionStore.swift b/BitDream/TransmissionStore.swift index 94ba3b7..34b3852 100644 --- a/BitDream/TransmissionStore.swift +++ b/BitDream/TransmissionStore.swift @@ -105,7 +105,6 @@ final class TransmissionStore: NSObject, ObservableObject { @Published var lastErrorMessage: String = "" @Published var nextRetryAt: Date? - @Published var showConnectionErrorAlert: Bool = false @Published var sessionConfiguration: TransmissionSessionResponseArguments? @Published private(set) var settingsConnectionGeneration = UUID() @@ -502,7 +501,6 @@ extension TransmissionStore { func clearReconnectPresentationState() { nextRetryAt = nil cancelRetryTask() - showConnectionErrorAlert = false } func clearPendingRetrySchedule() { @@ -561,22 +559,12 @@ extension TransmissionStore { let remainingDelay = nextRetryAt.timeIntervalSince(now) scheduleRetryTask(after: remainingDelay, generation: currentConnectionGeneration) } - #if os(iOS) - showConnectionErrorAlert = true - #else - showConnectionErrorAlert = false - #endif - return + return } let scheduledDelay = reconnectBackoff.nextDelay() scheduleRetryTask(after: scheduledDelay, generation: currentConnectionGeneration) - #if os(iOS) - showConnectionErrorAlert = true - #else - showConnectionErrorAlert = false - #endif } // Add a method to update the poll interval and restart the timer diff --git a/BitDream/Views/iOS/iOSConnectionBannerView.swift b/BitDream/Views/iOS/iOSConnectionBannerView.swift new file mode 100644 index 0000000..4eb55e8 --- /dev/null +++ b/BitDream/Views/iOS/iOSConnectionBannerView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +#if os(iOS) +struct iOSConnectionBannerView: View { + @ObservedObject var store: TransmissionStore + + private var shouldShowLastError: Bool { + store.connectionStatus == .reconnecting && !store.lastErrorMessage.isEmpty + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: connectionStatusSymbol(for: store.connectionStatus)) + .foregroundStyle(connectionStatusColor(for: store.connectionStatus)) + .font(.system(size: 16, weight: .semibold)) + + VStack(alignment: .leading, spacing: 2) { + Text(connectionStatusTitle(for: store.connectionStatus)) + .font(.subheadline.weight(.semibold)) + + TimelineView(.periodic(from: .now, by: 1)) { context in + Text( + connectionRetryText( + status: store.connectionStatus, + retryAt: store.nextRetryAt, + at: context.date + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if shouldShowLastError { + Text(store.lastErrorMessage) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + + Spacer() + + Button("Retry") { + store.reconnect() + } + .buttonStyle(.bordered) + .disabled(!store.canAttemptReconnect) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.ultraThinMaterial) + .overlay(alignment: .bottom) { + Divider() + } + .accessibilityElement(children: .combine) + } +} +#endif diff --git a/BitDream/Views/iOS/iOSContentView.swift b/BitDream/Views/iOS/iOSContentView.swift index 321afeb..9e5bc8a 100644 --- a/BitDream/Views/iOS/iOSContentView.swift +++ b/BitDream/Views/iOS/iOSContentView.swift @@ -29,6 +29,14 @@ struct iOSContentView: View { VStack(spacing: 0) { StatsHeaderView(store: store) + Group { + if store.host != nil, store.connectionStatus != .connected { + iOSConnectionBannerView(store: store) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.default, value: store.connectionStatus) + // Show list regardless of connection status List(selection: $selectedTorrentIds) { torrentRows @@ -52,17 +60,6 @@ struct iOSContentView: View { .onChange(of: sortOrder) { _, newValue in UserDefaults.standard.sortOrder = newValue } - .alert("Connection Error", isPresented: $store.showConnectionErrorAlert) { - Button("Edit Server", role: .none) { - store.editServers.toggle() - } - Button("Retry", role: .none) { - store.reconnect() - } - Button("Cancel", role: .cancel) {} - } message: { - Text(store.lastErrorMessage) - } } detail: { if let selectedTorrent = selectedTorrentsSet.first { TorrentDetail(store: store, torrent: selectedTorrent) diff --git a/BitDream/Views/macOS/macOSConnectionBannerView.swift b/BitDream/Views/macOS/macOSConnectionBannerView.swift new file mode 100644 index 0000000..cdcd9ea --- /dev/null +++ b/BitDream/Views/macOS/macOSConnectionBannerView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +#if os(macOS) +struct macOSConnectionBannerView: View { + @Environment(\.openWindow) private var openWindow + @ObservedObject var store: TransmissionStore + + var body: some View { + HStack(spacing: 12) { + Image(systemName: connectionStatusSymbol(for: store.connectionStatus)) + .foregroundStyle(connectionStatusColor(for: store.connectionStatus)) + .font(.system(size: 16, weight: .semibold)) + VStack(alignment: .leading, spacing: 2) { + Text(connectionStatusTitle(for: store.connectionStatus)) + .font(.subheadline.weight(.semibold)) + TimelineView(.periodic(from: .now, by: 1)) { context in + Text( + connectionRetryText( + status: store.connectionStatus, + retryAt: store.nextRetryAt, + at: context.date + ) + ) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + } + Spacer() + Button("Connection Info") { + openWindow(id: "connection-info") + } + .buttonStyle(.bordered) + .help("Open Connection Info window") + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.ultraThinMaterial) + .overlay(alignment: .bottom) { + Divider() + } + } +} +#endif diff --git a/BitDream/Views/macOS/macOSContentDetail.swift b/BitDream/Views/macOS/macOSContentDetail.swift index 828360f..718eacd 100644 --- a/BitDream/Views/macOS/macOSContentDetail.swift +++ b/BitDream/Views/macOS/macOSContentDetail.swift @@ -24,8 +24,8 @@ struct macOSContentDetail: View { VStack(spacing: 0) { StatsHeaderView(store: store) - if store.connectionStatus == .reconnecting { - ConnectionBannerView(status: store.connectionStatus, retryAt: store.nextRetryAt) + if store.host != nil, store.connectionStatus != .connected { + macOSConnectionBannerView(store: store) } VStack { @@ -216,43 +216,6 @@ struct macOSContentInspector: View { } } -private struct ConnectionBannerView: View { - @Environment(\.openWindow) private var openWindow - - let status: TransmissionStore.ConnectionStatus - let retryAt: Date? - - var body: some View { - HStack(spacing: 12) { - Image(systemName: connectionStatusSymbol(for: status)) - .foregroundColor(connectionStatusColor(for: status)) - .font(.system(size: 16, weight: .semibold)) - VStack(alignment: .leading, spacing: 2) { - Text(connectionStatusTitle(for: status)) - .font(.subheadline) - .fontWeight(.semibold) - TimelineView(.periodic(from: .now, by: 1)) { context in - Text(connectionRetryText(status: status, retryAt: retryAt, at: context.date)) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - .truncationMode(.tail) - } - } - Spacer() - Button("Connection Info") { - openWindow(id: "connection-info") - } - .buttonStyle(.bordered) - .help("Open Connection Info window") - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(.ultraThinMaterial) - .overlay(Divider(), alignment: .bottom) - } -} - private final class TorrentInfoAccumulator: Sendable { private let values = Mutex<[TorrentInfo]>([]) From b2730f15374940e694e1487ad6865e41731d1692 Mon Sep 17 00:00:00 2001 From: austin-smith Date: Mon, 9 Mar 2026 20:16:10 -0700 Subject: [PATCH 07/11] Add preferences popover for filter & sort Replace the separate filter and sort menus with a single preferences popover accessed from the bottom-bar slider button. Adds @State showPrefs and a new prefsPopoverContent view (NavigationStack + List) that consolidates filter buttons, a sort property section and a segmented Picker for sort order, plus a Done toolbar button and presentation styling. Removes the old filterMenu and sortMenu implementations and updates checkmark styling for the selected sort property. --- BitDream/Views/iOS/iOSContentView.swift | 121 +++++++++++------------- 1 file changed, 56 insertions(+), 65 deletions(-) diff --git a/BitDream/Views/iOS/iOSContentView.swift b/BitDream/Views/iOS/iOSContentView.swift index 9e5bc8a..cbcccf0 100644 --- a/BitDream/Views/iOS/iOSContentView.swift +++ b/BitDream/Views/iOS/iOSContentView.swift @@ -23,6 +23,7 @@ struct iOSContentView: View { @State var filterBySelection: [TorrentStatusCalc] = TorrentStatusCalc.allCases @AppStorage(UserDefaultsKeys.showContentTypeIcons) private var showContentTypeIcons: Bool = true @State private var searchText: String = "" + @State private var showPrefs: Bool = false var body: some View { NavigationSplitView { @@ -163,12 +164,14 @@ private extension iOSContentView { var bottomToolbarItems: some ToolbarContent { Group { ToolbarItem(placement: .bottomBar) { - Menu { - filterMenu - sortMenu + Button { + showPrefs.toggle() } label: { Image(systemName: "slider.horizontal.3") } + .popover(isPresented: $showPrefs) { + prefsPopoverContent + } } ToolbarSpacer(.flexible, placement: .bottomBar) @@ -205,79 +208,67 @@ private extension iOSContentView { } } - var filterMenu: some View { - Menu { - Section(header: Text("Include")) { - Button("All") { - filterBySelection = TorrentStatusCalc.allCases - } - Button("Downloading") { - filterBySelection = [.downloading] - } - Button("Complete") { - filterBySelection = [.complete] - } - Button("Paused") { - filterBySelection = [.paused] - } - } - Section(header: Text("Exclude")) { - Button("Complete") { - filterBySelection = TorrentStatusCalc.allCases.filter { $0 != .complete } + var prefsPopoverContent: some View { + NavigationStack { + List { + Section { + Button("All") { + filterBySelection = TorrentStatusCalc.allCases + } + Button("Downloading") { + filterBySelection = [.downloading] + } + Button("Complete") { + filterBySelection = [.complete] + } + Button("Paused") { + filterBySelection = [.paused] + } + Button("Exclude Complete") { + filterBySelection = TorrentStatusCalc.allCases.filter { $0 != .complete } + } + } header: { + Text("Filter") } - } - } label: { - Text("Filter By") - Image(systemName: "line.3.horizontal.decrease") - } - .environment(\.menuOrder, .fixed) - } - var sortMenu: some View { - Menu { - ForEach(SortProperty.allCases, id: \.self) { property in - Button { - sortProperty = property - } label: { - HStack { - Text(property.rawValue) - Spacer() - if sortProperty == property { - Image(systemName: "checkmark") + Section { + ForEach(SortProperty.allCases, id: \.self) { property in + Button { + sortProperty = property + } label: { + HStack { + Text(property.rawValue) + Spacer() + if sortProperty == property { + Image(systemName: "checkmark") + .foregroundStyle(.accent) + } + } } } - } - } - - Divider() - - Button { - sortOrder = .ascending - } label: { - HStack { - Text("Ascending") - Spacer() - if sortOrder == .ascending { - Image(systemName: "checkmark") + Picker("Order", selection: $sortOrder) { + Text("Ascending").tag(SortOrder.ascending) + Text("Descending").tag(SortOrder.descending) } + .pickerStyle(.segmented) + .listRowSeparator(.hidden) + } header: { + Text("Sort") } } - - Button { - sortOrder = .descending - } label: { - HStack { - Text("Descending") - Spacer() - if sortOrder == .descending { - Image(systemName: "checkmark") + .buttonStyle(.plain) + .listStyle(.insetGrouped) + .navigationTitle("Filter & Sort") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { + showPrefs = false } } } - } label: { - Label("Sort", systemImage: "arrow.up.arrow.down") } - .environment(\.menuOrder, .fixed) + .presentationDragIndicator(.visible) } func pauseAllTorrents() { From 269b90c80aff60c5e5b67948736438519b6274bd Mon Sep 17 00:00:00 2001 From: austin-smith Date: Mon, 9 Mar 2026 20:23:09 -0700 Subject: [PATCH 08/11] Improve server selection menu on iOS Replace the separate serverSelectionMenu with an inline "Servers" section inside the action toolbar menu. Add a ForEach that lists hosts as buttons (showing a checkmark for the current host) and call store.setHost on selection. Also update menu labels to "Add Server" and "Edit Servers" and remove the old Picker-based serverSelectionMenu view. This makes server selection directly accessible from the toolbar. --- BitDream/Views/iOS/iOSContentView.swift | 40 ++++++++++--------------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/BitDream/Views/iOS/iOSContentView.swift b/BitDream/Views/iOS/iOSContentView.swift index cbcccf0..1d3a0e7 100644 --- a/BitDream/Views/iOS/iOSContentView.swift +++ b/BitDream/Views/iOS/iOSContentView.swift @@ -141,14 +141,26 @@ private extension iOSContentView { var actionToolbarItems: some ToolbarContent { ToolbarItemGroup(placement: .automatic) { Menu { - serverSelectionMenu - Divider() Button(action: { store.setup.toggle() }, label: { - Label("Add", systemImage: "plus") + Label("Add Server", systemImage: "plus") }) Button(action: { store.editServers.toggle() }, label: { - Label("Edit", systemImage: "square.and.pencil") + Label("Edit Servers", systemImage: "square.and.pencil") }) + Section("Servers") { + ForEach(hosts, id: \.serverID) { host in + Button { + store.setHost(host: host) + } label: { + Label( + host.name ?? "Unnamed Server", + systemImage: store.host?.serverID == host.serverID + ? "checkmark.circle.fill" + : "circle" + ) + } + } + } } label: { Image(systemName: "server.rack") } @@ -188,26 +200,6 @@ private extension iOSContentView { } } - var serverSelectionMenu: some View { - Menu { - Picker("Server", selection: .init( - get: { store.host }, - set: { host in - if let host { - store.setHost(host: host) - } - } - )) { - ForEach(hosts, id: \.serverID) { host in - Text(host.name ?? "Unnamed Server") - .tag(host as Host?) - } - } - } label: { - Label("Server", systemImage: "arrow.triangle.2.circlepath") - } - } - var prefsPopoverContent: some View { NavigationStack { List { From d78fd2593a2b86c0d530ab54126b8cecbb237c05 Mon Sep 17 00:00:00 2001 From: austin-smith Date: Mon, 9 Mar 2026 20:29:34 -0700 Subject: [PATCH 09/11] improve iOS about view --- .../Views/iOS/Settings/iOSAboutView.swift | 117 +++++++++--------- 1 file changed, 59 insertions(+), 58 deletions(-) diff --git a/BitDream/Views/iOS/Settings/iOSAboutView.swift b/BitDream/Views/iOS/Settings/iOSAboutView.swift index 248ee4a..dc95476 100644 --- a/BitDream/Views/iOS/Settings/iOSAboutView.swift +++ b/BitDream/Views/iOS/Settings/iOSAboutView.swift @@ -14,78 +14,79 @@ struct iOSAboutView: View { } var body: some View { - VStack(spacing: 16) { - Spacer() + ScrollView { + VStack(spacing: 16) { + // App Icon + Image("AppIconPreview-Default") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 6) - // App Icon - Image("AppIconPreview-Default") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 100, height: 100) - .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 6) - - // App Name and Tagline - VStack(spacing: 4) { - Text("BitDream") - .font(.system(size: 24, weight: .bold, design: .monospaced)) - .foregroundStyle(.primary) - - Text("Remote Control for Transmission") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.secondary) - } - - // Description - Text("BitDream is a native and feature-rich remote control client for Transmission web server. It provides a modern interface to manage your Transmission server from anywhere.") - .font(.system(size: 14)) - .foregroundStyle(.primary) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 24) + // App Name and Tagline + VStack(spacing: 4) { + Text("BitDream") + .font(.system(size: 24, weight: .bold, design: .monospaced)) + .foregroundStyle(.primary) - // Version Information - VStack(spacing: 2) { - HStack(spacing: 8) { - Text("Version") - .font(.system(size: 14, weight: .medium)) + Text("Remote Control for Transmission") + .font(.system(size: 15, weight: .medium)) .foregroundStyle(.secondary) - Text(appVersion) - .font(.system(size: 14, weight: .medium, design: .monospaced)) - .foregroundStyle(.primary) } - // Copyright - Text("© \(copyrightYear) Austin Smith") - .font(.system(size: 12)) - .foregroundStyle(.tertiary) - .padding(.top, 12) - } - - // Transmission Acknowledgment - VStack(spacing: 8) { - Divider() + // Description + Text("BitDream is a native and feature-rich remote control client for Transmission web server. It provides a modern interface to manage your Transmission server from anywhere.") + .font(.system(size: 14)) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) .padding(.horizontal, 24) - HStack(spacing: 4) { - Text("Powered by") - .font(.system(size: 14)) + // Version Information + VStack(spacing: 2) { + HStack(spacing: 8) { + Text("Version") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + Text(appVersion) + .font(.system(size: 14, weight: .medium, design: .monospaced)) + .foregroundStyle(.primary) + } + + // Copyright + Text("© \(copyrightYear) Austin Smith") + .font(.system(size: 12)) .foregroundStyle(.tertiary) + .padding(.top, 12) + } - Button("Transmission") { - if let url = URL(string: "https://transmissionbt.com/") { - openURL(url) + // Transmission Acknowledgment + VStack(spacing: 8) { + Divider() + .padding(.horizontal, 24) + + HStack(spacing: 4) { + Text("Powered by") + .font(.system(size: 14)) + .foregroundStyle(.tertiary) + + Button("Transmission") { + if let url = URL(string: "https://transmissionbt.com/") { + openURL(url) + } } + .buttonStyle(.plain) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(themeManager.accentColor) } - .buttonStyle(.plain) - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(themeManager.accentColor) } } - - Spacer() + .frame(maxWidth: .infinity) + .padding(.top, 32) + .padding(.bottom, 40) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .navigationTitle("About") .navigationBarTitleDisplayMode(.inline) } From 59840a22a063e927428ea867a0f53381276f5426 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 9 Mar 2026 22:39:07 -0700 Subject: [PATCH 10/11] Manage supplemental load with replaceLoad task Introduce a managed loading mechanism in TorrentDetailSupplementalStore: add managedLoadTask and managedLoadGeneration, cancel the task on deinit, and add replaceLoad(for:using:onError:) which cancels previous tasks, increments a generation token, and only clears the task if the generation matches. Add a convenience replaceLoad overload that accepts Binding/Binding error handlers. Update iOS and macOS torrent detail views to call replaceLoad instead of spawning Tasks or calling load directly (including adjusting the onChange handler to forward the new torrent ID and replacing onRetry closures). This prevents overlapping loads and ensures only the latest requested load completes and cleans up its task. --- BitDream/Views/Shared/TorrentDetail.swift | 43 +++++++++++++++++++ BitDream/Views/iOS/iOSTorrentDetail.swift | 21 ++++----- BitDream/Views/macOS/macOSTorrentDetail.swift | 4 +- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index f60fe1a..12780fd 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -229,11 +229,17 @@ internal struct TorrentDetailSupplementalState: Sendable { @MainActor internal final class TorrentDetailSupplementalStore: ObservableObject { @Published private(set) var state = TorrentDetailSupplementalState() + private var managedLoadTask: Task? + private var managedLoadGeneration = 0 init(state: TorrentDetailSupplementalState = TorrentDetailSupplementalState()) { self.state = state } + deinit { + managedLoadTask?.cancel() + } + var payload: TorrentDetailSupplementalPayload { state.payload } @@ -250,6 +256,27 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { state.shouldDisplayPayload(for: torrentID) } + func replaceLoad( + for torrentID: Int, + using store: TransmissionStore, + onError: @escaping @MainActor @Sendable (String) -> Void + ) { + managedLoadGeneration += 1 + let generation = managedLoadGeneration + + managedLoadTask?.cancel() + managedLoadTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.load(for: torrentID, using: store, onError: onError) + + guard self.managedLoadGeneration == generation else { + return + } + + self.managedLoadTask = nil + } + } + @discardableResult func applyCommittedFileStatsMutation( _ mutation: TorrentDetailFileStatsMutation, @@ -323,6 +350,22 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { ) } + func replaceLoad( + for torrentID: Int, + using store: TransmissionStore, + showingError: Binding, + errorMessage: Binding + ) { + replaceLoad( + for: torrentID, + using: store, + onError: makeTransmissionBindingErrorHandler( + isPresented: showingError, + message: errorMessage + ) + ) + } + func loadIfIdle( for torrentID: Int, using store: TransmissionStore, diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index 69f876e..556791a 100644 --- a/BitDream/Views/iOS/iOSTorrentDetail.swift +++ b/BitDream/Views/iOS/iOSTorrentDetail.swift @@ -28,10 +28,9 @@ struct iOSTorrentDetail: View { supplementalStore.shouldDisplayPayload(for: torrent.id) } - @MainActor - private func loadSupplementalDetails() async { - await supplementalStore.load( - for: torrent.id, + private func replaceSupplementalLoad(for torrentID: Int) { + supplementalStore.replaceLoad( + for: torrentID, using: store, showingError: $showingError, errorMessage: $errorMessage @@ -55,15 +54,11 @@ struct iOSTorrentDetail: View { peersDestination: peersDestination, onDelete: { showingDeleteConfirmation = true }, onRetryPiecesLoad: { - Task { - await loadSupplementalDetails() - } + replaceSupplementalLoad(for: torrent.id) } ) - .onChange(of: torrent.id, initial: true) { _, _ in - Task { - await loadSupplementalDetails() - } + .onChange(of: torrent.id, initial: true) { _, newTorrentID in + replaceSupplementalLoad(for: newTorrentID) } .toolbar { detailToolbar @@ -215,7 +210,7 @@ struct iOSTorrentDetail: View { unavailableTitle: "Files Unavailable", unavailableMessage: "The latest file details could not be loaded.", onLoadIfIdle: { await supplementalStore.loadIfIdle(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) }, - onRetry: { Task { await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) } } + onRetry: { replaceSupplementalLoad(for: torrent.id) } ) .navigationTitle("Files") .navigationBarTitleDisplayMode(.inline) @@ -243,7 +238,7 @@ struct iOSTorrentDetail: View { unavailableTitle: "Peers Unavailable", unavailableMessage: "The latest peer details could not be loaded.", onLoadIfIdle: { await supplementalStore.loadIfIdle(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) }, - onRetry: { Task { await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) } } + onRetry: { replaceSupplementalLoad(for: torrent.id) } ) .navigationTitle("Peers") .navigationBarTitleDisplayMode(.inline) diff --git a/BitDream/Views/macOS/macOSTorrentDetail.swift b/BitDream/Views/macOS/macOSTorrentDetail.swift index f48f186..b0b5d6f 100644 --- a/BitDream/Views/macOS/macOSTorrentDetail.swift +++ b/BitDream/Views/macOS/macOSTorrentDetail.swift @@ -155,7 +155,7 @@ struct macOSTorrentDetail: View { unavailableTitle: "Files Unavailable", unavailableMessage: "The latest file details could not be loaded.", onLoadIfIdle: { await supplementalStore.loadIfIdle(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) }, - onRetry: { Task { await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) } } + onRetry: { supplementalStore.replaceLoad(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) } ) .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -181,7 +181,7 @@ struct macOSTorrentDetail: View { unavailableTitle: "Peers Unavailable", unavailableMessage: "The latest peer details could not be loaded.", onLoadIfIdle: { await supplementalStore.loadIfIdle(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) }, - onRetry: { Task { await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) } } + onRetry: { supplementalStore.replaceLoad(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) } ) .frame(maxWidth: .infinity, maxHeight: .infinity) } From 005ddd6ab0f855c3a3ad6fc06d998f2020acdee6 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Mon, 9 Mar 2026 23:06:19 -0700 Subject: [PATCH 11/11] use structured operation for torrent load Replace the direct async load with performStructuredTransmissionOperation and track a requestGeneration via mutateState(beginLoading:). On error, mark failure only if the generation matches and call onError; on success, apply the returned TransmissionTorrentDetailSnapshot with a new applyManagedSnapshot helper. Add clearManagedLoadTask(ifMatching:) to only clear the managedLoadTask when the generation matches, and handle cancellation via markCancellation. Overall this centralizes load/error/cancellation handling and keeps generation checks consistent. --- BitDream/Views/Shared/TorrentDetail.swift | 52 ++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/BitDream/Views/Shared/TorrentDetail.swift b/BitDream/Views/Shared/TorrentDetail.swift index 12780fd..3292da1 100644 --- a/BitDream/Views/Shared/TorrentDetail.swift +++ b/BitDream/Views/Shared/TorrentDetail.swift @@ -265,15 +265,35 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { let generation = managedLoadGeneration managedLoadTask?.cancel() - managedLoadTask = Task { @MainActor [weak self] in + let requestGeneration = mutateState { state in + state.beginLoading(for: torrentID) + } + + managedLoadTask = Task { [weak self] in + let snapshot = await performStructuredTransmissionOperation( + operation: { try await store.loadTorrentDetail(id: torrentID) }, + onError: { [weak self] message in + guard let self else { return } + guard self.markFailure(for: torrentID, generation: requestGeneration) else { + return + } + onError(message) + } + ) + guard let self else { return } - await self.load(for: torrentID, using: store, onError: onError) - guard self.managedLoadGeneration == generation else { - return + if let snapshot { + self.applyManagedSnapshot( + snapshot, + for: torrentID, + requestGeneration: requestGeneration + ) + } else { + _ = self.markCancellation(for: torrentID, generation: requestGeneration) } - self.managedLoadTask = nil + self.clearManagedLoadTask(ifMatching: generation) } } @@ -392,6 +412,28 @@ internal final class TorrentDetailSupplementalStore: ObservableObject { mutateState { $0.markCancelled(for: torrentID, generation: generation) } } + private func applyManagedSnapshot( + _ snapshot: TransmissionTorrentDetailSnapshot, + for torrentID: Int, + requestGeneration: Int + ) { + mutateState { state in + _ = state.apply( + snapshot: snapshot, + for: torrentID, + generation: requestGeneration + ) + } + } + + private func clearManagedLoadTask(ifMatching generation: Int) { + guard managedLoadGeneration == generation else { + return + } + + managedLoadTask = nil + } + @discardableResult private func mutateState( _ mutate: (inout TorrentDetailSupplementalState) -> Result