Skip to content

Commit 362fcbe

Browse files
committed
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.
1 parent 8f96db5 commit 362fcbe

14 files changed

Lines changed: 737 additions & 11 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import Foundation
2+
import UniformTypeIdentifiers
3+
4+
/// Public boundary payload that app-target external pickers (Stock Photos)
5+
/// construct and pass through `ExternalMediaPickerDelegate`. The
6+
/// module's view model converts this to `UploadSource.remoteURL(_)` before
7+
/// enqueueing — keeps the internal `UploadSource` enum out of the public API.
8+
public struct ExternalRemoteMedia: Sendable {
9+
public let url: URL
10+
public let suggestedName: String
11+
public let contentType: UTType
12+
public let caption: String?
13+
14+
public init(url: URL, suggestedName: String, contentType: UTType, caption: String?) {
15+
self.url = url
16+
self.suggestedName = suggestedName
17+
self.contentType = contentType
18+
self.caption = caption
19+
}
20+
}

Modules/Sources/WordPressMediaLibrary/Preferences/AspectRatioPreference.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import WordPressShared
1212
enum AspectRatioPreference {
1313
private static let key = UPRUConstants.mediaAspectRatioModeEnabledKey
1414

15+
@MainActor
1516
static func load(defaults: UserDefaults = .standard) -> Bool {
1617
if let value = defaults.object(forKey: key) as? Bool { return value }
1718
return UIDevice.current.userInterfaceIdiom == .pad
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import SwiftUI
2+
3+
struct BannerView: View {
4+
let summary: MediaLibraryViewModel.BannerSummary
5+
let onTap: () -> Void
6+
7+
var body: some View {
8+
Button(action: onTap) {
9+
HStack(spacing: 12) {
10+
if summary.pendingCount > 0 {
11+
ProgressView()
12+
.progressViewStyle(.circular)
13+
.controlSize(.small)
14+
}
15+
Text(label)
16+
.font(.subheadline)
17+
Spacer()
18+
Image(systemName: "chevron.right")
19+
.foregroundStyle(.tertiary)
20+
}
21+
.padding(.horizontal, 16)
22+
.padding(.vertical, 10)
23+
.background(.thinMaterial, in: .rect(cornerRadius: 12))
24+
}
25+
.buttonStyle(.plain)
26+
.padding(.horizontal, 16)
27+
.padding(.vertical, 8)
28+
}
29+
30+
private var label: String {
31+
switch (summary.pendingCount, summary.failedCount) {
32+
case (let p, 0) where p > 0:
33+
return String.localizedStringWithFormat(Strings.uploadBannerUploadingOnly, p)
34+
case (let p, let f) where p > 0 && f > 0:
35+
return String.localizedStringWithFormat(Strings.uploadBannerMixed, p, f)
36+
case (0, let f) where f > 0:
37+
return String.localizedStringWithFormat(Strings.uploadBannerFailedOnly, f)
38+
default:
39+
return ""
40+
}
41+
}
42+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import SwiftUI
2+
import UIKit
3+
import UniformTypeIdentifiers
4+
5+
struct CameraPickerRepresentable: UIViewControllerRepresentable {
6+
enum Mode { case photo, video }
7+
let mode: Mode
8+
let onPicked: (UploadSource) -> Void
9+
let onCancel: () -> Void
10+
11+
func makeUIViewController(context: Context) -> UIImagePickerController {
12+
let controller = UIImagePickerController()
13+
controller.sourceType = .camera
14+
controller.mediaTypes = [
15+
mode == .photo ? UTType.image.identifier : UTType.movie.identifier
16+
]
17+
if mode == .video {
18+
controller.videoQuality = .typeHigh
19+
}
20+
controller.delegate = context.coordinator
21+
return controller
22+
}
23+
24+
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
25+
26+
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
27+
28+
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
29+
let parent: CameraPickerRepresentable
30+
31+
init(parent: CameraPickerRepresentable) {
32+
self.parent = parent
33+
}
34+
35+
func imagePickerController(
36+
_ picker: UIImagePickerController,
37+
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
38+
) {
39+
picker.dismiss(animated: true)
40+
switch parent.mode {
41+
case .photo:
42+
if let image = info[.originalImage] as? UIImage {
43+
parent.onPicked(.cameraImage(image, capturedAt: Date()))
44+
} else {
45+
parent.onCancel()
46+
}
47+
case .video:
48+
if let url = info[.mediaURL] as? URL {
49+
parent.onPicked(.cameraVideo(url, capturedAt: Date()))
50+
} else {
51+
parent.onCancel()
52+
}
53+
}
54+
}
55+
56+
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
57+
picker.dismiss(animated: true)
58+
parent.onCancel()
59+
}
60+
}
61+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import SwiftUI
2+
3+
/// Public delegate protocol the app-target picker sheets call into.
4+
/// `MediaLibraryViewModel` conforms internally; the app target only sees
5+
/// this protocol existential via `ExternalMediaPickerOption.sheetContent`.
6+
@MainActor
7+
public protocol ExternalMediaPickerDelegate: AnyObject {
8+
func didPick(remoteMedia: [ExternalRemoteMedia])
9+
func didPick(imagePlaygroundFile url: URL, suggestedName: String)
10+
func didCancel()
11+
}
12+
13+
/// Public extension point that `MediaLibraryView`'s add-menu iterates.
14+
/// `MediaLibraryRouting` constructs one of these per external source the
15+
/// app target wants to offer (Stock Photos, Image Playground).
16+
public struct ExternalMediaPickerOption: Identifiable {
17+
public let id: String
18+
public let label: String
19+
public let systemImage: String
20+
public let sheetContent: @MainActor (_ delegate: any ExternalMediaPickerDelegate) -> AnyView
21+
22+
public init(
23+
id: String,
24+
label: String,
25+
systemImage: String,
26+
sheetContent: @escaping @MainActor (_ delegate: any ExternalMediaPickerDelegate) -> AnyView
27+
) {
28+
self.id = id
29+
self.label = label
30+
self.systemImage = systemImage
31+
self.sheetContent = sheetContent
32+
}
33+
}

Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryHostingController.swift

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ public enum MediaLibraryHostingController {
1212
@MainActor
1313
public static func make(
1414
client: WordPressClient,
15-
tracker: any MediaTracker
15+
tracker: any MediaTracker,
16+
uploader: MediaUploader,
17+
externalPickerOptions: [ExternalMediaPickerOption] = []
1618
) -> UIViewController {
17-
let view = MediaLibraryContainerView(client: client, tracker: tracker)
19+
let view = MediaLibraryContainerView(
20+
client: client,
21+
tracker: tracker,
22+
uploader: uploader,
23+
externalPickerOptions: externalPickerOptions
24+
)
1825
let host = UIHostingController(rootView: view)
1926
host.navigationItem.largeTitleDisplayMode = .never
2027
return host
@@ -30,6 +37,8 @@ public enum MediaLibraryHostingController {
3037
private struct MediaLibraryContainerView: View {
3138
let client: WordPressClient
3239
let tracker: any MediaTracker
40+
let uploader: MediaUploader
41+
let externalPickerOptions: [ExternalMediaPickerOption]
3342

3443
@State private var resolved: Resolved?
3544
@State private var error: Error?
@@ -48,7 +57,8 @@ private struct MediaLibraryContainerView: View {
4857
viewModel: resolved.viewModel,
4958
service: resolved.service,
5059
client: client,
51-
tracker: tracker
60+
tracker: tracker,
61+
externalPickerOptions: externalPickerOptions
5262
)
5363
} else if let error {
5464
EmptyStateView.failure(error: error) {
@@ -66,7 +76,8 @@ private struct MediaLibraryContainerView: View {
6676
viewModel: MediaLibraryViewModel(
6777
service: service,
6878
client: client,
69-
tracker: tracker
79+
tracker: tracker,
80+
uploader: uploader
7081
),
7182
service: service
7283
)

Modules/Sources/WordPressMediaLibrary/Views/MediaLibraryView.swift

Lines changed: 129 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import DesignSystem
22
import SwiftUI
3+
import UIKit
34
import WordPressAPI
45
import WordPressAPIInternal
56
import WordPressCore
@@ -13,19 +14,35 @@ struct MediaLibraryView: View {
1314
let service: WpService
1415
let client: WordPressClient
1516
let tracker: any MediaTracker
17+
var externalPickerOptions: [ExternalMediaPickerOption] = []
1618

1719
@State private var searchText = ""
1820
@State private var isAspectRatioMode = AspectRatioPreference.load()
1921
/// Incremented by the Retry button so its `.task(id:)` re-fires; the initial
2022
/// value 0 is ignored so we don't double-load on appearance.
2123
@State private var retryToken = 0
24+
@State private var activePicker: ActivePicker?
25+
@State private var isPresentingUploads = false
26+
27+
private enum ActivePicker: Hashable, Identifiable {
28+
case photoLibrary, takePhoto, takeVideo, chooseFile
29+
case external(id: String)
30+
var id: Self { self }
31+
}
2232

2333
var body: some View {
2434
ZStack {
2535
if searchText.isEmpty {
26-
MediaGridView(items: viewModel.displayItems, isAspectRatioMode: isAspectRatioMode)
27-
.refreshable { await viewModel.refresh() }
28-
.overlay { libraryOverlay }
36+
VStack(spacing: 0) {
37+
if let summary = viewModel.bannerSummary {
38+
BannerView(summary: summary) {
39+
isPresentingUploads = true
40+
}
41+
}
42+
MediaGridView(items: viewModel.displayItems, isAspectRatioMode: isAspectRatioMode)
43+
.refreshable { await viewModel.refresh() }
44+
.overlay { libraryOverlay }
45+
}
2946
} else {
3047
MediaLibrarySearchView(
3148
service: service,
@@ -51,7 +68,79 @@ struct MediaLibraryView: View {
5168
.minimizedSearchToolbarBehavior()
5269
.autocorrectionDisabled()
5370
.textInputAutocapitalization(.never)
54-
.toolbar { filterMenu }
71+
.toolbar {
72+
filterMenu
73+
addMenu
74+
}
75+
// `MediaLibraryView` is hosted in a UIKit `UINavigationController`
76+
// via `UIHostingController`, so there's no SwiftUI `NavigationStack`
77+
// ancestor for `.navigationDestination` to push into. Present the
78+
// Uploads queue as a sheet instead — it's a self-contained
79+
// management surface (its own toolbar + bulk menu) and survives
80+
// the SwiftUI/UIKit boundary cleanly.
81+
.sheet(isPresented: $isPresentingUploads) {
82+
NavigationStack {
83+
UploadsView(viewModel: viewModel)
84+
.toolbar {
85+
ToolbarItem(placement: .topBarLeading) {
86+
Button(Strings.uploadsScreenClose) {
87+
isPresentingUploads = false
88+
}
89+
}
90+
}
91+
}
92+
}
93+
.sheet(item: $activePicker) { picker in
94+
switch picker {
95+
case .photoLibrary:
96+
PhotosPickerRepresentable(
97+
onPicked: { sources in
98+
activePicker = nil
99+
Task { await viewModel.enqueue(sources: sources) }
100+
},
101+
onCancel: { activePicker = nil }
102+
)
103+
.ignoresSafeArea()
104+
case .takePhoto:
105+
CameraPickerRepresentable(
106+
mode: .photo,
107+
onPicked: { source in
108+
activePicker = nil
109+
Task { await viewModel.enqueue(sources: [source]) }
110+
},
111+
onCancel: { activePicker = nil }
112+
)
113+
.ignoresSafeArea()
114+
case .takeVideo:
115+
CameraPickerRepresentable(
116+
mode: .video,
117+
onPicked: { source in
118+
activePicker = nil
119+
Task { await viewModel.enqueue(sources: [source]) }
120+
},
121+
onCancel: { activePicker = nil }
122+
)
123+
.ignoresSafeArea()
124+
case .chooseFile:
125+
EmptyView()
126+
.fileImporter(
127+
isPresented: .constant(true),
128+
allowedContentTypes: viewModel.uploader?.filePickerContentTypes ?? [],
129+
allowsMultipleSelection: true,
130+
onCompletion: { result in
131+
activePicker = nil
132+
if case .success(let urls) = result {
133+
let sources = urls.map { UploadSource.file($0) }
134+
Task { await viewModel.enqueue(sources: sources) }
135+
}
136+
}
137+
)
138+
case .external(let id):
139+
if let option = externalPickerOptions.first(where: { $0.id == id }) {
140+
option.sheetContent(viewModel)
141+
}
142+
}
143+
}
55144
}
56145

57146
@ToolbarContentBuilder private var filterMenu: some ToolbarContent {
@@ -93,6 +182,42 @@ struct MediaLibraryView: View {
93182
}
94183
}
95184

185+
@ToolbarContentBuilder private var addMenu: some ToolbarContent {
186+
ToolbarItem(placement: .topBarTrailing) {
187+
Menu {
188+
Button(Strings.addMenuPhotoLibrary, systemImage: "photo.on.rectangle") {
189+
activePicker = .photoLibrary
190+
}
191+
if UIImagePickerController.isSourceTypeAvailable(.camera) {
192+
Button(Strings.addMenuTakePhoto, systemImage: "camera") {
193+
activePicker = .takePhoto
194+
}
195+
Button(Strings.addMenuTakeVideo, systemImage: "video") {
196+
activePicker = .takeVideo
197+
}
198+
}
199+
Button(Strings.addMenuChooseFile, systemImage: "folder") {
200+
activePicker = .chooseFile
201+
}
202+
if !externalPickerOptions.isEmpty {
203+
Section {
204+
ForEach(externalPickerOptions) { option in
205+
Button(option.label, systemImage: option.systemImage) {
206+
activePicker = .external(id: option.id)
207+
}
208+
}
209+
// TODO: AINFRA-1496 — when the server-side numeric size field
210+
// lands, add a "View Usage" item here that opens
211+
// MediaStorageDetailsView (V1 view, kept alive in the app target).
212+
}
213+
}
214+
} label: {
215+
Image(systemName: "plus")
216+
.accessibilityLabel(Strings.addMenuTitle)
217+
}
218+
}
219+
}
220+
96221
@ViewBuilder private func filterButton(for kind: MediaKind?) -> some View {
97222
let title = kind?.title ?? Strings.filterAll
98223
let isSelected = kind == viewModel.kind

0 commit comments

Comments
 (0)