diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index e883662fec..8721ff73b2 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -6237,7 +6237,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6305,7 +6305,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NKUJUXUJ3B; diff --git a/iOSClient/Data/NCManageDatabase+Metadata.swift b/iOSClient/Data/NCManageDatabase+Metadata.swift index 0135c4dc62..00fbf11c8b 100644 --- a/iOSClient/Data/NCManageDatabase+Metadata.swift +++ b/iOSClient/Data/NCManageDatabase+Metadata.swift @@ -1467,23 +1467,39 @@ extension NCManageDatabase { } } - func getTransferAsync(tranfersSuccess: [tableMetadata]) async -> [tableMetadata] { + func getTransferAsync(tranfersSuccess: [tableMetadata], status: [Int], offset: Int, limit: Int) async -> (metadatas: [tableMetadata], inWaiting: Int, inProgress: Int, inError: Int) { await core.performRealmReadAsync { realm in - let predicate = NSPredicate(format: "status IN %@", NCGlobal.shared.metadataStatusTransfers) + let allTransfers = realm.objects(tableMetadata.self) + .filter("status != 0") + + let excludedIds = Set(tranfersSuccess.compactMap(\.ocIdTransfer)) + + let inWaiting = allTransfers.filter("status IN %@", NCGlobal.shared.metadatasStatusInWaiting).count + let inProgress = allTransfers.filter("status IN %@", NCGlobal.shared.metadatasStatusDownloadingUploading).count + let inError = allTransfers.filter("status IN %@", NCGlobal.shared.metadatasStatusInError).count + let sortDescriptors = [ RealmSwift.SortDescriptor(keyPath: "status", ascending: false), - RealmSwift.SortDescriptor(keyPath: "sessionDate", ascending: true) + RealmSwift.SortDescriptor(keyPath: "sessionDate", ascending: true), + RealmSwift.SortDescriptor(keyPath: "ocId", ascending: true) ] - let results = realm.objects(tableMetadata.self) - .filter(predicate) + let results = allTransfers + .filter("status IN %@", status) .sorted(by: sortDescriptors) + .filter { !excludedIds.contains($0.ocIdTransfer) } - let excludedIds = Set(tranfersSuccess.compactMap { $0.ocIdTransfer }) - let filtered = results.filter { !excludedIds.contains($0.ocIdTransfer) } + let startIndex = min(offset, results.count) + let endIndex = min(startIndex + limit, results.count) + let metadatas = results[startIndex.. Int { diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index 32d28416d8..76162ca9d4 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -231,9 +231,9 @@ final class NCGlobal: Sendable { let metadataStatusForScreenAwake = [-1, -2, 1, 2] let metadataStatusHideInView = [1, 2, 3, 11] let metadataStatusWaitWebDav = [10, 11, 12, 13, 14, 15] - let metadataStatusTransfers = [-2, -3, 2, 3, 10, 11, 12, 13, 14, 15] let metadatasStatusInWaiting = [-1, 1, 10, 11, 12, 13, 14, 15] + let metadatasStatusInError = [-3, 3] let metadatasStatusInWaitingDownloadUpload = [-1, 1] let metadatasStatusDownloadingUploading = [-2, 2] diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 9cb813b125..6b648fd281 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -435,9 +435,15 @@ "_remote_" = "Remote"; "_remote_group_" = "Remote group"; "_conversation_" = "Conversation"; - -"_no_transfer_" = "No transfers yet"; -"_no_transfer_sub_" = "Uploads and downloads from this device will show up here"; +"_no_transfer_" = "No transfers yet"; +"_no_transfer_sub_" = "Uploads and downloads from this device will show up here"; +"_no_transfer_in_progress_" = "No transfers in progress"; +"_no_transfer_in_progress_sub_" = "Active uploads and downloads from this device will appear here."; +"_no_transfer_in_waiting_" = "No transfers waiting"; +"_no_transfer_in_waiting_sub_" = "Transfers waiting to start will appear here."; +"_no_transfer_in_error_" = "No transfer errors"; +"_no_transfer_in_error_sub_" = "Transfers that need your attention will appear here."; +"_transfers_waiting_shown_" = "%1$d of %2$d transfers waiting"; "_no_activity_footer_" = "No more activities to load"; "_transfers_" = "Transfers"; "_activity_" = "Activity"; diff --git a/iOSClient/Transfers/NCTransfersModel.swift b/iOSClient/Transfers/NCTransfersModel.swift index 61249cc232..d190184073 100644 --- a/iOSClient/Transfers/NCTransfersModel.swift +++ b/iOSClient/Transfers/NCTransfersModel.swift @@ -5,15 +5,36 @@ import Foundation import NextcloudKit +internal enum TransfersFilter: Sendable { + case waiting + case progress + case error + + var statuses: [Int] { + switch self { + case .waiting: + return NCGlobal.shared.metadatasStatusInWaiting + case .progress: + return NCGlobal.shared.metadatasStatusDownloadingUploading + case .error: + return NCGlobal.shared.metadatasStatusInError + } + } +} + final class TransfersViewModel: ObservableObject, NCMetadataTransfersSuccessDelegate { @Published var metadatas: [tableMetadata] = [] @Published var progressMap: [String: Float] = [:] - @Published var isLoading = false @Published var showFlushMessage = false @Published var inWaitingCount = 0 @Published var inProgressCount = 0 @Published var inErrorCount = 0 + @Published private(set) var selectedFilter: TransfersFilter = .progress + + private var isLoadingTransfers = false + private let transfersLimit = 100 + // Dependencies private let session: NCSession.Session private let database = NCManageDatabase.shared @@ -47,34 +68,45 @@ final class TransfersViewModel: ObservableObject, NCMetadataTransfersSuccessDele func pollTransfers() async { while !Task.isCancelled { if !isXcodeRunningForPreviews { - isLoading = true - - // Items - let transfersSuccess = await networking.metadataTranfersSuccess.getAll() - let results = await database.getTransferAsync(tranfersSuccess: transfersSuccess) - metadatas = results.filter { - self.global.metadataStatusTransfers.contains($0.status) - } + await loadTransfers() + } + try? await Task.sleep(for: .seconds(0.5)) + } + } - // inWaitingCount - let countTransfersSuccess = await NCNetworking.shared.metadataTranfersSuccess.count() - let countWaiting = await NCManageDatabase.shared.getMetadatasStatusCountAsync(status: NCGlobal.shared.metadatasStatusInWaiting) - inWaitingCount = max(0, countWaiting - countTransfersSuccess) + @MainActor + func selectFilter(_ filter: TransfersFilter) async { + guard selectedFilter != filter else { + return + } - // inProgressCount - inProgressCount = metadatas.compactMap(\.status) - .filter { NCGlobal.shared.metadatasStatusDownloadingUploading.contains($0) } - .count + selectedFilter = filter + await loadTransfers() + } - // inErrorCount - inErrorCount = metadatas.compactMap(\.errorCode) - .filter { $0 != 0 } - .count + @MainActor + private func loadTransfers() async { + guard !isLoadingTransfers else { + return + } - isLoading = false - } - try? await Task.sleep(for: .seconds(0.5)) + isLoadingTransfers = true + defer { + isLoadingTransfers = false } + + let transfersSuccess = await networking.metadataTranfersSuccess.getAll() + let result = await database.getTransferAsync( + tranfersSuccess: transfersSuccess, + status: selectedFilter.statuses, + offset: 0, + limit: transfersLimit + ) + + metadatas = result.metadatas + inWaitingCount = result.inWaiting + inProgressCount = result.inProgress + inErrorCount = result.inError } func cancel(item: tableMetadata) async { diff --git a/iOSClient/Transfers/NCTransfersView.swift b/iOSClient/Transfers/NCTransfersView.swift index 4b3c9a73ee..9c8da7dcce 100644 --- a/iOSClient/Transfers/NCTransfersView.swift +++ b/iOSClient/Transfers/NCTransfersView.swift @@ -7,7 +7,6 @@ import SwiftUI // MARK: - Main View struct TransfersView: View { - @Environment(\.dismiss) private var dismiss @StateObject private var model: TransfersViewModel private let onClose: (() -> Void)? @@ -34,9 +33,7 @@ struct TransfersView: View { .toolbar { ToolbarItem(placement: .cancellationAction) { Button("_close_") { - if let onClose { - onClose() - } + onClose?() } } } @@ -47,16 +44,86 @@ struct TransfersView: View { .presentationDetents([.medium, .large]) } + private var emptyFilterTitle: String { + switch model.selectedFilter { + case .progress: + return NSLocalizedString("_no_transfer_in_progress_", comment: "") + case .waiting: + return NSLocalizedString("_no_transfer_in_waiting_", comment: "") + case .error: + return NSLocalizedString("_no_transfer_in_error_", comment: "") + } + } + + private var emptyFilterDescription: String { + switch model.selectedFilter { + case .progress: + return NSLocalizedString("_no_transfer_in_progress_sub_", comment: "") + case .waiting: + return NSLocalizedString("_no_transfer_in_waiting_sub_", comment: "") + case .error: + return NSLocalizedString("_no_transfer_in_error_sub_", comment: "") + } + } + + private var emptyFilterSymbol: String { + switch model.selectedFilter { + case .progress: + return "arrow.up.arrow.down.circle" + case .waiting: + return "arrow.triangle.2.circlepath" + case .error: + return "exclamationmark.triangle" + } + } + @ViewBuilder private var contentView: some View { - if model.showFlushMessage || (model.metadatas.isEmpty && model.inWaitingCount == 0) { + if model.showFlushMessage || ( + model.inWaitingCount == 0 && + model.inProgressCount == 0 && + model.inErrorCount == 0 + ) { EmptyTransfersView(model: model) + } else if model.metadatas.isEmpty { + VStack(spacing: 0) { + TransfersSummaryHeader( + selectedFilter: model.selectedFilter, + inWaitingCount: model.inWaitingCount, + inProgressCount: model.inProgressCount, + inErrorCount: model.inErrorCount, + onSelect: { filter in + Task { + await model.selectFilter(filter) + } + } + ) + .font(.headline) + .padding(.horizontal, 15) + .padding(.vertical, 6) + + Spacer() + + ContentUnavailableView( + emptyFilterTitle, + systemImage: emptyFilterSymbol, + description: Text(emptyFilterDescription) + ) + + Spacer() + } } else { List { Section(header: TransfersSummaryHeader( + selectedFilter: model.selectedFilter, inWaitingCount: model.inWaitingCount, inProgressCount: model.inProgressCount, - inErrorCount: model.inErrorCount + inErrorCount: model.inErrorCount, + onSelect: { filter in + Task { + await model.selectFilter(filter) + } + } ).font(.headline)) { ForEach(model.metadatas, id: \.ocId) { item in TransferRowView(model: model, item: item) { @@ -65,6 +132,22 @@ struct TransfersView: View { .listRowInsets(EdgeInsets()) .listRowSeparator(.hidden) } + + if model.selectedFilter == .waiting, + model.inWaitingCount > model.metadatas.count { + Text( + String( + format: NSLocalizedString("_transfers_waiting_shown_", comment: ""), + model.metadatas.count, + model.inWaitingCount + ) + ) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 12) + .listRowSeparator(.hidden) + } } } .listStyle(.plain) @@ -75,32 +158,62 @@ struct TransfersView: View { // MARK: - Summary Header struct TransfersSummaryHeader: View { + let selectedFilter: TransfersFilter let inWaitingCount: Int let inProgressCount: Int let inErrorCount: Int + let onSelect: (TransfersFilter) -> Void var body: some View { HStack(spacing: 8) { - summaryPill(title: "_in_waiting_", value: inWaitingCount) - summaryPill(title: "_in_progress_", value: inProgressCount) - summaryPill(title: "_in_error_", value: inErrorCount) + summaryButton( + title: "_in_progress_", + value: inProgressCount, + filter: .progress + ) + + summaryButton( + title: "_in_waiting_", + value: inWaitingCount, + filter: .waiting + ) + + summaryButton( + title: "_in_error_", + value: inErrorCount, + filter: .error + ) + Spacer() } .padding(.vertical, 6) } - private func summaryPill(title: String, value: Int) -> some View { - HStack(spacing: 6) { - Text(NSLocalizedString(title, comment: "")) - .font(.caption) - .foregroundStyle(.secondary) - Text("\(value)") - .font(.caption) - .fontWeight(.semibold) + private func summaryButton( + title: String, + value: Int, + filter: TransfersFilter + ) -> some View { + Button { + onSelect(filter) + } label: { + HStack(spacing: 6) { + Text(NSLocalizedString(title, comment: "")) + .font(.caption) + + Text("\(value)") + .font(.caption) + .fontWeight(.semibold) + } + .foregroundStyle(selectedFilter == filter ? .primary : .secondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + selectedFilter == filter ? AnyShapeStyle(.ultraThinMaterial) : AnyShapeStyle(.clear), + in: Capsule() + ) } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.ultraThinMaterial, in: Capsule()) + .buttonStyle(.plain) } } @@ -137,14 +250,8 @@ struct EmptyTransfersView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .task(id: model.showFlushMessage) { - if model.showFlushMessage { - withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) { - flash = true - } - } else { - withAnimation(.easeInOut(duration: 0.25)) { - flash = false - } + withAnimation(flash ? .easeInOut(duration: 0.25) : .spring(response: 0.35, dampingFraction: 0.82)) { + flash = model.showFlushMessage } } } @@ -167,7 +274,8 @@ struct TransferRowView: View { .font(.icon(30)) VStack(alignment: .leading, spacing: 6) { - Text(item.fileName).font(.headline) + Text(item.fileName) + .font(.headline) if !status.status.isEmpty { Text(status.status) @@ -198,10 +306,7 @@ struct TransferRowView: View { } label: { ZStack { Circle() - .stroke( - Color.gray.opacity(0.2), - lineWidth: 2 - ) + .stroke(Color.gray.opacity(0.2), lineWidth: 2) .frame(width: 36, height: 36) Circle() @@ -213,6 +318,7 @@ struct TransferRowView: View { .rotationEffect(.degrees(-90)) .frame(width: 36, height: 36) .animation(.easeInOut(duration: 0.25), value: model.progress(for: item)) + Image(systemName: "stop.fill") .font(.icon(14, weight: .bold)) .foregroundStyle(.primary) @@ -222,6 +328,7 @@ struct TransferRowView: View { .accessibilityLabel(NSLocalizedString("_cancel_", comment: "")) } .contentShape(Rectangle()) + Divider() } .padding(.horizontal, 15) @@ -235,9 +342,11 @@ struct TransfersView_Previews: PreviewProvider { static var previews: some View { let metadatas: [tableMetadata] = [ tableMetadata(ocId: "1", fileName: "filename 1", status: NCGlobal.shared.metadataStatusWaitCreateFolder), - tableMetadata(ocId: "2", fileName: "filename 2", size: 7230000, status: NCGlobal.shared.metadataStatusUploading), - tableMetadata(ocId: "3", fileName: "filename 3", size: 5230000, status: NCGlobal.shared.metadataStatusDownloading), - tableMetadata(ocId: "4", fileName: "filename 4", size: 7230000, status: NCGlobal.shared.metadataStatusUploadError, sessionError: "Disk full Disk full Disk full Disk full Disk full Disk full Disk full Disk full", errorCode: 1)] + tableMetadata(ocId: "2", fileName: "filename 2", size: 7_230_000, status: NCGlobal.shared.metadataStatusUploading), + tableMetadata(ocId: "3", fileName: "filename 3", size: 5_230_000, status: NCGlobal.shared.metadataStatusDownloading), + tableMetadata(ocId: "4", fileName: "filename 4", size: 7_230_000, status: NCGlobal.shared.metadataStatusUploadError, sessionError: "Disk full Disk full Disk full Disk full Disk full Disk full Disk full Disk full", errorCode: 1) + ] + return TransfersView(previewMetadatas: metadatas) .previewDisplayName("Transfers – Preview Items") }