Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QE: Share image #572

Open
wants to merge 19 commits into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 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
2 changes: 2 additions & 0 deletions Demo/Gravatar-Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Demo/Gravatar-Demo/Info.plist";
INFOPLIST_KEY_NSCameraUsageDescription = "Accessing camera to create an avatar.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Accessing photo library to save the avatar.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main;
Expand Down Expand Up @@ -502,6 +503,7 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Demo/Gravatar-Demo/Info.plist";
INFOPLIST_KEY_NSCameraUsageDescription = "Accessing camera to create an avatar.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Accessing photo library to save the avatar.";
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
INFOPLIST_KEY_UIMainStoryboardFile = Main;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ struct AvatarImageModel: Hashable, Identifiable, Sendable {
return URL(string: url)
}

var shareURL: URL? {
guard case .remote(let url) = source else {
return nil
}
return URLComponents(string: url)?.replacingQueryItem(name: "size", value: "max").url
}

var localImage: Image? {
guard case .local(let image) = source else {
return nil
Expand All @@ -47,6 +54,6 @@ struct AvatarImageModel: Hashable, Identifiable, Sendable {
}

func settingStatus(to newStatus: State) -> AvatarImageModel {
AvatarImageModel(id: id, source: source, state: newStatus)
AvatarImageModel(id: id, source: source, state: newStatus, isSelected: isSelected)
}
}
29 changes: 25 additions & 4 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
@State private var safariURL: URL?
@State private var uploadError: FailedUploadInfo?
@State private var isUploadErrorDialogPresented: Bool = false
@State private var shareSheetItem: AvatarShareItem?

var contentLayoutProvider: AvatarPickerContentLayoutProviding
var customImageEditor: ImageEditorBlock<ImageEditor>?
Expand Down Expand Up @@ -141,6 +142,12 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
.onChange(of: model.backendSelectedAvatarURL) { _ in
notifyAvatarSelection()
}
.sheet(item: $shareSheetItem) { item in
// Sharing the URL helps with proper metadata to appear at the top of the share sheet.
ShareSheet(items: [item.url, item.image])
.colorScheme(colorScheme)
.presentationDetentsIfAvailable([.medium, .large])
}
}

private func header() -> some View {
Expand Down Expand Up @@ -293,8 +300,7 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
isUploadErrorDialogPresented = true
},
onAvatarActionTap: { avatar, action in
// TODO: Replace
print("Avatar action tapped: \(avatar.id), \(action.rawValue)")
handleAvatarAction(avatar: avatar, action: action)
}
)
.padding(.horizontal, Constants.horizontalPadding)
Expand All @@ -310,8 +316,7 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
isUploadErrorDialogPresented = true
},
onAvatarActionTap: { avatar, action in
// TODO: Replace
print("Avatar action tapped: \(avatar.id), \(action.rawValue)")
handleAvatarAction(avatar: avatar, action: action)
}
)
.padding(.top, .DS.Padding.medium)
Expand All @@ -324,6 +329,22 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
}
}

func handleAvatarAction(avatar: AvatarImageModel, action: AvatarAction) {
switch action {
case .share:
Task {
if let url = avatar.shareURL,
let image = await model.fetchOriginalSizeAvatar(for: avatar)
{
shareSheetItem = AvatarShareItem(id: avatar.id, image: image, url: url)
}
}
case .delete:
// TODO: Delete
break
}
}

func selectAvatar(with id: String) {
Task {
if await model.selectAvatar(with: id) != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SwiftUI
class AvatarPickerViewModel: ObservableObject {
private let profileService: ProfileService
private let avatarService: AvatarService
private let imageDownloader: ImageDownloader

private(set) var email: Email? {
didSet {
Expand All @@ -30,8 +31,7 @@ class AvatarPickerViewModel: ObservableObject {
@Published var selectedAvatarURL: URL?
@Published private(set) var backendSelectedAvatarURL: URL?
@Published private(set) var gridResponseStatus: Result<Void, Error>?

let grid: AvatarGridModel = .init(avatars: [])
@Published private(set) var grid: AvatarGridModel = .init(avatars: [])
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should have been a @Published var from the beginning imo. Otherwise the UI can fail to get updated properly.


private var profileResult: Result<ProfileSummaryModel, Error>? {
didSet {
Expand All @@ -50,12 +50,19 @@ class AvatarPickerViewModel: ObservableObject {
@Published var profileModel: AvatarPickerProfileView.Model?
@ObservedObject var toastManager: ToastManager = .init()

init(email: Email, authToken: String?, profileService: ProfileService? = nil, avatarService: AvatarService? = nil) {
init(
email: Email,
authToken: String?,
profileService: ProfileService? = nil,
avatarService: AvatarService? = nil,
imageDownloader: ImageDownloader? = nil
) {
self.email = email
avatarIdentifier = .email(email)
self.authToken = authToken
self.profileService = profileService ?? ProfileService()
self.avatarService = avatarService ?? AvatarService()
self.imageDownloader = imageDownloader ?? ImageDownloadService()
}

/// Internal init for previewing purposes. Do not make this public.
Expand All @@ -64,8 +71,13 @@ class AvatarPickerViewModel: ObservableObject {
selectedImageID: String? = nil,
profileModel: ProfileSummaryModel? = nil,
profileService: ProfileService? = nil,
avatarService: AvatarService? = nil
avatarService: AvatarService? = nil,
imageDownloader: ImageDownloader? = nil
) {
self.profileService = profileService ?? ProfileService()
self.avatarService = avatarService ?? AvatarService()
self.imageDownloader = imageDownloader ?? ImageDownloadService()

if let selectedImageID {
self.selectedAvatarResult = .success(selectedImageID)
}
Expand All @@ -84,8 +96,6 @@ class AvatarPickerViewModel: ObservableObject {
break
}
}
self.profileService = profileService ?? ProfileService()
self.avatarService = avatarService ?? AvatarService()
}

func selectAvatar(with id: String) async -> Avatar? {
Expand All @@ -105,6 +115,23 @@ class AvatarPickerViewModel: ObservableObject {
return await avatarSelectionTask?.value
}

func fetchOriginalSizeAvatar(for avatar: AvatarImageModel) async -> UIImage? {
guard let avatarURL = avatar.shareURL else { return nil }
do {
grid.setState(to: .loading, onAvatarWithID: avatar.id)
let result = try await imageDownloader.fetchImage(with: avatarURL, forceRefresh: false, processingMethod: .common())
grid.setState(to: .loaded, onAvatarWithID: avatar.id)
return result.image
} catch ImageFetchingError.responseError(reason: let reason) where reason.urlSessionErrorLocalizedDescription != nil {
grid.setState(to: .loaded, onAvatarWithID: avatar.id)
toastManager.showToast(reason.urlSessionErrorLocalizedDescription ?? Localized.avatarDownloadFail, type: .error)
} catch {
grid.setState(to: .loaded, onAvatarWithID: avatar.id)
toastManager.showToast(Localized.avatarDownloadFail, type: .error)
}
return nil
}

func postAvatarSelection(with avatarID: String, authToken: String, identifier: ProfileIdentifier) async -> Avatar? {
defer {
grid.setState(to: .loaded, onAvatarWithID: avatarID)
Expand Down Expand Up @@ -302,7 +329,7 @@ class AvatarPickerViewModel: ObservableObject {
}

extension AvatarPickerViewModel {
private enum Localized {
enum Localized {
static let genericUploadError = SDKLocalizedString(
"AvatarPickerViewModel.Upload.Error.message",
value: "Oops, there was an error uploading the image.",
Expand All @@ -323,6 +350,11 @@ extension AvatarPickerViewModel {
value: "The provided image exceeds the maximum size: 10MB",
comment: "Error message to show when the upload fails because the image is too big."
)
static let avatarDownloadFail = SDKLocalizedString(
"AvatarPickerViewModel.Download.Fail",
value: "Oops, something didn't quite work out while trying to download your avatar.",
comment: "This error message shows when the user attempts to download an avatar and fails."
)
}
}

Expand Down
7 changes: 7 additions & 0 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarShareItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import UIKit

struct AvatarShareItem: Identifiable {
let id: String
let image: UIImage
let url: URL
}
22 changes: 22 additions & 0 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/Views/ShareSheet.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import SwiftUI

/// Use `ShareLink` after iOS 16+.
struct ShareSheet: UIViewControllerRepresentable {
var items: [Any]
var activities: [UIActivity]? = nil

func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: items, applicationActivities: activities)
controller.excludedActivityTypes = [.print,
.postToWeibo,
.postToTencentWeibo,
.addToReadingList,
.postToVimeo,
.openInIBooks]
return controller
}

func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
// No need to update dynamically
}
}
9 changes: 9 additions & 0 deletions Sources/GravatarUI/SwiftUI/View+Additions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ extension View {
}
}

/// Applies detents for iOS 16+.
func presentationDetentsIfAvailable(_ detents: [QEDetent]) -> some View {
if #available(iOS 16.0, *) {
return self.presentationDetents(detents.map())
} else {
return self
}
}

/// Caution: `InnerHeightPreferenceKey` accumulates the values so DO NOT use this on a View and one of its ancestors at the same time.
@ViewBuilder
func accumulateIntrinsicHeight() -> some View {
Expand Down
19 changes: 19 additions & 0 deletions Sources/TestHelpers/TestURLSessionError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

package class TestURLSessionError: NSError, @unchecked Sendable {
let message: String

package init(message: String) {
self.message = message
super.init(domain: NSURLErrorDomain, code: 1)
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override package var localizedDescription: String {
message
}
}
105 changes: 103 additions & 2 deletions Tests/GravatarUITests/AvatarPickerViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,16 @@ final class AvatarPickerViewModelTests {
model = Self.createModel()
}

static func createModel(session: URLSessionAvatarPickerMock = .init()) -> AvatarPickerViewModel {
static func createModel(
session: URLSessionAvatarPickerMock = .init(),
imageDownloader: ImageDownloader = TestImageFetcher(result: .success)
) -> AvatarPickerViewModel {
.init(
email: .init("[email protected]"),
authToken: "token",
profileService: ProfileService(urlSession: session),
avatarService: AvatarService(urlSession: session)
avatarService: AvatarService(urlSession: session),
imageDownloader: imageDownloader
)
}

Expand Down Expand Up @@ -64,6 +68,103 @@ final class AvatarPickerViewModelTests {
}
}

@Test
func testFetchOriginalSizeAvatarSuccess() async throws {
await model.refresh()
guard let avatar = model.grid.avatars.first else {
#expect(Bool(false), "No avatar found")
return
}

await confirmation(expectedCount: 2) { confirmation in
model.toastManager.$toasts.sink { toasts in
#expect(toasts.count == 0, "No toast should be shown in success case")
confirmation.confirm()
}.store(in: &cancellables)

var observedStates: [AvatarImageModel.State] = []
model.grid.$avatars.sink { models in
observedStates.append(models[0].state)
if observedStates.count == 3 {
#expect(observedStates[0] == .loaded)
#expect(observedStates[1] == .loading)
#expect(observedStates[2] == .loaded)
confirmation.confirm()
}
}.store(in: &cancellables)
let result = await model.fetchOriginalSizeAvatar(for: avatar)
#expect(result != nil)
}
}

@Test
func testFetchOriginalSizeFailsWithURLSessionError() async throws {
let model = Self.createModel(imageDownloader: TestImageFetcher(result: .urlSessionError))
await model.refresh()
guard let avatar = model.grid.avatars.first else {
#expect(Bool(false), "No avatar found")
return
}

await confirmation(expectedCount: 2) { confirmation in
model.toastManager.$toasts.sink { toasts in
#expect(toasts.count <= 1)
if toasts.count == 1 {
#expect(toasts.first?.message == TestImageFetcher.sessionErrorMessage)
#expect(toasts.first?.type == .error)
confirmation.confirm()
}
}.store(in: &cancellables)

var observedStates: [AvatarImageModel.State] = []
model.grid.$avatars.sink { models in
observedStates.append(models[0].state)
if observedStates.count == 3 {
#expect(observedStates[0] == .loaded)
#expect(observedStates[1] == .loading)
#expect(observedStates[2] == .loaded)
confirmation.confirm()
}
}.store(in: &cancellables)
let result = await model.fetchOriginalSizeAvatar(for: avatar)
#expect(result == nil)
}
}

@Test
func testFetchOriginalSizeFailsWithGenericError() async throws {
let model = Self.createModel(imageDownloader: TestImageFetcher(result: .fail))
await model.refresh()
guard let avatar = model.grid.avatars.first else {
#expect(Bool(false), "No avatar found")
return
}

await confirmation(expectedCount: 2) { confirmation in
model.toastManager.$toasts.sink { toasts in
#expect(toasts.count <= 1)
if toasts.count == 1 {
#expect(toasts.first?.message == AvatarPickerViewModel.Localized.avatarDownloadFail)
#expect(toasts.first?.type == .error)
confirmation.confirm()
}
}.store(in: &cancellables)

var observedStates: [AvatarImageModel.State] = []
model.grid.$avatars.sink { models in
observedStates.append(models[0].state)
if observedStates.count == 3 {
#expect(observedStates[0] == .loaded)
#expect(observedStates[1] == .loading)
#expect(observedStates[2] == .loaded)
confirmation.confirm()
}
}.store(in: &cancellables)
let result = await model.fetchOriginalSizeAvatar(for: avatar)
#expect(result == nil)
}
}

@Test
func testUploadAvatar() async throws {
model.grid.setAvatars([])
Expand Down
Loading