From 90df03bffb42b820dfbdacff9ae22bd3c3d687f4 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Mon, 8 Jun 2026 22:47:03 +1200 Subject: [PATCH] Add upload UI and wire the uploader into the grid Toolbar Add menu, sticky in-flight banner, and dedicated Uploads screen with per-item and bulk retry/cancel, plus the Photos/camera picker representables. Wires MediaUploader into the view model and adds the external-picker extension point (option + delegate) the app target plugs into. --- .../Models/ExternalRemoteMedia.swift | 30 +++ .../Preferences/AspectRatioPreference.swift | 1 + .../Views/BannerView.swift | 42 ++++ .../Views/CameraPickerRepresentable.swift | 61 ++++++ .../Views/ExternalMediaPickerSupport.swift | 33 ++++ .../Views/MediaLibraryHostingController.swift | 19 +- .../Views/MediaLibraryView.swift | 133 ++++++++++++- .../Views/MediaLibraryViewModel.swift | 183 +++++++++++++++++- .../Views/PhotosPickerRepresentable.swift | 47 +++++ .../Views/UploadRow.swift | 54 ++++++ .../Views/UploadsView.swift | 85 ++++++++ .../Media/MediaLibraryRouting.swift | 14 +- .../Media/V2/MediaUploadPolicyFactory.swift | 35 ++++ .../Media/V2/MediaUploaderRegistry.swift | 45 +++++ 14 files changed, 771 insertions(+), 11 deletions(-) create mode 100644 Modules/Sources/WordPressMediaLibrary/Models/ExternalRemoteMedia.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/BannerView.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/CameraPickerRepresentable.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/ExternalMediaPickerSupport.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/PhotosPickerRepresentable.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/UploadRow.swift create mode 100644 Modules/Sources/WordPressMediaLibrary/Views/UploadsView.swift create mode 100644 WordPress/Classes/ViewRelated/Media/V2/MediaUploadPolicyFactory.swift create mode 100644 WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift diff --git a/Modules/Sources/WordPressMediaLibrary/Models/ExternalRemoteMedia.swift b/Modules/Sources/WordPressMediaLibrary/Models/ExternalRemoteMedia.swift new file mode 100644 index 000000000000..40022ff1b050 --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Models/ExternalRemoteMedia.swift @@ -0,0 +1,30 @@ +import Foundation +import UniformTypeIdentifiers + +/// Public boundary payload that app-target external pickers (Stock Photos, +/// Tenor) construct and pass through `ExternalMediaPickerDelegate`. The +/// module's view model converts this to `UploadSource.remoteURL(_)` before +/// enqueueing — keeps the internal `UploadSource` enum out of the public API. +public struct ExternalRemoteMedia: Sendable { + public let url: URL + public let suggestedName: String + public let contentType: UTType + public let caption: String? + + public init(url: URL, suggestedName: String, contentType: UTType, caption: String?) { + self.url = url + self.suggestedName = suggestedName + self.contentType = contentType + self.caption = caption + } +} + +/// Narrow enum at the picker delegate boundary. The full `MediaUploadSource` +/// includes values a remote picker should never produce (.photoLibrary, +/// .camera, .otherApps, .imagePlayground); restricting the delegate's +/// parameter to this two-case enum makes wrong analytics attribution a +/// compile error rather than a silent runtime bug. +public enum ExternalRemoteMediaSource: Sendable { + case stockPhotos + case tenor +} diff --git a/Modules/Sources/WordPressMediaLibrary/Preferences/AspectRatioPreference.swift b/Modules/Sources/WordPressMediaLibrary/Preferences/AspectRatioPreference.swift index cc5eb57f77c8..b8c48fd2a88e 100644 --- a/Modules/Sources/WordPressMediaLibrary/Preferences/AspectRatioPreference.swift +++ b/Modules/Sources/WordPressMediaLibrary/Preferences/AspectRatioPreference.swift @@ -12,6 +12,7 @@ import WordPressShared enum AspectRatioPreference { private static let key = UPRUConstants.mediaAspectRatioModeEnabledKey + @MainActor static func load(defaults: UserDefaults = .standard) -> Bool { if let value = defaults.object(forKey: key) as? Bool { return value } return UIDevice.current.userInterfaceIdiom == .pad diff --git a/Modules/Sources/WordPressMediaLibrary/Views/BannerView.swift b/Modules/Sources/WordPressMediaLibrary/Views/BannerView.swift new file mode 100644 index 000000000000..5326ab76f8fd --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/BannerView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct BannerView: View { + let summary: MediaLibraryViewModel.BannerSummary + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + if summary.pendingCount > 0 { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + } + Text(label) + .font(.subheadline) + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(.thinMaterial, in: .rect(cornerRadius: 12)) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private var label: String { + switch (summary.pendingCount, summary.failedCount) { + case (let p, 0) where p > 0: + return String.localizedStringWithFormat(Strings.uploadBannerUploadingOnly, p) + case (let p, let f) where p > 0 && f > 0: + return String.localizedStringWithFormat(Strings.uploadBannerMixed, p, f) + case (0, let f) where f > 0: + return String.localizedStringWithFormat(Strings.uploadBannerFailedOnly, f) + default: + return "" + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/CameraPickerRepresentable.swift b/Modules/Sources/WordPressMediaLibrary/Views/CameraPickerRepresentable.swift new file mode 100644 index 000000000000..89830fd57c5c --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/CameraPickerRepresentable.swift @@ -0,0 +1,61 @@ +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +struct CameraPickerRepresentable: UIViewControllerRepresentable { + enum Mode { case photo, video } + let mode: Mode + let onPicked: (UploadSource) -> Void + let onCancel: () -> Void + + func makeUIViewController(context: Context) -> UIImagePickerController { + let controller = UIImagePickerController() + controller.sourceType = .camera + controller.mediaTypes = [ + mode == .photo ? UTType.image.identifier : UTType.movie.identifier + ] + if mode == .video { + controller.videoQuality = .typeHigh + } + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: CameraPickerRepresentable + + init(parent: CameraPickerRepresentable) { + self.parent = parent + } + + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + picker.dismiss(animated: true) + switch parent.mode { + case .photo: + if let image = info[.originalImage] as? UIImage { + parent.onPicked(.cameraImage(image, capturedAt: Date())) + } else { + parent.onCancel() + } + case .video: + if let url = info[.mediaURL] as? URL { + parent.onPicked(.cameraVideo(url, capturedAt: Date())) + } else { + parent.onCancel() + } + } + } + + func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { + picker.dismiss(animated: true) + parent.onCancel() + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/ExternalMediaPickerSupport.swift b/Modules/Sources/WordPressMediaLibrary/Views/ExternalMediaPickerSupport.swift new file mode 100644 index 000000000000..0c99528a2dcf --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/ExternalMediaPickerSupport.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// Public delegate protocol the app-target picker sheets call into. +/// `MediaLibraryViewModel` conforms internally; the app target only sees +/// this protocol existential via `ExternalMediaPickerOption.sheetContent`. +@MainActor +public protocol ExternalMediaPickerDelegate: AnyObject { + func didPick(remoteMedia: [ExternalRemoteMedia], source: ExternalRemoteMediaSource) + func didPick(imagePlaygroundFile url: URL, suggestedName: String) + func didCancel() +} + +/// Public extension point that `MediaLibraryView`'s add-menu iterates. +/// `MediaLibraryRouting` constructs one of these per external source the +/// app target wants to offer (Stock Photos, Tenor, Image Playground). +public struct ExternalMediaPickerOption: Identifiable { + public let id: String + public let label: String + public let systemImage: String + public let sheetContent: @MainActor (_ delegate: any ExternalMediaPickerDelegate) -> AnyView + + public init( + id: String, + label: String, + systemImage: String, + sheetContent: @escaping @MainActor (_ delegate: any ExternalMediaPickerDelegate) -> AnyView + ) { + self.id = id + self.label = label + self.systemImage = systemImage + self.sheetContent = sheetContent + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift index 0f75e9545ad1..540cbbd496fe 100644 --- a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift @@ -12,9 +12,16 @@ public enum MediaLibraryHostingController { @MainActor public static func make( client: WordPressClient, - tracker: any MediaTracker + tracker: any MediaTracker, + uploader: MediaUploader, + externalPickerOptions: [ExternalMediaPickerOption] = [] ) -> UIViewController { - let view = MediaLibraryContainerView(client: client, tracker: tracker) + let view = MediaLibraryContainerView( + client: client, + tracker: tracker, + uploader: uploader, + externalPickerOptions: externalPickerOptions + ) let host = UIHostingController(rootView: view) host.navigationItem.largeTitleDisplayMode = .never return host @@ -30,6 +37,8 @@ public enum MediaLibraryHostingController { private struct MediaLibraryContainerView: View { let client: WordPressClient let tracker: any MediaTracker + let uploader: MediaUploader + let externalPickerOptions: [ExternalMediaPickerOption] @State private var resolved: Resolved? @State private var error: Error? @@ -48,7 +57,8 @@ private struct MediaLibraryContainerView: View { viewModel: resolved.viewModel, service: resolved.service, client: client, - tracker: tracker + tracker: tracker, + externalPickerOptions: externalPickerOptions ) } else if let error { EmptyStateView.failure(error: error) { @@ -66,7 +76,8 @@ private struct MediaLibraryContainerView: View { viewModel: MediaLibraryViewModel( service: service, client: client, - tracker: tracker + tracker: tracker, + uploader: uploader ), service: service ) diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift index 07664755804a..8fc8d36bee18 100644 --- a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift @@ -1,5 +1,6 @@ import DesignSystem import SwiftUI +import UIKit import WordPressAPI import WordPressAPIInternal import WordPressCore @@ -13,19 +14,35 @@ struct MediaLibraryView: View { let service: WpService let client: WordPressClient let tracker: any MediaTracker + var externalPickerOptions: [ExternalMediaPickerOption] = [] @State private var searchText = "" @State private var isAspectRatioMode = AspectRatioPreference.load() /// Incremented by the Retry button so its `.task(id:)` re-fires; the initial /// value 0 is ignored so we don't double-load on appearance. @State private var retryToken = 0 + @State private var activePicker: ActivePicker? + @State private var isPresentingUploads = false + + private enum ActivePicker: Hashable, Identifiable { + case photoLibrary, takePhoto, takeVideo, chooseFile + case external(id: String) + var id: Self { self } + } var body: some View { ZStack { if searchText.isEmpty { - MediaGridView(items: viewModel.displayItems, isAspectRatioMode: isAspectRatioMode) - .refreshable { await viewModel.refresh() } - .overlay { libraryOverlay } + VStack(spacing: 0) { + if let summary = viewModel.bannerSummary { + BannerView(summary: summary) { + isPresentingUploads = true + } + } + MediaGridView(items: viewModel.displayItems, isAspectRatioMode: isAspectRatioMode) + .refreshable { await viewModel.refresh() } + .overlay { libraryOverlay } + } } else { MediaLibrarySearchView( service: service, @@ -51,7 +68,79 @@ struct MediaLibraryView: View { .minimizedSearchToolbarBehavior() .autocorrectionDisabled() .textInputAutocapitalization(.never) - .toolbar { filterMenu } + .toolbar { + filterMenu + addMenu + } + // `MediaLibraryView` is hosted in a UIKit `UINavigationController` + // via `UIHostingController`, so there's no SwiftUI `NavigationStack` + // ancestor for `.navigationDestination` to push into. Present the + // Uploads queue as a sheet instead — it's a self-contained + // management surface (its own toolbar + bulk menu) and survives + // the SwiftUI/UIKit boundary cleanly. + .sheet(isPresented: $isPresentingUploads) { + NavigationStack { + UploadsView(viewModel: viewModel) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(Strings.uploadsScreenClose) { + isPresentingUploads = false + } + } + } + } + } + .sheet(item: $activePicker) { picker in + switch picker { + case .photoLibrary: + PhotosPickerRepresentable( + onPicked: { sources in + activePicker = nil + Task { await viewModel.enqueue(sources: sources) } + }, + onCancel: { activePicker = nil } + ) + .ignoresSafeArea() + case .takePhoto: + CameraPickerRepresentable( + mode: .photo, + onPicked: { source in + activePicker = nil + Task { await viewModel.enqueue(sources: [source]) } + }, + onCancel: { activePicker = nil } + ) + .ignoresSafeArea() + case .takeVideo: + CameraPickerRepresentable( + mode: .video, + onPicked: { source in + activePicker = nil + Task { await viewModel.enqueue(sources: [source]) } + }, + onCancel: { activePicker = nil } + ) + .ignoresSafeArea() + case .chooseFile: + EmptyView() + .fileImporter( + isPresented: .constant(true), + allowedContentTypes: viewModel.uploader?.filePickerContentTypes ?? [], + allowsMultipleSelection: true, + onCompletion: { result in + activePicker = nil + if case .success(let urls) = result { + let sources = urls.map { UploadSource.file($0) } + Task { await viewModel.enqueue(sources: sources) } + } + } + ) + case .external(let id): + if let option = externalPickerOptions.first(where: { $0.id == id }) { + option.sheetContent(viewModel) + } + } + } } @ToolbarContentBuilder private var filterMenu: some ToolbarContent { @@ -93,6 +182,42 @@ struct MediaLibraryView: View { } } + @ToolbarContentBuilder private var addMenu: some ToolbarContent { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button(Strings.addMenuPhotoLibrary, systemImage: "photo.on.rectangle") { + activePicker = .photoLibrary + } + if UIImagePickerController.isSourceTypeAvailable(.camera) { + Button(Strings.addMenuTakePhoto, systemImage: "camera") { + activePicker = .takePhoto + } + Button(Strings.addMenuTakeVideo, systemImage: "video") { + activePicker = .takeVideo + } + } + Button(Strings.addMenuChooseFile, systemImage: "folder") { + activePicker = .chooseFile + } + if !externalPickerOptions.isEmpty { + Section { + ForEach(externalPickerOptions) { option in + Button(option.label, systemImage: option.systemImage) { + activePicker = .external(id: option.id) + } + } + // TODO: AINFRA-1496 — when the server-side numeric size field + // lands, add a "View Usage" item here that opens + // MediaStorageDetailsView (V1 view, kept alive in the app target). + } + } + } label: { + Image(systemName: "plus") + .accessibilityLabel(Strings.addMenuTitle) + } + } + } + @ViewBuilder private func filterButton(for kind: MediaKind?) -> some View { let title = kind?.title ?? Strings.filterAll let isSelected = kind == viewModel.kind diff --git a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift index f6103ec8333c..1b96e77a3619 100644 --- a/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift +++ b/Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryViewModel.swift @@ -16,6 +16,28 @@ final class MediaLibraryViewModel: ObservableObject { private let tracker: any MediaTracker private let client: WordPressClient private let collection: Collection + let uploader: MediaUploader? + + @Published private(set) var bannerSummary: BannerSummary? + @Published private(set) var uploadsScreenItems: [UploadRowItem] = [] + + private var uploaderObserverTask: Task? + + struct BannerSummary: Equatable { + let pendingCount: Int + let failedCount: Int + } + + struct UploadRowItem: Identifiable, Equatable { + enum Mode: Equatable { + case uploading(Progress) + case failed(message: String, isRetryable: Bool) + } + let id: UUID + let displayName: String + let kind: MediaKind + let mode: Mode + } @Published private(set) var items: [MediaGridItem] = [] /// Stored, derived from `items` + `kind`. Recomputed only in `reload()` and @@ -57,12 +79,15 @@ final class MediaLibraryViewModel: ObservableObject { /// Builds the collection from the wordpress-rs service: the library when /// `search` is nil, a search collection otherwise. `client` is retained so - /// `observe()` can subscribe to the local cache's update stream. + /// `observe()` can subscribe to the local cache's update stream. The + /// `uploader` is wired only for the library instance; search instances + /// leave it nil and never surface the upload banner or queue. init( service: WpService, client: WordPressClient, tracker: any MediaTracker, - search: String? = nil + search: String? = nil, + uploader: MediaUploader? = nil ) { self.tracker = tracker self.client = client @@ -71,6 +96,126 @@ final class MediaLibraryViewModel: ObservableObject { filter: MediaListFilter(search: search, mediaType: nil), perPage: 100 ) + self.uploader = uploader + startUploaderObserver() + } + + /// Subscribes weakly so a navigated-away view model deallocates instead + /// of being kept alive by the stream loop. The publisher replays the + /// current snapshot to the new subscriber before emitting transitions. + private func startUploaderObserver() { + guard let uploader else { return } + let publisher = uploader.statePublisher + uploaderObserverTask = Task { [weak self] in + for await state in publisher.values { + guard !Task.isCancelled else { return } + guard let self else { return } + self.applyUploaderState(state) + } + } + } + + deinit { + uploaderObserverTask?.cancel() + } + + @MainActor + private func applyUploaderState(_ state: UploaderState) { + if state.isEmpty { + bannerSummary = nil + } else { + bannerSummary = BannerSummary( + pendingCount: state.pendingCount, + failedCount: state.failedCount + ) + } + // `state.entries` preserves submission order across pending/failed + // transitions, so the Uploads-screen row stays put when an + // in-flight upload fails (or a failed row is retried). + uploadsScreenItems = state.entries.map { entry in + switch entry { + case .pending(let p): + return UploadRowItem( + id: p.id, + displayName: p.displayName, + kind: p.kind, + mode: .uploading(p.progress) + ) + case .failed(let f): + return UploadRowItem( + id: f.id, + displayName: f.displayName, + kind: f.kind, + mode: .failed(message: f.errorMessage, isRetryable: f.isRetryable) + ) + } + } + } + + func enqueue(sources: [UploadSource], analyticsSource: MediaUploadSource? = nil) async { + guard let uploader else { return } + // INVARIANT: any .remoteURL in sources MUST be enqueued with explicit + // non-nil analyticsSource. .remoteURL carries no provenance — Stock and + // Tenor share the same payload shape, distinguished only by override. + assert( + analyticsSource != nil + || !sources.contains(where: { + if case .remoteURL = $0 { return true } else { return false } + }), + "Remote-URL sources must be enqueued with explicit analyticsSource" + ) + + for source in sources { + let resolvedSource = analyticsSource ?? analyticsSourceFor(source: source) + tracker.track(.mediaLibraryAdded(source: resolvedSource, kind: source.estimatedKind)) + } + await uploader.enqueue(sources: sources) + } + + func cancelUpload(_ id: UUID) async { + await uploader?.cancel(id) + } + + func retryUpload(_ id: UUID) async { + guard let uploader else { return } + tracker.track(.mediaLibraryUploadRetried) + await uploader.retry(id) + } + + func dismissUpload(_ id: UUID) async { + await uploader?.dismiss(id) + } + + func cancelAllUploads() async { await uploader?.cancelAllPending() } + + func retryAllUploads() async { + guard let uploader else { return } + let retryable = uploadsScreenItems.contains { row in + if case .failed(_, let isRetryable) = row.mode { return isRetryable } + return false + } + guard retryable else { return } + tracker.track(.mediaLibraryUploadRetried) + await uploader.retryAllFailed() + } + + func dismissAllUploads() async { await uploader?.dismissAllFailed() } + + private func analyticsSourceFor(source: UploadSource) -> MediaUploadSource { + switch source { + case .photoLibrary: return .photoLibrary + case .cameraImage, .cameraVideo: return .camera + case .file: return .otherApps + case .imagePlayground: return .imagePlayground + case .remoteURL: + // No case-derived value — Stock and Tenor share the same payload, + // distinguished only by explicit override at the enqueue boundary. + // `enqueue(sources:analyticsSource:)` asserts analyticsSource is + // non-nil when .remoteURL is in sources. Returning .otherApps here + // is a fallback if the assertion is bypassed (e.g., a non-debug + // build where assert() compiles away). + return .otherApps + } } // MARK: Filter mutator @@ -154,3 +299,37 @@ final class MediaLibraryViewModel: ObservableObject { } } } + +extension MediaLibraryViewModel: ExternalMediaPickerDelegate { + func didPick(remoteMedia: [ExternalRemoteMedia], source: ExternalRemoteMediaSource) { + let analyticsSource: MediaUploadSource = + switch source { + case .stockPhotos: .stockPhotos + case .tenor: .tenor + } + let sources = remoteMedia.map { media in + UploadSource.remoteURL( + UploadSource.RemoteURL( + url: media.url, + suggestedName: media.suggestedName, + contentType: media.contentType, + caption: media.caption + ) + ) + } + Task { await self.enqueue(sources: sources, analyticsSource: analyticsSource) } + } + + func didPick(imagePlaygroundFile url: URL, suggestedName: String) { + Task { + await self.enqueue( + sources: [.imagePlayground(url, suggestedName: suggestedName)], + analyticsSource: .imagePlayground + ) + } + } + + func didCancel() { + // No-op today; hook exists for future analytics if needed. + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/PhotosPickerRepresentable.swift b/Modules/Sources/WordPressMediaLibrary/Views/PhotosPickerRepresentable.swift new file mode 100644 index 000000000000..332d9b86b24e --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/PhotosPickerRepresentable.swift @@ -0,0 +1,47 @@ +import PhotosUI +import SwiftUI + +struct PhotosPickerRepresentable: UIViewControllerRepresentable { + let onPicked: ([UploadSource]) -> Void + let onCancel: () -> Void + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration(photoLibrary: .shared()) + config.filter = .any(of: [.images, .videos]) + config.selectionLimit = 0 + config.preferredAssetRepresentationMode = .current + let controller = PHPickerViewController(configuration: config) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {} + + func makeCoordinator() -> Coordinator { Coordinator(parent: self) } + + final class Coordinator: NSObject, PHPickerViewControllerDelegate { + let parent: PhotosPickerRepresentable + + init(parent: PhotosPickerRepresentable) { + self.parent = parent + } + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(animated: true) + guard !results.isEmpty else { + parent.onCancel() + return + } + let sources: [UploadSource] = results.map { result in + let provider = result.itemProvider + let suggested = provider.suggestedName + let hintUTI = + provider.registeredContentTypes(conformingTo: .movie).first + ?? provider.registeredContentTypes(conformingTo: .image).first + ?? .item + return .photoLibrary(itemProvider: provider, suggestedName: suggested, hint: hintUTI) + } + parent.onPicked(sources) + } + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/UploadRow.swift b/Modules/Sources/WordPressMediaLibrary/Views/UploadRow.swift new file mode 100644 index 000000000000..2d1d83a4d46d --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/UploadRow.swift @@ -0,0 +1,54 @@ +import SwiftUI + +struct UploadRow: View { + let item: MediaLibraryViewModel.UploadRowItem + let onCancel: () -> Void + let onRetry: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: item.kind.systemImageName) + .font(.title3) + .foregroundStyle(.secondary) + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 4) { + Text(item.displayName) + .font(.subheadline) + .lineLimit(1) + switch item.mode { + case .uploading(let progress): + ProgressView(progress) + .progressViewStyle(.linear) + case .failed(let message, _): + Text(message) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } + } + + switch item.mode { + case .uploading: + Button(action: onCancel) { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + case .failed(_, let isRetryable): + HStack { + if isRetryable { + Button(Strings.uploadActionRetry, action: onRetry) + } + Button(Strings.uploadActionDismiss, action: onDismiss) + .tint(.secondary) + } + .buttonStyle(.bordered) + .controlSize(.small) + } + } + .padding(.vertical, 8) + } +} diff --git a/Modules/Sources/WordPressMediaLibrary/Views/UploadsView.swift b/Modules/Sources/WordPressMediaLibrary/Views/UploadsView.swift new file mode 100644 index 000000000000..40321d2ef0cc --- /dev/null +++ b/Modules/Sources/WordPressMediaLibrary/Views/UploadsView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +struct UploadsView: View { + @ObservedObject var viewModel: MediaLibraryViewModel + @State private var isConfirmingCancelAll = false + + var body: some View { + Group { + if viewModel.uploadsScreenItems.isEmpty { + ContentUnavailableView { + Label(Strings.uploadsScreenAllDone, systemImage: "checkmark.circle") + } + } else { + List(viewModel.uploadsScreenItems) { item in + UploadRow( + item: item, + onCancel: { Task { await viewModel.cancelUpload(item.id) } }, + onRetry: { Task { await viewModel.retryUpload(item.id) } }, + onDismiss: { Task { await viewModel.dismissUpload(item.id) } } + ) + } + .listStyle(.plain) + } + } + .navigationTitle(Strings.uploadsScreenTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + bulkMenu + } + } + .alert(Strings.uploadBulkCancelAll, isPresented: $isConfirmingCancelAll) { + Button(Strings.uploadBulkCancelAllConfirm, role: .destructive) { + Task { await viewModel.cancelAllUploads() } + } + Button(Strings.cancel, role: .cancel) {} + } message: { + Text(Strings.uploadBulkCancelAllMessage) + } + } + + @ViewBuilder private var bulkMenu: some View { + if hasAnyBulkAction { + Menu { + if hasRetryableFailed { + Button(Strings.uploadBulkRetryAll) { + Task { await viewModel.retryAllUploads() } + } + } + if hasFailed { + Button(Strings.uploadBulkDismissAll, role: .destructive) { + Task { await viewModel.dismissAllUploads() } + } + } + if hasUploading { + Button(Strings.uploadBulkCancelAll, role: .destructive) { + isConfirmingCancelAll = true + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + + private var hasUploading: Bool { + viewModel.uploadsScreenItems.contains { row in + if case .uploading = row.mode { return true } + return false + } + } + private var hasFailed: Bool { + viewModel.uploadsScreenItems.contains { row in + if case .failed = row.mode { return true } + return false + } + } + private var hasRetryableFailed: Bool { + viewModel.uploadsScreenItems.contains { row in + if case .failed(_, let isRetryable) = row.mode { return isRetryable } + return false + } + } + private var hasAnyBulkAction: Bool { hasUploading || hasFailed } +} diff --git a/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift b/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift index fe5e81cad7aa..7f4a8b10c96f 100644 --- a/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift +++ b/WordPress/Classes/ViewRelated/Media/MediaLibraryRouting.swift @@ -29,6 +29,18 @@ enum MediaLibraryRouting { properties["is_v2"] = "1" let tracker = MediaTrackerAdapter(blog: blog, baseProperties: properties) - return MediaLibraryHostingController.make(client: client, tracker: tracker) + let uploader: MediaUploader + do { + uploader = try MediaUploaderRegistry.shared.uploader(for: blog) + } catch { + Loggers.app.error("Failed to vend uploader: \(error)") + return nil + } + + return MediaLibraryHostingController.make( + client: client, + tracker: tracker, + uploader: uploader + ) } } diff --git a/WordPress/Classes/ViewRelated/Media/V2/MediaUploadPolicyFactory.swift b/WordPress/Classes/ViewRelated/Media/V2/MediaUploadPolicyFactory.swift new file mode 100644 index 000000000000..e0c23f775aba --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/V2/MediaUploadPolicyFactory.swift @@ -0,0 +1,35 @@ +import Foundation +import UniformTypeIdentifiers +import WordPressData +import WordPressMediaLibrary + +@MainActor +enum MediaUploadPolicyFactory { + static func make(from blog: Blog) -> MediaUploadPolicy { + let pickerTypes = blog.allowedTypeIdentifiers.compactMap { UTType($0) } + + let serverAllowed: Set = blog.allowedFileTypes + let defaultAllowed = MediaImportService.defaultAllowableFileExtensions + + let mediaSettings = MediaSettings() + let configuredMaxDim = mediaSettings.imageSizeForUpload + let imageMax: Int? = configuredMaxDim < Int.max ? configuredMaxDim : nil + + return MediaUploadPolicy( + filePickerContentTypes: pickerTypes, + isAllowedForUpload: { _, fileExtension in + let ext = fileExtension.lowercased() + if defaultAllowed.contains(ext) { return true } + if serverAllowed.isEmpty { return true } + return serverAllowed.contains(ext) + }, + imageMaxDimension: imageMax, + imageJpegQuality: mediaSettings.imageQualityForUpload.doubleValue, + convertHEICToJPEG: true, + videoMaxDurationSeconds: blog.videoDurationLimit, + videoExportPreset: mediaSettings.maxVideoSizeSetting.videoPreset, + videoOutputContentType: .mpeg4Movie, + stripImageLocation: mediaSettings.removeLocationSetting + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift b/WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift new file mode 100644 index 000000000000..eff67421c4b0 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Media/V2/MediaUploaderRegistry.swift @@ -0,0 +1,45 @@ +import Foundation +import WordPressCore +import WordPressData +import WordPressMediaLibrary + +@MainActor +final class MediaUploaderRegistry { + static let shared = MediaUploaderRegistry() + + private var uploaders: [TaggedManagedObjectID: MediaUploader] = [:] + private let clientFactory: WordPressClientFactory + + init(clientFactory: WordPressClientFactory = .shared) { + self.clientFactory = clientFactory + } + + func uploader(for blog: Blog) throws -> MediaUploader { + let id = TaggedManagedObjectID(blog) + if let existing = uploaders[id] { return existing } + + let site = try WordPressSite(blog: blog) + let client = clientFactory.instance(for: site) + let policy = MediaUploadPolicyFactory.make(from: blog) + let uploader = MediaUploader(client: client, policy: policy) + uploaders[id] = uploader + return uploader + } + + /// Removal call sites should derive the `TaggedManagedObjectID` from + /// the `Blog` while still on its managed object context, then pass it + /// here. Capturing the `Blog` itself across the launched `Task`'s + /// async boundary risks resolving a deleted-or-wrong-context object. + func tearDown(blogID: TaggedManagedObjectID) async { + guard let uploader = uploaders.removeValue(forKey: blogID) else { return } + await uploader.tearDown() + } + + func tearDownAll() async { + let snapshot = uploaders + uploaders.removeAll() + for (_, uploader) in snapshot { + await uploader.tearDown() + } + } +}