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

Improve share sheet #576

Merged
merged 9 commits into from
Nov 27, 2024
Merged
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
12 changes: 12 additions & 0 deletions Sources/Gravatar/Extensions/UIImage+Additions.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 2 additions & 2 deletions Sources/GravatarUI/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ 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

/// Medium height sheet that is expandable to full height. In compact height this is inactive and the sheet is displayed as full height.
/// - 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.
Expand Down Expand Up @@ -52,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.
Expand All @@ -67,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 }
}
13 changes: 6 additions & 7 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,11 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: 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])
.presentationDetentsIfAvailable(
[contentLayoutProvider.shareSheetInitialDetent, .large]
)
}
}

Expand Down Expand Up @@ -333,10 +334,8 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,20 @@ 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
}

func fetchAndSaveToFile(avatar: AvatarImageModel) async -> URL? {
guard let image = await fetchOriginalSizeAvatar(for: avatar) else { return nil }
do {
return try image.saveToFile()
} catch {
toastManager.showToast(Localized.avatarShareFail, type: .error)
}
return nil
}
Expand Down Expand Up @@ -350,10 +360,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."
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@ import UIKit

struct AvatarShareItem: Identifiable {
let id: String
let image: UIImage
let url: URL
let fileURL: URL
}
2 changes: 1 addition & 1 deletion Tests/GravatarUITests/AvatarPickerViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down