diff --git a/WordPress/Classes/Services/MediaUploadService.swift b/WordPress/Classes/Services/MediaUploadService.swift index d81676d63822..08b80b99cd45 100644 --- a/WordPress/Classes/Services/MediaUploadService.swift +++ b/WordPress/Classes/Services/MediaUploadService.swift @@ -3,8 +3,20 @@ import WordPressData import WordPressCore import WordPressAPI import WordPressAPIInternal +import UniformTypeIdentifiers + +protocol MediaUploadServiceEventObserver: Sendable { + func handle(event: MediaUploadService.Event, asset: ExportableAsset, service: MediaUploadService) +} actor MediaUploadService { + enum Event: Sendable { + case overallProgress(Progress) + case thumbnail(URL) + } + + nonisolated static let progressUserInfoKeyThumbnail = ProgressUserInfoKey(rawValue: "MediaUploadService.thumbnail") + private let coreDataStack: CoreDataStackSwift private let blog: TaggedManagedObjectID private let client: WordPressClient @@ -21,21 +33,38 @@ actor MediaUploadService { /// - asset: The asset to upload. /// - progress: A progress object to track the upload progress. /// - Returns: The saved Media instance. - func uploadToMediaLibrary(asset: ExportableAsset, fulfilling progress: Progress? = nil) async throws -> TaggedManagedObjectID { - precondition(progress == nil || progress!.totalUnitCount > 0) - - let overallProgress = progress ?? Progress.discreteProgress(totalUnitCount: 100) + func uploadToMediaLibrary(asset: ExportableAsset, observer: MediaUploadServiceEventObserver) async throws -> TaggedManagedObjectID { + let overallProgress = Progress.discreteProgress(totalUnitCount: 100) overallProgress.completedUnitCount = 0 - let export = try await exportAsset(asset, parentProgress: overallProgress) + await MainActor.run { + observer.handle(event: .overallProgress(overallProgress), asset: asset, service: self) + } + + let export = try await exportAsset(asset, parentProgress: overallProgress, withPendingUnitCount: 10) + + try Task.checkCancellation() + + if let thumbnail = await thumbnail(of: export) { + await MainActor.run { + observer.handle(event: .thumbnail(thumbnail), asset: asset, service: self) + } + } let uploadingProgress = Progress.discreteProgress(totalUnitCount: 100) - overallProgress.addChild(uploadingProgress, withPendingUnitCount: Int64((1.0 - overallProgress.fractionCompleted) * Double(overallProgress.totalUnitCount))) - let uploaded = try await client.api.uploadMedia( - params: MediaCreateParams(from: export), - fromLocalFileURL: export.url, - fulfilling: uploadingProgress - ).data + overallProgress.addChild(uploadingProgress, withPendingUnitCount: 90) + + let uploaded = try await withTaskCancellationHandler { + try await client.api.uploadMedia( + params: MediaCreateParams(from: export), + fromLocalFileURL: export.url, + fulfilling: uploadingProgress + ).data + } onCancel: { + uploadingProgress.cancel() + } + + try Task.checkCancellation() let media = try await coreDataStack.performAndSave { [blogID = blog] context in let blog = try context.existingObject(with: blogID) @@ -57,7 +86,7 @@ actor MediaUploadService { private extension MediaUploadService { - func exportAsset(_ exportable: ExportableAsset, parentProgress: Progress) async throws -> MediaExport { + func exportAsset(_ exportable: ExportableAsset, parentProgress: Progress, withPendingUnitCount unitCount: Int64) async throws -> MediaExport { let options = try await coreDataStack.performQuery { [blogID = blog] context in let blog = try context.existingObject(with: blogID) let allowableFileExtensions = blog.allowedFileTypes as? Set ?? [] @@ -79,7 +108,7 @@ private extension MediaUploadService { } ) // The "export" part covers the initial 10% of the overall progress. - parentProgress.addChild(progress, withPendingUnitCount: progress.totalUnitCount / 10) + parentProgress.addChild(progress, withPendingUnitCount: unitCount) } } @@ -113,7 +142,7 @@ private extension MediaUploadService { func configureMedia(_ media: Media, withExport export: MediaExport) { media.absoluteLocalURL = export.url - media.filename = export.url.lastPathComponent + media.filename = export.filename ?? export.url.lastPathComponent media.mediaType = (export.url as NSURL).assetMediaType if let fileSize = export.fileSize { @@ -187,6 +216,25 @@ private extension MediaUploadService { return nil } + func thumbnail(of exported: MediaExport) async -> URL? { + let exporter = MediaThumbnailExporter() + exporter.mediaDirectoryType = .cache + exporter.options.preferredSize = await MediaImageService.getThumbnailSize(for: exported.size ?? CGSizeMake(100, 100), size: .small) + exporter.options.scale = 1 // In pixels + + if exported.url.isGif { + exporter.options.thumbnailImageType = UTType.gif.identifier + } + + guard exporter.supportsThumbnailExport(forFile: exported.url), + let (_, thumbnail) = try? await exporter.exportThumbnail(forFileURL: exported.url) + else { + return nil + } + + return thumbnail.url + } + func updateMedia(_ media: Media, with remote: MediaWithEditContext) { media.mediaID = NSNumber(value: remote.id) media.remoteURL = remote.sourceUrl @@ -245,7 +293,7 @@ private extension MediaCreateParams { dateGmt: nil, slug: nil, status: nil, - title: export.url.lastPathComponent, // TODO: Add a `filename` property to `MediaExport`. + title: export.filename ?? export.url.lastPathComponent, author: nil, commentStatus: nil, pingStatus: nil, diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index d6d2501c6675..379eb3c78e42 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -26,6 +26,7 @@ public enum FeatureFlag: Int, CaseIterable { case pluginManagementOverhaul case nativeJetpackConnection case newsletterSubscribers + case newUploadMedia /// Returns a boolean indicating if the feature is enabled. /// @@ -82,6 +83,8 @@ public enum FeatureFlag: Int, CaseIterable { return BuildConfiguration.current == .debug case .newsletterSubscribers: return true + case .newUploadMedia: + return false } } @@ -125,6 +128,7 @@ extension FeatureFlag { case .readerGutenbergCommentComposer: "Gutenberg Comment Composer" case .nativeJetpackConnection: "Native Jetpack Connection" case .newsletterSubscribers: "Newsletter Subscribers" + case .newUploadMedia: "New Upload Media UI" } } } diff --git a/WordPress/Classes/Utility/Media/ItemProviderMediaExporter.swift b/WordPress/Classes/Utility/Media/ItemProviderMediaExporter.swift index 23b98731888a..c7b388c9f3a9 100644 --- a/WordPress/Classes/Utility/Media/ItemProviderMediaExporter.swift +++ b/WordPress/Classes/Utility/Media/ItemProviderMediaExporter.swift @@ -62,7 +62,7 @@ final class ItemProviderMediaExporter: MediaExporter { try FileManager.default.copyItem(at: original, to: url) let pixelSize = url.pixelSize - let media = MediaExport(url: url, fileSize: url.fileSize, width: pixelSize.width, height: pixelSize.height, duration: nil) + let media = MediaExport(url: url, filename: provider.suggestedName, fileSize: url.fileSize, width: pixelSize.width, height: pixelSize.height, duration: nil) let exportProgress = Progress(totalUnitCount: 1) exportProgress.completedUnitCount = 1 progress.addChild(exportProgress, withPendingUnitCount: MediaExportProgressUnits.halfDone) diff --git a/WordPress/Classes/Utility/Media/MediaExporter.swift b/WordPress/Classes/Utility/Media/MediaExporter.swift index 305ffa755230..74f3aa4976d1 100644 --- a/WordPress/Classes/Utility/Media/MediaExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaExporter.swift @@ -11,8 +11,9 @@ enum MediaExportProgressUnits { /// class MediaExport { /// The resulting file URL of an export. - /// let url: URL + /// The name of the original file. + let filename: String? /// The resulting file size in bytes of the export. let fileSize: Int64? /// The pixel width of the media exported. @@ -24,14 +25,22 @@ class MediaExport { /// A caption to be added to the media item. let caption: String? - init(url: URL, fileSize: Int64?, width: CGFloat?, height: CGFloat?, duration: TimeInterval?, caption: String? = nil) { + init(url: URL, filename: String?, fileSize: Int64?, width: CGFloat?, height: CGFloat?, duration: TimeInterval?, caption: String? = nil) { self.url = url + self.filename = filename self.fileSize = fileSize self.height = height self.width = width self.duration = duration self.caption = caption } + + var size: CGSize? { + if let width, let height { + return CGSizeMake(width, height) + } + return nil + } } /// Completion block with an AssetExport. diff --git a/WordPress/Classes/Utility/Media/MediaExternalExporter.swift b/WordPress/Classes/Utility/Media/MediaExternalExporter.swift index 2e9afef2180d..873f97f63aa0 100644 --- a/WordPress/Classes/Utility/Media/MediaExternalExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaExternalExporter.swift @@ -68,6 +68,7 @@ class MediaExternalExporter: MediaExporter { fileExtension: "gif") try data.write(to: mediaURL) onCompletion(MediaExport(url: mediaURL, + filename: asset.name, fileSize: mediaURL.fileSize, width: mediaURL.pixelSize.width, height: mediaURL.pixelSize.height, diff --git a/WordPress/Classes/Utility/Media/MediaImageExporter.swift b/WordPress/Classes/Utility/Media/MediaImageExporter.swift index 9c2cdbc973d5..d7df5d979432 100644 --- a/WordPress/Classes/Utility/Media/MediaImageExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaImageExporter.swift @@ -228,6 +228,7 @@ class MediaImageExporter: MediaExporter { writer.nullifyGPSData = options.stripsGeoLocationIfNeeded let result = try writer.writeImageSource(source) onCompletion(MediaExport(url: url, + filename: filename, fileSize: url.fileSize, width: result.width, height: result.height, diff --git a/WordPress/Classes/Utility/Media/MediaThumbnailExporter.swift b/WordPress/Classes/Utility/Media/MediaThumbnailExporter.swift index bffb812bb7a3..5f398d4eefe7 100644 --- a/WordPress/Classes/Utility/Media/MediaThumbnailExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaThumbnailExporter.swift @@ -248,6 +248,7 @@ class MediaThumbnailExporter: MediaExporter { try fileManager.moveItem(at: export.url, to: thumbnail) // Configure with the new URL let thumbnailExport = MediaExport(url: thumbnail, + filename: nil, fileSize: export.fileSize, width: export.width, height: export.height, diff --git a/WordPress/Classes/Utility/Media/MediaURLExporter.swift b/WordPress/Classes/Utility/Media/MediaURLExporter.swift index 344854cf77a6..08aadc8d266f 100644 --- a/WordPress/Classes/Utility/Media/MediaURLExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaURLExporter.swift @@ -149,6 +149,7 @@ class MediaURLExporter: MediaExporter { fileExtension: "gif") try fileManager.copyItem(at: url, to: mediaURL) onCompletion(MediaExport(url: mediaURL, + filename: nil, fileSize: mediaURL.fileSize, width: mediaURL.pixelSize.width, height: mediaURL.pixelSize.height, @@ -172,6 +173,7 @@ class MediaURLExporter: MediaExporter { fileExtension: fileExtension) try fileManager.copyItem(at: url, to: mediaURL) onCompletion(MediaExport(url: mediaURL, + filename: nil, fileSize: mediaURL.fileSize, width: nil, height: nil, diff --git a/WordPress/Classes/Utility/Media/MediaVideoExporter.swift b/WordPress/Classes/Utility/Media/MediaVideoExporter.swift index d23a4560ba3d..a31049e4abbe 100644 --- a/WordPress/Classes/Utility/Media/MediaVideoExporter.swift +++ b/WordPress/Classes/Utility/Media/MediaVideoExporter.swift @@ -176,6 +176,7 @@ class MediaVideoExporter: MediaExporter { } progress.completedUnitCount = MediaExportProgressUnits.done onCompletion(MediaExport(url: mediaURL, + filename: filename, fileSize: mediaURL.fileSize, width: mediaURL.pixelSize.width, height: mediaURL.pixelSize.height, diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaAddMediaMenuController.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaAddMediaMenuController.swift index e282a50312cd..b87cc9a348b9 100644 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaAddMediaMenuController.swift +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/Controllers/SiteMediaAddMediaMenuController.swift @@ -1,16 +1,30 @@ import UIKit import Photos import PhotosUI +import WordPressCore import WordPressData import WordPressShared +import SwiftUI final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDelegate, ImagePickerControllerDelegate, ExternalMediaPickerViewDelegate, UIDocumentPickerDelegate, ImagePlaygroundPickerDelegate { let blog: Blog let coordinator: MediaCoordinator + weak var viewController: UIViewController? - init(blog: Blog, coordinator: MediaCoordinator) { + private var mediaUploadService: MediaUploadService? + + init(blog: Blog, coordinator: MediaCoordinator, viewController: UIViewController) { self.blog = blog self.coordinator = coordinator + self.viewController = viewController + + if FeatureFlag.newUploadMedia.enabled, let client = try? WordPressClient(site: WordPressSite(blog: blog)) { + self.mediaUploadService = MediaUploadService( + coreDataStack: ContextManager.shared, + blog: TaggedManagedObjectID(blog), + client: client + ) + } } func makeMenu(for viewController: UIViewController) -> UIMenu { @@ -47,6 +61,18 @@ final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDel .showPhotosPicker(delegate: self) } + private func addMedia(_ assets: [ExportableAsset], analytics: MediaAnalyticsInfo) { + if let mediaUploadService, let viewController { + let mediaUploadingView = MediaUploadingView(mediaUploadService: mediaUploadService, assets: assets) + let hostingController = UIHostingController(rootView: mediaUploadingView) + viewController.present(hostingController, animated: true) + } else { + for asset in assets { + coordinator.addMedia(from: asset, to: blog, analyticsInfo: analytics) + } + } + } + // MARK: - PHPickerViewControllerDelegate func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { @@ -56,10 +82,8 @@ final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDel return } - for result in results { - let info = MediaAnalyticsInfo(origin: .mediaLibrary(.deviceLibrary), selectionMethod: .fullScreenPicker) - coordinator.addMedia(from: result.itemProvider, to: blog, analyticsInfo: info) - } + let assets = results.map { $0.itemProvider } + addMedia(assets, analytics: MediaAnalyticsInfo(origin: .mediaLibrary(.deviceLibrary), selectionMethod: .fullScreenPicker)) } // MARK: - ImagePlaygroundPickerDelegate @@ -67,11 +91,8 @@ final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDel func imagePlaygroundViewController(_ viewController: UIViewController, didCreateImageAt imageURL: URL) { viewController.presentingViewController?.dismiss(animated: true) - coordinator.addMedia( - from: MediaPickerMenu.makeItemProvider(with: imageURL), - to: blog, - analyticsInfo: MediaAnalyticsInfo(origin: .mediaLibrary(.imagePlayground), selectionMethod: .fullScreenPicker) - ) + let asset = MediaPickerMenu.makeItemProvider(with: imageURL) + addMedia([asset], analytics: MediaAnalyticsInfo(origin: .mediaLibrary(.imagePlayground), selectionMethod: .fullScreenPicker)) } // MARK: - ImagePickerControllerDelegate @@ -79,35 +100,37 @@ final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDel func imagePicker(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { picker.presentingViewController?.dismiss(animated: true) - func addAsset(from asset: ExportableAsset) { - let info = MediaAnalyticsInfo(origin: .mediaLibrary(.camera), selectionMethod: .fullScreenPicker) - coordinator.addMedia(from: asset, to: blog, analyticsInfo: info) - } + var assets = [ExportableAsset]() + guard let mediaType = info[.mediaType] as? String else { return } switch mediaType { case UTType.image.identifier: if let image = info[.originalImage] as? UIImage { - addAsset(from: image) + assets.append(image) } case UTType.movie.identifier: if let videoURL = info[.mediaURL] as? URL { - addAsset(from: videoURL as NSURL) + assets.append(videoURL as NSURL) } default: break } + + guard !assets.isEmpty else { return } + + addMedia(assets, analytics: MediaAnalyticsInfo(origin: .mediaLibrary(.camera), selectionMethod: .fullScreenPicker)) } // MARK: - ExternalMediaPickerViewDelegate func externalMediaPickerViewController(_ viewController: ExternalMediaPickerViewController, didFinishWithSelection assets: [ExternalMediaAsset]) { viewController.presentingViewController?.dismiss(animated: true) - for asset in assets { - let info = MediaAnalyticsInfo(origin: .mediaLibrary(viewController.source), selectionMethod: .fullScreenPicker) - coordinator.addMedia(from: asset, to: blog, analyticsInfo: info) + addMedia(assets, analytics: MediaAnalyticsInfo(origin: .mediaLibrary(viewController.source), selectionMethod: .fullScreenPicker)) + + for _ in assets { switch viewController.source { case .stockPhotos: WPAnalytics.track(.stockMediaUploaded) @@ -137,10 +160,7 @@ final class SiteMediaAddMediaMenuController: NSObject, PHPickerViewControllerDel } func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - for documentURL in urls as [NSURL] { - let info = MediaAnalyticsInfo(origin: .mediaLibrary(.otherApps), selectionMethod: .documentPicker) - coordinator.addMedia(from: documentURL, to: blog, analyticsInfo: info) - } + addMedia(urls.map { $0 as NSURL }, analytics: MediaAnalyticsInfo(origin: .mediaLibrary(.otherApps), selectionMethod: .documentPicker)) } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/MediaUploadingView.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/MediaUploadingView.swift new file mode 100644 index 000000000000..9b150ea5c7fe --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/MediaUploadingView.swift @@ -0,0 +1,294 @@ +import Foundation +import SwiftUI +import WordPressData +import Combine + +struct MediaUploadingView: View { + + private let mediaUploadService: MediaUploadService + private let assets: [ExportableAsset] + + @State private var uploadItems: [UploadItemState] = [] + @State private var isUploading = false + @State private var cancellation: AnyCancellable? = nil + + @Environment(\.dismiss) private var dismiss + + init(mediaUploadService: MediaUploadService, assets: [ExportableAsset]) { + self.mediaUploadService = mediaUploadService + self.assets = assets + } + + var body: some View { + NavigationView { + List { + Section { + ForEach(uploadItems.indices, id: \ .self) { index in + AssetUploadRow( + asset: uploadItems[index].asset, + uploadState: uploadItems[index].state, + progress: uploadItems[index].progress, + thumbnailURL: uploadItems[index].thumbnailURL, + onRetry: { + Task { + await uploadAsset(at: index) + } + } + ) + } + } + .listSectionSeparator(.hidden) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + .navigationTitle(Strings.uploadingMediaTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + if isUploading { + Button(Strings.stopUploads, action: cancelAllUploads) + } else if hasUnfinishedUploads { + Button(Strings.restartUploads) { + Task { + await startUploads() + } + } + } + } + ToolbarItem(placement: .navigationBarTrailing) { + if !isUploading { + Button(SharedStrings.Button.done) { + dismiss() + } + } + } + } + } + .interactiveDismissDisabled(isUploading) + .task { + await startUploads() + } + } + + private func startUploads() async { + if self.uploadItems.isEmpty { + self.uploadItems = assets.map { asset in + UploadItemState(asset: asset, state: .notStarted, thumbnailURL: nil) + } + } + + isUploading = true + defer { isUploading = false } + + await withTaskGroup(of: Void.self) { group in + self.cancellation = .init(group.cancelAll) + + for (index, item) in uploadItems.enumerated() where item.state.shouldUpload { + group.addTask { + await uploadAsset(at: index) + } + } + } + + self.cancellation = nil + } + + private func cancelAllUploads() { + guard cancellation != nil else { return } + + cancellation?.cancel() + cancellation = nil + isUploading = false + + for index in uploadItems.indices { + switch uploadItems[index].state { + case .notStarted, .uploading: + uploadItems[index].state = .cancelled + case .success, .failed, .cancelled: + // Do nothing. + break + } + + uploadItems[index].progress?.cancel() + } + } + + private var hasUnfinishedUploads: Bool { + uploadItems.contains { $0.state.shouldUpload } + } + + private func uploadAsset(at index: Int) async { + let asset = uploadItems[index].asset + uploadItems[index].state = .uploading + + do { + let mediaID = try await mediaUploadService.uploadToMediaLibrary(asset: asset, observer: self) + uploadItems[index].state = .success(mediaID: mediaID) + } catch is CancellationError { + uploadItems[index].state = .cancelled + } catch { + uploadItems[index].state = .failed(error: error) + } + } +} + +extension MediaUploadingView: MediaUploadServiceEventObserver { + func handle(event: MediaUploadService.Event, asset: any ExportableAsset, service: MediaUploadService) { + guard let index = uploadItems.firstIndex(where: { $0.asset === asset }) else { + return + } + + switch event { + case let .overallProgress(progress): + uploadItems[index].progress = progress + case let .thumbnail(url): + uploadItems[index].thumbnailURL = url + } + } +} + +private enum UploadState { + case notStarted + case uploading + case success(mediaID: TaggedManagedObjectID) + case failed(error: Error) + case cancelled + + var shouldUpload: Bool { + switch self { + case .notStarted, .failed, .cancelled: + return true + default: + return false + } + } +} + +private struct AssetUploadRow: View { + let asset: ExportableAsset + let uploadState: UploadState + let progress: Progress? + let thumbnailURL: URL? + let onRetry: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 16) { + AsyncImage(url: thumbnailURL) { image in + image.resizable().scaledToFill() + } placeholder: { + Image(systemName: "photo") + .resizable() + .scaledToFit() + .foregroundColor(.secondary) + } + .frame(width: 44, height: 44) + .cornerRadius(8) + .clipped() + + Text(assetName) + .font(.body) + .foregroundColor(.primary) + .lineLimit(2) + + Spacer() + + statusView + .frame(width: 32, height: 32) + } + .padding(.vertical, 8) + } + + // MARK: - Status View (Right Side) + @ViewBuilder + var statusView: some View { + switch uploadState { + case .uploading: + if let progress { + CircularProgressBarView(progress: progress) + } + case .success: + Image(systemName: "checkmark.circle.fill") + .font(.title2) + case .failed: + Button(action: onRetry) { + Image(systemName: "exclamationmark.circle.fill") + .font(.title2) + } + .buttonStyle(.plain) + default: + EmptyView() + } + } + + var assetName: String { + switch asset { + case let url as URL: + return url.lastPathComponent + case let provider as NSItemProvider: + return provider.suggestedName ?? "-" + default: + return "-" + } + } +} + +private struct CircularProgressBarView: View { + let progress: Progress + + @State private var fractionCompleted: Double = 0.0 + + var body: some View { + ZStack { + Circle() + .stroke( + Color.accentColor.opacity(0.5), + lineWidth: 4 + ) + Circle() + .trim(from: 0, to: fractionCompleted) + .stroke( + Color.accentColor, + style: StrokeStyle( + lineWidth: 4, + lineCap: .round + ) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeOut, value: fractionCompleted) + + } + .frame(width: 20, height: 20) + .onAppear { + fractionCompleted = progress.fractionCompleted + } + .onChange(of: progress) { + fractionCompleted = $0.fractionCompleted + } + .onReceive(progress.publisher(for: \.fractionCompleted)) { + self.fractionCompleted = $0 + } + } +} + +// MARK: - Array Extension + +extension Array { + subscript(safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} + +private enum Strings { + static let uploadingMediaTitle = NSLocalizedString("media.uploading.title", value: "Uploading Media", comment: "Title for the media uploading modal") + static let stopUploads = NSLocalizedString("media.uploading.stop", value: "Stop", comment: "Accessibility label for stop uploads button") + static let restartUploads = NSLocalizedString("media.uploading.restart", value: "Restart", comment: "Accessibility label for restart uploads button") + static let uploadComplete = NSLocalizedString("media.uploading.complete", value: "Upload complete", comment: "Subtitle shown when upload is complete") + static let uploadFailed = NSLocalizedString("media.uploading.failed", value: "Upload failed", comment: "Subtitle shown when upload failed") +} + +private struct UploadItemState { + var asset: ExportableAsset + var state: UploadState + var progress: Progress? + var thumbnailURL: URL? +} diff --git a/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaViewController.swift b/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaViewController.swift index 6a471b490790..80f2400c5cf8 100644 --- a/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaViewController.swift +++ b/WordPress/Classes/ViewRelated/Media/SiteMedia/SiteMediaViewController.swift @@ -11,7 +11,7 @@ final class SiteMediaViewController: UIViewController, SiteMediaCollectionViewCo private lazy var collectionViewController = SiteMediaCollectionViewController(blog: blog) private lazy var buttonAddMedia = UIButton(type: .custom) - private lazy var buttonAddMediaMenuController = SiteMediaAddMediaMenuController(blog: blog, coordinator: coordinator) + private lazy var buttonAddMediaMenuController = SiteMediaAddMediaMenuController(blog: blog, coordinator: coordinator, viewController: self) private var buttonFilter: UIButton? private lazy var toolbarItemDelete = UIBarButtonItem(barButtonSystemItem: .trash, target: self, action: #selector(buttonDeleteTapped))