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/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.. 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 { @@ -199,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 } @@ -220,6 +256,47 @@ 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() + 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 } + + if let snapshot { + self.applyManagedSnapshot( + snapshot, + for: torrentID, + requestGeneration: requestGeneration + ) + } else { + _ = self.markCancellation(for: torrentID, generation: requestGeneration) + } + + self.clearManagedLoadTask(ifMatching: generation) + } + } + @discardableResult func applyCommittedFileStatsMutation( _ mutation: TorrentDetailFileStatsMutation, @@ -293,6 +370,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, @@ -319,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 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) } 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 a4cae60..1d3a0e7 100644 --- a/BitDream/Views/iOS/iOSContentView.swift +++ b/BitDream/Views/iOS/iOSContentView.swift @@ -23,12 +23,21 @@ 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 { 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 +61,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) @@ -126,18 +124,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 +141,52 @@ private extension iOSContentView { var actionToolbarItems: some ToolbarContent { ToolbarItemGroup(placement: .automatic) { Menu { - filterMenu - sortMenu - 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 Server", systemImage: "plus") }) - Divider() - Button(action: { - store.showSettings.toggle() - }, label: { - Label("Settings", systemImage: "gear") + Button(action: { store.editServers.toggle() }, label: { + 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: "ellipsis.circle") + Image(systemName: "server.rack") } + + Button(action: { + store.showSettings.toggle() + }, label: { + Image(systemName: "gear") + }) } } var bottomToolbarItems: some ToolbarContent { Group { + ToolbarItem(placement: .bottomBar) { + Button { + showPrefs.toggle() + } label: { + Image(systemName: "slider.horizontal.3") + } + .popover(isPresented: $showPrefs) { + prefsPopoverContent + } + } + + ToolbarSpacer(.flexible, placement: .bottomBar) DefaultToolbarItem(kind: .search, placement: .bottomBar) ToolbarSpacer(.flexible, placement: .bottomBar) @@ -175,106 +194,73 @@ private extension iOSContentView { Button(action: { store.isShowingAddAlert.toggle() }, label: { - Label("Add Torrent", systemImage: "plus") + Image(systemName: "plus") }) - .foregroundStyle(.tint) } } } - var serverSelectionMenu: some View { - Menu { - Picker("Server", selection: .init( - get: { store.host }, - set: { host in - if let host { - store.setHost(host: host) + 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") } - )) { - ForEach(hosts, id: \.serverID) { host in - Text(host.name ?? "Unnamed Server") - .tag(host as Host?) - } - } - } label: { - Label("Server", systemImage: "arrow.triangle.2.circlepath") - } - } - - 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 } - } - } - } label: { - Text("Filter By") - Image(systemName: "slider.horizontal.3") - } - .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() { diff --git a/BitDream/Views/iOS/iOSTorrentDetail.swift b/BitDream/Views/iOS/iOSTorrentDetail.swift index a0ded51..556791a 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 = "" @@ -21,22 +28,40 @@ struct iOSTorrentDetail: View { supplementalStore.shouldDisplayPayload(for: torrent.id) } + private func replaceSupplementalLoad(for torrentID: Int) { + supplementalStore.replaceLoad( + for: torrentID, + using: store, + showingError: $showingError, + errorMessage: $errorMessage + ) + } + 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: { + replaceSupplementalLoad(for: torrent.id) + } ) - .task(id: torrent.id) { - await supplementalStore.load(for: torrent.id, using: store, showingError: $showingError, errorMessage: $errorMessage) + .onChange(of: torrent.id, initial: true) { _, newTorrentID in + replaceSupplementalLoad(for: newTorrentID) } .toolbar { - TorrentDetailToolbar(torrent: torrent, store: store) + detailToolbar } .alert("Delete Torrent", isPresented: $showingDeleteConfirmation) { Button(role: .destructive) { @@ -52,6 +77,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 +100,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], @@ -108,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) @@ -136,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) @@ -148,9 +250,11 @@ private struct IOSTorrentDetailContent Void + let onRetryPiecesLoad: () -> Void var body: some View { NavigationStack { @@ -231,22 +335,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")) { @@ -291,4 +385,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/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 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]>([]) diff --git a/BitDream/Views/macOS/macOSTorrentDetail.swift b/BitDream/Views/macOS/macOSTorrentDetail.swift index f645f22..b0b5d6f 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 @@ -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,44 +181,18 @@ 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) } } } -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(