Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions Modules/Sources/WordPressMediaLibrary/Views/BannerView.swift
Original file line number Diff line number Diff line change
@@ -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 ""
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?
Expand All @@ -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) {
Expand All @@ -66,7 +76,8 @@ private struct MediaLibraryContainerView: View {
viewModel: MediaLibraryViewModel(
service: service,
client: client,
tracker: tracker
tracker: tracker,
uploader: uploader
),
service: service
)
Expand Down
133 changes: 129 additions & 4 deletions Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import DesignSystem
import SwiftUI
import UIKit
import WordPressAPI
import WordPressAPIInternal
import WordPressCore
Expand All @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading