From 7b78b75b5c63ddffeb9e634e4796523af4fc55b2 Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Wed, 27 Nov 2024 14:07:30 +0300 Subject: [PATCH 1/9] Improve share sheet options (We get more options this way) --- .../SwiftUI/AvatarPicker/AvatarPickerView.swift | 9 +++------ .../AvatarPicker/AvatarPickerViewModel.swift | 13 +++++++++++++ .../SwiftUI/AvatarPicker/AvatarShareItem.swift | 3 +-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift index aeee8c86..d4b4c947 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift @@ -143,8 +143,7 @@ struct AvatarPickerView: View { 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]) + ShareSheet(items: [item.fileURL]) .colorScheme(colorScheme) .presentationDetentsIfAvailable([.medium, .large]) } @@ -333,10 +332,8 @@ struct AvatarPickerView: View { 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) + if let fileURL = await model.fetchAndSaveToFile(avatar: avatar) { + shareSheetItem = AvatarShareItem(id: avatar.id, fileURL: fileURL) } } case .delete: diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index e6204e1a..95eef77b 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -132,6 +132,19 @@ class AvatarPickerViewModel: ObservableObject { return nil } + func fetchAndSaveToFile(avatar: AvatarImageModel) async -> URL? { + guard let image = await fetchOriginalSizeAvatar(for: avatar), + let imageData = image.pngData() else { return nil } + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("image.jpg") + do { + try imageData.write(to: fileURL) + return fileURL + } catch { + 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) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarShareItem.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarShareItem.swift index 788c4fd7..fd4a0eac 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarShareItem.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarShareItem.swift @@ -2,6 +2,5 @@ import UIKit struct AvatarShareItem: Identifiable { let id: String - let image: UIImage - let url: URL + let fileURL: URL } From 6fc6750753a67a0cd9c9a2cb8c76e1dcf3f43ea3 Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Wed, 27 Nov 2024 14:17:39 +0300 Subject: [PATCH 2/9] Update the error --- .../AvatarPicker/AvatarPickerViewModel.swift | 14 +++++++------- .../AvatarPickerViewModelTests.swift | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index 95eef77b..4b3c4461 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -124,10 +124,10 @@ class AvatarPickerViewModel: ObservableObject { 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) + toastManager.showToast(reason.urlSessionErrorLocalizedDescription ?? Localized.avatarShareFail, type: .error) } catch { grid.setState(to: .loaded, onAvatarWithID: avatar.id) - toastManager.showToast(Localized.avatarDownloadFail, type: .error) + toastManager.showToast(Localized.avatarShareFail, type: .error) } return nil } @@ -140,7 +140,7 @@ class AvatarPickerViewModel: ObservableObject { try imageData.write(to: fileURL) return fileURL } catch { - toastManager.showToast(Localized.avatarDownloadFail, type: .error) + toastManager.showToast(Localized.avatarShareFail, type: .error) } return nil } @@ -363,10 +363,10 @@ 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." + static let avatarShareFail = SDKLocalizedString( + "AvatarPickerViewModel.Share.Fail", + value: "Oops, something didn't quite work out while trying to share your avatar.", + comment: "This error message shows when the user attempts to share an avatar and fails." ) } } diff --git a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift index 857d06d4..a19eba58 100644 --- a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift +++ b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift @@ -135,7 +135,7 @@ final class AvatarPickerViewModelTests { model.toastManager.$toasts.sink { toasts in #expect(toasts.count <= 1) if toasts.count == 1 { - #expect(toasts.first?.message == AvatarPickerViewModel.Localized.avatarDownloadFail) + #expect(toasts.first?.message == AvatarPickerViewModel.Localized.avatarShareFail) #expect(toasts.first?.type == .error) confirmation.confirm() } From 9f89102096f860691c40204c586a69a5454bfbc9 Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Wed, 27 Nov 2024 14:19:39 +0300 Subject: [PATCH 3/9] Update strings in base locale --- Sources/GravatarUI/Resources/en.lproj/Localizable.strings | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/GravatarUI/Resources/en.lproj/Localizable.strings b/Sources/GravatarUI/Resources/en.lproj/Localizable.strings index 7a37a073..3e8f9e3b 100644 --- a/Sources/GravatarUI/Resources/en.lproj/Localizable.strings +++ b/Sources/GravatarUI/Resources/en.lproj/Localizable.strings @@ -73,8 +73,8 @@ /* Title of a button that will take you to your Gravatar profile, with an arrow indicating that this action will cause you to leave this view */ "AvatarPickerProfile.Button.ViewProfile.title" = "View profile →"; -/* This error message shows when the user attempts to download an avatar and fails. */ -"AvatarPickerViewModel.Download.Fail" = "Oops, something didn't quite work out while trying to download your avatar."; +/* This error message shows when the user attempts to share an avatar and fails. */ +"AvatarPickerViewModel.Share.Fail" = "Oops, something didn't quite work out while trying to share your avatar."; /* This error message shows when the user attempts to pick a different avatar and fails. */ "AvatarPickerViewModel.Update.Fail" = "Oops, something didn't quite work out while trying to change your avatar."; From 15349d0dc39375f89b2472dce39c3a895b26833a Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Wed, 27 Nov 2024 14:23:58 +0300 Subject: [PATCH 4/9] Set 0.7 fractional height (This way it is more close to the QE height) --- Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift index d4b4c947..0e7dff19 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift @@ -145,7 +145,7 @@ struct AvatarPickerView: View { .sheet(item: $shareSheetItem) { item in ShareSheet(items: [item.fileURL]) .colorScheme(colorScheme) - .presentationDetentsIfAvailable([.medium, .large]) + .presentationDetentsIfAvailable([.fraction(0.7), .large]) } } From 7d276db03bad955bc49fabb36ad3a1962c00d84f Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Wed, 27 Nov 2024 14:27:42 +0300 Subject: [PATCH 5/9] Use a constant var instead of literal --- .../SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift | 4 +++- .../GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift index fa6aa52b..dc2dd835 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift @@ -3,6 +3,8 @@ import SwiftUI /// Presentation styles supported for the verticially scrolling content. public enum VerticalContentPresentationStyle: Sendable, Equatable { + public static let expandableMediumInitialFraction: CGFloat = 0.7 + /// Full height sheet. case large @@ -10,7 +12,7 @@ public enum VerticalContentPresentationStyle: Sendable, Equatable { /// - initialFraction: The fractional height of the sheet in its initial state. /// - prioritizeScrollOverResize: A behavior that prioritizes scrolling the content of the sheet when /// swiping, rather than resizing it. Note that this parameter is effective only for iOS 16.4 +. - case expandableMedium(initialFraction: CGFloat = 0.7, prioritizeScrollOverResize: Bool = false) + case expandableMedium(initialFraction: CGFloat = VerticalContentPresentationStyle.expandableMediumInitialFraction, prioritizeScrollOverResize: Bool = false) } /// Presentation styles supported for the horizontially scrolling content. diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift index 0e7dff19..b72f66cc 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift @@ -145,7 +145,7 @@ struct AvatarPickerView: View { .sheet(item: $shareSheetItem) { item in ShareSheet(items: [item.fileURL]) .colorScheme(colorScheme) - .presentationDetentsIfAvailable([.fraction(0.7), .large]) + .presentationDetentsIfAvailable([.fraction(VerticalContentPresentationStyle.expandableMediumInitialFraction), .large]) } } From e82a962e622e8939599cccc12e26561a2dc137ae Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Wed, 27 Nov 2024 14:58:24 +0300 Subject: [PATCH 6/9] Fix the thumbnail by fixing the file extension --- .../GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index 4b3c4461..78a22ba2 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -135,7 +135,7 @@ class AvatarPickerViewModel: ObservableObject { func fetchAndSaveToFile(avatar: AvatarImageModel) async -> URL? { guard let image = await fetchOriginalSizeAvatar(for: avatar), let imageData = image.pngData() else { return nil } - let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("image.jpg") + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("image.png") do { try imageData.write(to: fileURL) return fileURL From 0646e8c5041a8193d60d5c90bbf0054846ce889d Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Wed, 27 Nov 2024 15:00:40 +0300 Subject: [PATCH 7/9] Make it jpeg (much faster this way) --- .../SwiftUI/AvatarPicker/AvatarPickerViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index 78a22ba2..fbce3eaf 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -134,8 +134,8 @@ class AvatarPickerViewModel: ObservableObject { func fetchAndSaveToFile(avatar: AvatarImageModel) async -> URL? { guard let image = await fetchOriginalSizeAvatar(for: avatar), - let imageData = image.pngData() else { return nil } - let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("image.png") + let imageData = image.jpegData(compressionQuality: 1) else { return nil } + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("image.jpg") do { try imageData.write(to: fileURL) return fileURL From 2faafb994d581372e31208ef1d30c58a3243d4e3 Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Wed, 27 Nov 2024 15:28:35 +0300 Subject: [PATCH 8/9] Calculate the initial detent based on the content layout --- .../AvatarPickerContentLayout.swift | 20 +++++++++++++++++++ .../AvatarPicker/AvatarPickerView.swift | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift index dc2dd835..6793d623 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerContentLayout.swift @@ -54,6 +54,20 @@ public enum AvatarPickerContentLayout: AvatarPickerContentLayoutProviding, Equat false } } + + var shareSheetInitialDetent: QEDetent { + switch self { + case .vertical(presentationStyle: let presentationStyle): + switch presentationStyle { + case .expandableMedium(let initialFraction, _): + .fraction(initialFraction) + case .large: + .medium + } + case .horizontal: + .fraction(VerticalContentPresentationStyle.expandableMediumInitialFraction) + } + } } /// Content layout to use pre iOS 16.0 where the system don't offer different presentation styles for SwiftUI. @@ -69,10 +83,16 @@ enum AvatarPickerContentLayoutType: String, CaseIterable, Identifiable, AvatarPi // MARK: AvatarPickerContentLayoutProviding var contentLayout: AvatarPickerContentLayoutType { self } + + var shareSheetInitialDetent: QEDetent { + .fraction(VerticalContentPresentationStyle.expandableMediumInitialFraction) + } } /// Internal type. This is an abstraction over `AvatarPickerContentLayoutType` and `AvatarPickerContentLayout` /// to use when all we are interested is to find out if the content is horizontial or vertical. protocol AvatarPickerContentLayoutProviding: Sendable { var contentLayout: AvatarPickerContentLayoutType { get } + // Determines the initial detent of share sheet inside the QE. + var shareSheetInitialDetent: QEDetent { get } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift index b72f66cc..7c62afdd 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift @@ -145,7 +145,9 @@ struct AvatarPickerView: View { .sheet(item: $shareSheetItem) { item in ShareSheet(items: [item.fileURL]) .colorScheme(colorScheme) - .presentationDetentsIfAvailable([.fraction(VerticalContentPresentationStyle.expandableMediumInitialFraction), .large]) + .presentationDetentsIfAvailable( + [contentLayoutProvider.shareSheetInitialDetent, .large] + ) } } From 4801fd3103a6e2e2115051d05537d235c1534f54 Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Wed, 27 Nov 2024 16:34:54 +0300 Subject: [PATCH 9/9] Extract `saveToFile()` --- Sources/Gravatar/Extensions/UIImage+Additions.swift | 12 ++++++++++++ .../{UIImage.swift => UIImage+Square.swift} | 0 .../SwiftUI/AvatarPicker/AvatarPickerViewModel.swift | 7 ++----- 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 Sources/Gravatar/Extensions/UIImage+Additions.swift rename Sources/Gravatar/Extensions/{UIImage.swift => UIImage+Square.swift} (100%) diff --git a/Sources/Gravatar/Extensions/UIImage+Additions.swift b/Sources/Gravatar/Extensions/UIImage+Additions.swift new file mode 100644 index 00000000..a2286c19 --- /dev/null +++ b/Sources/Gravatar/Extensions/UIImage+Additions.swift @@ -0,0 +1,12 @@ +import UIKit + +extension UIImage { + /// Saves image into the temp directory as a jpeg file. + /// - Returns: The URL of the file. + package func saveToFile() throws -> URL? { + guard let imageData = jpegData(compressionQuality: 1) else { return nil } + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("image.jpg") + try imageData.write(to: fileURL) + return fileURL + } +} diff --git a/Sources/Gravatar/Extensions/UIImage.swift b/Sources/Gravatar/Extensions/UIImage+Square.swift similarity index 100% rename from Sources/Gravatar/Extensions/UIImage.swift rename to Sources/Gravatar/Extensions/UIImage+Square.swift diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index fbce3eaf..be164a1d 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -133,12 +133,9 @@ class AvatarPickerViewModel: ObservableObject { } func fetchAndSaveToFile(avatar: AvatarImageModel) async -> URL? { - guard let image = await fetchOriginalSizeAvatar(for: avatar), - let imageData = image.jpegData(compressionQuality: 1) else { return nil } - let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("image.jpg") + guard let image = await fetchOriginalSizeAvatar(for: avatar) else { return nil } do { - try imageData.write(to: fileURL) - return fileURL + return try image.saveToFile() } catch { toastManager.showToast(Localized.avatarShareFail, type: .error) }