Skip to content

Commit

Permalink
Show upload fail error dialog (#420)
Browse files Browse the repository at this point in the history
  • Loading branch information
pinarol authored Sep 25, 2024
1 parent 5eae547 commit 12cb93c
Show file tree
Hide file tree
Showing 14 changed files with 166 additions and 94 deletions.
15 changes: 15 additions & 0 deletions Sources/Gravatar/Network/APIErrorPayload.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

/// Error payload for the REST API calls.
public protocol APIErrorPayload: Sendable {
/// A business error code that identifies this error. (This is not the HTTP status code.)
var code: String { get }
/// Error message that comes from the REST API.
var message: String? { get }
}

extension ModelError: APIErrorPayload {
public var message: String? {
error
}
}
11 changes: 4 additions & 7 deletions Sources/Gravatar/Network/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@ public enum ResponseErrorReason: Sendable {
case URLSessionError(error: Error)

/// The response contains an invalid HTTP status code. By default, status code >= 400 is recognized as invalid.
case invalidHTTPStatusCode(response: HTTPURLResponse, data: Data)
case invalidHTTPStatusCode(response: HTTPURLResponse, errorPayload: APIErrorPayload? = nil)

/// The response is not a `HTTPURLResponse`.
case invalidURLResponse(response: URLResponse)

///
case invalidRequest(error: ModelError)

/// An unexpected error has occurred.
case unexpected(Error)

Expand All @@ -33,9 +30,9 @@ public enum ResponseErrorReason: Sendable {
return nil
}

public var errorData: Data? {
if case .invalidHTTPStatusCode(_, let data) = self {
return data
public var errorPayload: APIErrorPayload? {
if case .invalidHTTPStatusCode(_, let payload) = self {
return payload
}
return nil
}
Expand Down
12 changes: 2 additions & 10 deletions Sources/Gravatar/Network/Services/AvatarService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,10 @@ public struct AvatarService: Sendable {
do {
let (data, _) = try await imageUploader.uploadImage(image, accessToken: accessToken, additionalHTTPHeaders: [(name: "Client-Type", value: "ios")])
return try data.decode()

} catch ImageUploadError.responseError(reason: let reason) where reason.httpStatusCode == 400 {
guard let data = reason.errorData, let error: ModelError = try? data.decode() else {
throw ImageUploadError.responseError(reason: reason)
}
} catch let error as ImageUploadError {
throw error
} catch let error as DecodingError {
throw ImageUploadError.responseError(reason: .unexpected(error))
} catch {
throw error
throw ImageUploadError.responseError(reason: .unexpected(error))
}
}
}

extension ModelError: Error {}
11 changes: 8 additions & 3 deletions Sources/Gravatar/Network/Services/URLSessionHTTPClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,16 @@ extension HTTPClientError {
func map() -> ResponseErrorReason {
switch self {
case .URLSessionError(let error):
.URLSessionError(error: error)
return .URLSessionError(error: error)
case .invalidHTTPStatusCodeError(let response, let data):
.invalidHTTPStatusCode(response: response, data: data)
if response.statusCode == 400 {
let error: ModelError? = try? data.decode()
return .invalidHTTPStatusCode(response: response, errorPayload: error)
} else {
return .invalidHTTPStatusCode(response: response)
}
case .invalidURLResponseError(let response):
.invalidURLResponse(response: response)
return .invalidURLResponse(response: response)
}
}
}
15 changes: 13 additions & 2 deletions Sources/GravatarUI/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,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 →";

/* An message that will appear in a small 'toast' message overlaying the current view */
"AvatarPickerViewModel.Toast.Error.message" = "Oops, there was an error uploading the image.";
/* A generic error message to show on an error dialog when the upload fails. */
"AvatarPickerViewModel.Upload.Error.message" = "Oops, there was an error uploading the image.";

/* Text on a sample Gravatar profile, appearing in the place where a Gravatar profile would display your short biography. */
"ClaimProfile.Label.AboutMe" = "Tell the world who you are. Your avatar and bio that follows you across the web.";
Expand Down Expand Up @@ -70,3 +70,14 @@
/* An option in a menu that display the user's Photo Library and allow them to choose a photo from it */
"SystemImagePickerView.Source.PhotoLibrary.title" = "Choose a Photo";

/* The title of the upload error dialog. */
"AvatarPicker.Upload.Error.title" = "Upload has failed";

/* The title of the remove button on the upload error dialog. */
"AvatarPicker.Upload.Error.Remove.title" = "Remove";

/* The title of the retry button on the upload error dialog. */
"AvatarPicker.Upload.Error.Retry.title" = "Retry";

/* The title of the dismiss button on a confirmation dialog. */
"AvatarPicker.Dismiss.title" = "Dismiss";
4 changes: 2 additions & 2 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarGridModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class AvatarGridModel: ObservableObject {
}
}

func deleteModel(_ avatar: AvatarImageModel) {
avatars.removeAll { $0 == avatar }
func deleteModel(_ id: String) {
avatars.removeAll { $0.id == id }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ struct AvatarImageModel: Hashable, Identifiable, Sendable {
case local(image: UIImage)
}

enum State {
enum State: Equatable, Hashable {
case loaded
case loading
case retry
case error
case error(supportsRetry: Bool, errorMessage: String)
}

let id: String
Expand Down
72 changes: 56 additions & 16 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
@State private var safariURL: URL?
@Environment(\.verticalSizeClass) var verticalSizeClass
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@State private var uploadError: FailedUploadInfo?
@State private var isUploadErrorDialogPresented: Bool = false
var customImageEditor: ImageEditorBlock<ImageEditor>?
var tokenErrorHandler: (() -> Void)?

Expand All @@ -39,6 +41,28 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
.task {
model.refresh()
}
.confirmationDialog(
Localized.uploadErrorTitle,
isPresented: $isUploadErrorDialogPresented,
titleVisibility: .visible,
presenting: uploadError
) { error in
Button(role: .destructive) {
deleteFailedUpload(error.avatarLocalID)
} label: {
Label(Localized.removeButtonTitle, systemImage: "trash")
}
if error.supportsRetry {
Button {
retryUpload(error.avatarLocalID)
} label: {
Label(Localized.retryButtonTitle, systemImage: "arrow.clockwise")
}
}
Button(Localized.dismissButtonTitle, role: .cancel) {}
} message: { error in
Text(error.errorMessage)
}
}

ToastContainerView(toastManager: model.toastManager)
Expand Down Expand Up @@ -180,15 +204,15 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
}
}

private func retryUpload(_ avatar: AvatarImageModel) {
private func retryUpload(_ id: String) {
Task {
await model.retryUpload(of: avatar.id)
await model.retryUpload(of: id)
}
}

private func deleteFailedUpload(_ avatar: AvatarImageModel) {
private func deleteFailedUpload(_ id: String) {
withAnimation {
model.deleteFailed(avatar)
model.deleteFailed(id)
}
}

Expand All @@ -206,11 +230,9 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
onImagePickerDidPickImage: { image in
uploadImage(image)
},
onRetryUpload: { avatar in
retryUpload(avatar)
},
onDeleteFailed: { avatar in
deleteFailedUpload(avatar)
onFailedUploadTapped: { failedUploadInfo in
uploadError = failedUploadInfo
isUploadErrorDialogPresented = true
}
)
.padding(.horizontal, Constants.horizontalPadding)
Expand All @@ -221,11 +243,9 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
onAvatarTap: { avatar in
model.selectAvatar(with: avatar.id)
},
onRetryUpload: { avatar in
retryUpload(avatar)
},
onDeleteFailed: { avatar in
deleteFailedUpload(avatar)
onFailedUploadTapped: { failedUploadInfo in
uploadError = failedUploadInfo
isUploadErrorDialogPresented = true
}
)
.padding(.top, .DS.Padding.medium)
Expand Down Expand Up @@ -318,6 +338,26 @@ private enum AvatarPicker {
}

enum Localized {
static let uploadErrorTitle = SDKLocalizedString(
"AvatarPicker.Upload.Error.title",
value: "Upload has failed",
comment: "The title of the upload error dialog."
)
static let removeButtonTitle = SDKLocalizedString(
"AvatarPicker.Upload.Error.Remove.title",
value: "Remove",
comment: "The title of the remove button on the upload error dialog."
)
static let retryButtonTitle = SDKLocalizedString(
"AvatarPicker.Upload.Error.Retry.title",
value: "Retry",
comment: "The title of the retry button on the upload error dialog."
)
static let dismissButtonTitle = SDKLocalizedString(
"AvatarPicker.Dismiss.title",
value: "Dismiss",
comment: "The title of the dismiss button on a confirmation dialog."
)
static let buttonUploadImage = SDKLocalizedString(
"AvatarPicker.ContentLoading.Success.ctaButtonTitle",
value: "Upload image",
Expand Down Expand Up @@ -454,8 +494,8 @@ private enum AvatarPicker {
.init(id: "4", source: .remote(url: "https://gravatar.com/userimage/110207384/fbbd335e57862e19267679f19b4f9db8.jpeg?size=256")),
.init(id: "5", source: .remote(url: "https://gravatar.com/userimage/110207384/96c6950d6d8ce8dd1177a77fe738101e.jpeg?size=256")),
.init(id: "6", source: .remote(url: "https://gravatar.com/userimage/110207384/4a4f9385b0a6fa5c00342557a098f480.jpeg?size=256")),
.init(id: "7", source: .local(image: UIImage()), state: .retry),
.init(id: "8", source: .local(image: UIImage()), state: .error),
.init(id: "7", source: .local(image: UIImage()), state: .error(supportsRetry: true, errorMessage: "Something went wrong.")),
.init(id: "8", source: .local(image: UIImage()), state: .error(supportsRetry: false, errorMessage: "Something went wrong.")),
],
selectedImageID: "5",
profileModel: PreviewModel()
Expand Down
47 changes: 35 additions & 12 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ class AvatarPickerViewModel: ObservableObject {
await doUpload(squareImage: localImage, localID: localID, accessToken: authToken)
}

func deleteFailed(_ avatar: AvatarImageModel) {
grid.deleteModel(avatar)
func deleteFailed(_ id: String) {
grid.deleteModel(id)
}

private func doUpload(squareImage: UIImage, localID: String, accessToken: String) async {
Expand All @@ -176,17 +176,40 @@ class AvatarPickerViewModel: ObservableObject {

let newModel = AvatarImageModel(id: avatar.id, source: .remote(url: avatar.url))
grid.replaceModel(withID: localID, with: newModel)
} catch let error as ModelError {
let newModel = AvatarImageModel(id: localID, source: .local(image: squareImage), state: .error)
grid.replaceModel(withID: localID, with: newModel)
toastManager.showToast(error.error, type: .error)
} catch ImageUploadError.responseError(reason: let .invalidHTTPStatusCode(response, errorPayload)) where response.statusCode == 400 {
// If the status code is 400 then it means we got a validation error about this image and the operation is not suitable for retrying.
handleUploadError(
imageID: localID,
squareImage: squareImage,
supportsRetry: false,
errorMessage: errorPayload?.message ?? Localized.genericUploadError
)
} catch ImageUploadError.responseError(reason: let reason) where reason.urlSessionErrorLocalizedDescription != nil {
handleUploadError(
imageID: localID,
squareImage: squareImage,
supportsRetry: true,
errorMessage: reason.urlSessionErrorLocalizedDescription ?? Localized.genericUploadError
)
} catch {
let newModel = AvatarImageModel(id: localID, source: .local(image: squareImage), state: .retry)
grid.replaceModel(withID: localID, with: newModel)
toastManager.showToast(Localized.toastError, type: .error)
handleUploadError(
imageID: localID,
squareImage: squareImage,
supportsRetry: true,
errorMessage: Localized.genericUploadError
)
}
}

private func handleUploadError(imageID: String, squareImage: UIImage, supportsRetry: Bool, errorMessage: String) {
let newModel = AvatarImageModel(
id: imageID,
source: .local(image: squareImage),
state: .error(supportsRetry: supportsRetry, errorMessage: errorMessage)
)
grid.replaceModel(withID: imageID, with: newModel)
}

private func updateSelectedAvatarURL() {
guard let selectedID = selectedAvatarResult?.value() else { return }
grid.selectAvatar(withID: selectedID)
Expand Down Expand Up @@ -223,10 +246,10 @@ class AvatarPickerViewModel: ObservableObject {

extension AvatarPickerViewModel {
private enum Localized {
static let toastError = SDKLocalizedString(
"AvatarPickerViewModel.Toast.Error.message",
static let genericUploadError = SDKLocalizedString(
"AvatarPickerViewModel.Upload.Error.message",
value: "Oops, there was an error uploading the image.",
comment: "An message that will appear in a small 'toast' message overlaying the current view"
comment: "A generic error message to show on an error dialog when the upload fails."
)
}
}
Expand Down
10 changes: 3 additions & 7 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarGrid.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ struct AvatarGrid<ImageEditor: ImageEditorView>: View {
var customImageEditor: ImageEditorBlock<ImageEditor>?
let onAvatarTap: (AvatarImageModel) -> Void
let onImagePickerDidPickImage: (UIImage) -> Void
let onRetryUpload: (AvatarImageModel) -> Void
let onDeleteFailed: (AvatarImageModel) -> Void
let onFailedUploadTapped: (FailedUploadInfo) -> Void

var body: some View {
LazyVGrid(columns: gridItems, spacing: AvatarGridConstants.avatarSpacing) {
Expand All @@ -46,8 +45,7 @@ struct AvatarGrid<ImageEditor: ImageEditorView>: View {
grid.selectedAvatar?.id == avatar.id
},
onAvatarTap: onAvatarTap,
onRetryUpload: onRetryUpload,
onDeleteFailed: onDeleteFailed
onFailedUploadTapped: onFailedUploadTapped
)
}
}
Expand All @@ -68,9 +66,7 @@ struct AvatarGrid<ImageEditor: ImageEditorView>: View {
grid.selectAvatar(withID: avatar.id)
} onImagePickerDidPickImage: { image in
grid.append(newAvatarModel(image))
} onRetryUpload: { _ in
// No op. inside the preview.
} onDeleteFailed: { _ in
} onFailedUploadTapped: { _ in
// No op. inside the preview.
}
.padding()
Expand Down
Loading

0 comments on commit 12cb93c

Please sign in to comment.