diff --git a/Sources/Gravatar/Network/Services/AvatarService.swift b/Sources/Gravatar/Network/Services/AvatarService.swift index 8b6dfa94..f7d3a8fa 100644 --- a/Sources/Gravatar/Network/Services/AvatarService.swift +++ b/Sources/Gravatar/Network/Services/AvatarService.swift @@ -89,4 +89,25 @@ public struct AvatarService: Sendable { throw error.apiError() } } + + @discardableResult + package func update( + altText: String? = nil, + rating: AvatarRating? = nil, + avatarID: AvatarIdentifier, + accessToken: String + ) async throws -> Avatar { + var request = URLRequest(url: .avatarsURL.appendingPathComponent(avatarID.id)) + request.httpMethod = "PATCH" + let updateBody = UpdateAvatarRequest(rating: rating, altText: altText) + request.httpBody = try JSONEncoder().encode(updateBody) + + let authorizedRequest = request.settingAuthorizationHeaderField(with: accessToken) + do { + let (data, _) = try await client.data(with: authorizedRequest) + return try data.decode() + } catch { + throw error.apiError() + } + } } diff --git a/Sources/Gravatar/Network/Services/ProfileService.swift b/Sources/Gravatar/Network/Services/ProfileService.swift index 0f1bdf73..776ec143 100644 --- a/Sources/Gravatar/Network/Services/ProfileService.swift +++ b/Sources/Gravatar/Network/Services/ProfileService.swift @@ -3,12 +3,8 @@ import Foundation private let baseURL = URL(string: "https://api.gravatar.com/v3/profiles/")! private let avatarsBaseURLComponents = URLComponents(string: "https://api.gravatar.com/v3/me/avatars")! -private func avatarBaseURL(with avatarID: String) -> URL? { - URL(string: "https://api.gravatar.com/v3/me/avatars/\(avatarID)") -} - private func selectAvatarBaseURL(with avatarID: String) -> URL? { - avatarBaseURL(with: avatarID)?.appendingPathComponent("email") + URL(string: "https://api.gravatar.com/v3/me/avatars/\(avatarID)/email") } /// A service to perform Profile related tasks. @@ -63,30 +59,6 @@ public struct ProfileService: ProfileFetching, Sendable { throw error.apiError() } } - - @discardableResult - package func setRating( - _ rating: AvatarRating, - for avatar: AvatarIdentifier, - token: String - ) async throws -> Avatar { - guard let url = avatarsBaseURLComponents.url?.appendingPathComponent(avatar.id) - else { - throw APIError.requestError(reason: .urlInitializationFailed) - } - - do { - var request = URLRequest(url: url).settingAuthorizationHeaderField(with: token) - request.httpMethod = "PATCH" - - let requestBody = try JSONEncoder().encode(UpdateAvatarRequest(rating: rating)) - request.httpBody = requestBody - let (data, _) = try await client.data(with: request) - return try data.decode() - } catch { - throw error.apiError() - } - } } extension ProfileService { diff --git a/Sources/GravatarUI/Resources/en.lproj/Localizable.strings b/Sources/GravatarUI/Resources/en.lproj/Localizable.strings index 52f2efce..5d836b51 100644 --- a/Sources/GravatarUI/Resources/en.lproj/Localizable.strings +++ b/Sources/GravatarUI/Resources/en.lproj/Localizable.strings @@ -151,3 +151,23 @@ /* An option to show the image playground */ "SystemImagePickerView.Source.Playground.title" = "Playground"; +/* The title of Alt Text editor screen */ +"AltText.Editor.title" = "Alt Text"; + +/* Placeholder text for Alt Text editor text field */ +"AltText.Editor.placeholder" = "Write alt text..."; + +/* Title for Save button */ +"AltText.Editor.saveButtonTitle" = "Save"; + +/* Title for Cancel button */ +"AltText.Editor.cancelButtonTitle" = "Cancel"; + +/* This confirmation message shows when the user has updated the alt text */ +"AvatarPickerViewModel.AltText.Success" = "Image alt text was changed successfully."; + +/* This error message shows when the user attempts to change the alt text of an avatar and fails */ +"AvatarPickerViewModel.AltText.Error" = "Oops, something didn't quite work out while trying to update the alt text."; + +/* Title for Help button which opens a view explaining what alt text is */ +"AltText.Editor.helpButtonTitle" = "What is alt text?"; diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift index b41ea362..67f62327 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift @@ -6,6 +6,7 @@ enum AvatarAction: Identifiable { case delete case rating(AvatarRating) case playground + case altText var id: String { switch self { @@ -13,6 +14,7 @@ enum AvatarAction: Identifiable { case .delete: "delete" case .rating(let rating): rating.rawValue case .playground: "playground" + case .altText: "altText" } } @@ -24,6 +26,8 @@ enum AvatarAction: Identifiable { Image(systemName: "square.and.arrow.up") case .playground: Image(systemName: "apple.image.playground") + case .altText: + Image(systemName: "text.below.photo") case .rating: Image(systemName: "star.leadinghalf.filled") } @@ -49,6 +53,12 @@ enum AvatarAction: Identifiable { value: "Playground", comment: "An option to show the image playground" ) + case .altText: + SDKLocalizedString( + "AvatarPicker.AvatarAction.altText", + value: "Alt Text", + comment: "An option in the avatar menu that edits the avatar's Alt Text." + ) case .rating(let rating): String( format: SDKLocalizedString( @@ -65,7 +75,7 @@ enum AvatarAction: Identifiable { switch self { case .delete: .destructive - case .share, .rating, .playground: + case .share, .rating, .playground, .altText: nil } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarGridModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarGridModel.swift index 40c16e4d..c9124908 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarGridModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarGridModel.swift @@ -33,7 +33,7 @@ class AvatarGridModel: ObservableObject { func setState(to state: AvatarImageModel.State, onAvatarWithID id: String) { guard let imageModel = model(with: id) else { return } - let toggledModel = imageModel.settingStatus(to: state) + let toggledModel = imageModel.updating { $0.state = state } replaceModel(withID: id, with: toggledModel) } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift index 93dbaf14..f5fae74a 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift @@ -17,6 +17,7 @@ struct AvatarImageModel: Hashable, Identifiable, Sendable { let source: Source let isSelected: Bool let state: State + let altText: String let rating: AvatarRating var url: URL? { @@ -47,15 +48,49 @@ struct AvatarImageModel: Hashable, Identifiable, Sendable { return image } - init(id: String, source: Source, state: State = .loaded, isSelected: Bool = false, rating: AvatarRating = .g) { + init(id: String, source: Source, state: State, isSelected: Bool, rating: AvatarRating, altText: String) { self.id = id self.source = source self.state = state self.isSelected = isSelected self.rating = rating + self.altText = altText } - func settingStatus(to newStatus: State) -> AvatarImageModel { - AvatarImageModel(id: id, source: source, state: newStatus, isSelected: isSelected, rating: rating) + func updating(_ callback: (inout Builder) -> Void) -> AvatarImageModel { + var builder = Builder(self) + callback(&builder) + return builder.build() + } +} + +extension AvatarImageModel { + struct Builder { + var id: String + var source: Source + var isSelected: Bool + var state: State + var altText: String + var rating: AvatarRating + + fileprivate init(_ model: AvatarImageModel) { + self.id = model.id + self.source = model.source + self.isSelected = model.isSelected + self.state = model.state + self.altText = model.altText + self.rating = model.rating + } + + fileprivate func build() -> AvatarImageModel { + .init(id: id, source: source, state: state, isSelected: isSelected, rating: rating, altText: altText) + } + } +} + +extension AvatarImageModel { + /// This is meant to be used in previews and unit tests only. + static func preview_init(id: String, source: Source, state: State = .loaded, isSelected: Bool = false, rating: AvatarRating = .g) -> Self { + AvatarImageModel(id: id, source: source, state: state, isSelected: isSelected, rating: rating, altText: "") } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift index e11f56ee..85aed625 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift @@ -24,6 +24,7 @@ struct AvatarPickerView: View { @State private var avatarToDelete: AvatarImageModel? @State private var shareSheetItem: AvatarShareItem? @State private var playgroundInputItem: PlaygroundInputItem? + @State private var altTextEditorAvatar: AvatarImageModel? @State private var shouldDisplayNoSelectedAvatarWarning: Bool = false var contentLayoutProvider: AvatarPickerContentLayoutProviding @@ -152,19 +153,12 @@ struct AvatarPickerView: View { } .preference(key: VerticalSizeClassPreferenceKey.self, value: verticalSizeClass) .gravatarNavigation( - title: Constants.title, actionButtonDisabled: model.profileModel?.profileURL == nil, - onActionButtonPressed: { - openProfileEditInSafari() - }, onDoneButtonPressed: { isPresented = false } ) - .fullScreenCover(item: $safariURL) { url in - SafariView(url: url) - .edgesIgnoringSafeArea(.all) - } + .presentSafariView(url: $safariURL, colorScheme: colorScheme) .onChange(of: authToken ?? "") { newValue in model.update(authToken: newValue) } @@ -195,6 +189,18 @@ struct AvatarPickerView: View { uploadImage(image) } )) + .sheet(item: $altTextEditorAvatar) { avatarToEdit in + NavigationView { + AltTextEditorView(avatar: avatarToEdit, email: model.email, altText: avatarToEdit.altText) { newText in + altTextEditorAvatar = nil + Task { + await model.update(altText: newText, for: avatarToEdit) + } + } onCancel: { + altTextEditorAvatar = nil + } + }.colorScheme(colorScheme) + } } private func updateShouldDisplayNoSelectedAvatarWarning() { @@ -396,13 +402,19 @@ struct AvatarPickerView: View { playgroundInputItem = PlaygroundInputItem(id: avatar.id, image: Image(uiImage: image)) } } + case .altText: + showAltTextEditor(with: avatar) case .rating(let rating): Task { - await model.setRating(rating, for: avatar) + await model.update(rating: rating, for: avatar) } } } + func showAltTextEditor(with avatar: AvatarImageModel) { + altTextEditorAvatar = avatar + } + func selectAvatar(with id: String) { Task { if await model.selectAvatar(with: id) != nil { @@ -442,11 +454,6 @@ struct AvatarPickerView: View { safariURL = model.profileModel?.profileURL } - private func openProfileEditInSafari() { - guard let url = URL(string: "https://gravatar.com/profile") else { return } - safariURL = url - } - @ViewBuilder private func noSelectedAvatarWarning() -> some View { if shouldDisplayNoSelectedAvatarWarning { @@ -513,7 +520,6 @@ private enum AvatarPicker { enum Constants { static let horizontalPadding: CGFloat = .DS.Padding.double static let lightModeShadowColor = Color(uiColor: UIColor.rgba(25, 30, 35, alpha: 0.2)) - static let title: String = "Gravatar" // defined here to avoid translations static let vStackVerticalSpacing: CGFloat = .DS.Padding.medium static let profileViewTopSpacing: CGFloat = .DS.Padding.double } @@ -681,15 +687,15 @@ private enum AvatarPicker { } let avatarImageModels: [AvatarImageModel] = [ - .init(id: "0", source: .local(image: UIImage()), state: .loading), - .init(id: "1", source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")), - .init(id: "2", source: .remote(url: "https://gravatar.com/userimage/110207384/db73834576b01b69dd8da1e29877ca07.jpeg?size=256")), - .init(id: "3", source: .remote(url: "https://gravatar.com/userimage/110207384/3f7095bf2580265d1801d128c6410016.jpeg?size=256")), - .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: .error(supportsRetry: true, errorMessage: "Something went wrong.")), - .init(id: "8", source: .local(image: UIImage()), state: .error(supportsRetry: false, errorMessage: "Something went wrong.")), + .preview_init(id: "0", source: .local(image: UIImage()), state: .loading), + .preview_init(id: "1", source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")), + .preview_init(id: "2", source: .remote(url: "https://gravatar.com/userimage/110207384/db73834576b01b69dd8da1e29877ca07.jpeg?size=256")), + .preview_init(id: "3", source: .remote(url: "https://gravatar.com/userimage/110207384/3f7095bf2580265d1801d128c6410016.jpeg?size=256")), + .preview_init(id: "4", source: .remote(url: "https://gravatar.com/userimage/110207384/fbbd335e57862e19267679f19b4f9db8.jpeg?size=256")), + .preview_init(id: "5", source: .remote(url: "https://gravatar.com/userimage/110207384/96c6950d6d8ce8dd1177a77fe738101e.jpeg?size=256")), + .preview_init(id: "6", source: .remote(url: "https://gravatar.com/userimage/110207384/4a4f9385b0a6fa5c00342557a098f480.jpeg?size=256")), + .preview_init(id: "7", source: .local(image: UIImage()), state: .error(supportsRetry: true, errorMessage: "Something went wrong.")), + .preview_init(id: "8", source: .local(image: UIImage()), state: .error(supportsRetry: false, errorMessage: "Something went wrong.")), ] let selectedImageID = "5" let profileModel = PreviewModel() diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index b0d058fa..5d9c1263 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -207,7 +207,7 @@ class AvatarPickerViewModel: ObservableObject { let localID = UUID().uuidString - let localImageModel = AvatarImageModel(id: localID, source: .local(image: image), state: .loading, rating: .g) + let localImageModel = AvatarImageModel(id: localID, source: .local(image: image), state: .loading, isSelected: false, rating: .g, altText: "") grid.append(localImageModel) await doUpload(squareImage: image, localID: localID, accessToken: authToken) @@ -287,11 +287,14 @@ class AvatarPickerViewModel: ObservableObject { } private func handleUploadError(imageID: String, squareImage: UIImage, supportsRetry: Bool, errorMessage: String) { + let storedModel = grid.model(with: imageID) let newModel = AvatarImageModel( id: imageID, source: .local(image: squareImage), state: .error(supportsRetry: supportsRetry, errorMessage: errorMessage), - rating: grid.model(with: imageID)?.rating ?? .g + isSelected: false, + rating: storedModel?.rating ?? .g, + altText: storedModel?.altText ?? "" ) grid.replaceModel(withID: imageID, with: newModel) } @@ -338,19 +341,44 @@ class AvatarPickerViewModel: ObservableObject { await profile } - func setRating(_ rating: AvatarRating, for avatar: AvatarImageModel) async { - guard let authToken else { return } + @discardableResult + func update(altText: String, for avatar: AvatarImageModel) async -> Bool { + guard let token = self.authToken else { return false } + do { + let updatedAvatar = try await avatarService.update(altText: altText, avatarID: .hashID(avatar.id), accessToken: token) + toastManager.showToast(Localized.avatarAltTextSuccess + "\n\n \"\(altText)\"") + withAnimation { + grid.replaceModel(withID: avatar.id, with: .init(with: updatedAvatar)) + } + return true + } catch APIError.responseError(reason: let reason) where reason.urlSessionErrorLocalizedDescription != nil { + handleError(message: reason.urlSessionErrorLocalizedDescription ?? Localized.avatarAltTextError) + } catch { + handleError(message: Localized.avatarAltTextError) + } + + func handleError(message: String) { + toastManager.showToast(message, type: .error) + } + + return false + } + + @discardableResult + func update(rating: AvatarRating, for avatar: AvatarImageModel) async -> Bool { + guard let authToken else { return false } do { - let updatedAvatar = try await profileService.setRating( - rating, - for: .hashID(avatar.id), - token: authToken + let updatedAvatar = try await avatarService.update( + rating: rating, + avatarID: .hashID(avatar.id), + accessToken: authToken ) + toastManager.showToast(Localized.avatarRatingUpdateSuccess, type: .info) withAnimation { grid.replaceModel(withID: avatar.id, with: .init(with: updatedAvatar)) - toastManager.showToast(Localized.avatarRatingUpdateSuccess, type: .info) } + return true } catch APIError.responseError(let reason) where reason.urlSessionErrorLocalizedDescription != nil { handleError(message: reason.urlSessionErrorLocalizedDescription ?? Localized.avatarRatingError) } catch { @@ -360,6 +388,8 @@ class AvatarPickerViewModel: ObservableObject { func handleError(message: String) { toastManager.showToast(message, type: .error) } + + return false } func delete(_ avatar: AvatarImageModel) async -> Bool { @@ -444,6 +474,16 @@ extension AvatarPickerViewModel { 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." ) + static let avatarAltTextSuccess = SDKLocalizedString( + "AvatarPickerViewModel.AltText.Success", + value: "Image alt text was changed successfully.", + comment: "This confirmation message shows when the user has updated the alt text." + ) + static let avatarAltTextError = SDKLocalizedString( + "AvatarPickerViewModel.AltText.Error", + value: "Oops, something didn't quite work out while trying to update the alt text.", + comment: "This error message shows when the user attempts to change the alt text of an avatar and fails." + ) static let avatarRatingUpdateSuccess = SDKLocalizedString( "AvatarPickerViewModel.RatingUpdate.Success", value: "Avatar rating was changed successfully.", @@ -475,6 +515,7 @@ extension AvatarImageModel { source = .remote(url: avatar.url(withSize: String(avatarGridItemSize))) state = .loaded isSelected = avatar.isSelected + altText = avatar.altText rating = avatar.avatarRating } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AltTextEditorView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AltTextEditorView.swift new file mode 100644 index 00000000..b325b463 --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AltTextEditorView.swift @@ -0,0 +1,175 @@ +import SwiftUI + +struct AltTextEditorView: View { + let avatar: AvatarImageModel? + let email: Email? + let imageSize: CGFloat = 96 + let minLength: CGFloat = 96 + let characterLimit: Int = 100 + + var shouldShowCharCount: Bool { + altText.count > 0 + } + + @Environment(\.colorScheme) var colorScheme + + @State var altText: String = "" + @State var charCount: Int = 0 + @State var safariURL: URL? = nil + + @FocusState var focused: Bool + + let onSave: (String) -> Void + let onCancel: () -> Void + + var body: some View { + VStack { + if let email { + EmailText(email: email) + } + VStack(alignment: .leading) { + HStack { + titleText + Spacer() + altTextHelpButton + } + ZStack(alignment: .bottomTrailing) { + HStack(alignment: .top) { + imageView + altTextField + } + if shouldShowCharCount { + characterCountText + } + } + actionButton + } + .padding() + .avatarPickerBorder(colorScheme: .light) + Spacer() + } + .padding() + .gravatarNavigation( + doneButtonTitle: Localized.cancelButtonTitle, + actionButtonDisabled: false, + shouldEmitInnerHeight: false, + onDoneButtonPressed: { + onCancel() + } + ) + .presentSafariView(url: $safariURL, colorScheme: colorScheme) + } + + var altTextField: some View { + ZStack(alignment: .topLeading) { + TextEditor(text: $altText) + .multilineTextAlignment(.leading) + .frame(maxHeight: 100) + .font(.footnote) + .focused($focused) + .onAppear { focused = true } + .onChange(of: altText) { _ in + // Crops text to fit char limit. + altText = String(altText.prefix(characterLimit)) + } + if altText.count == 0 { + Text(Localized.altTextPlaceholder) + .padding(8) + // Exactly possitions placeholder over TextEditor text. + .padding(.leading, -3) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } + + var titleText: some View { + Text(Localized.pageTitle) + .font(.title2) + .fontWeight(.semibold) + } + + var actionButton: some View { + Button { + onSave(altText) + } label: { + CTAButtonView(Localized.saveButtonTitle) + }.padding(.top) + } + + var characterCountText: some View { + Text("\(altText.count)") + .font(.callout) + .foregroundColor(altText.count >= characterLimit ? .red : .secondary) + } + + var altTextHelpButton: some View { + Button(Localized.helpButtonTitle) { + safariURL = URL(string: "https://support.gravatar.com/profiles/avatars/#add-alt-text-to-avatars") + }.font(.footnote) + } + + var imageView: some View { + AvatarView( + url: avatar?.url, + placeholder: avatar?.localImage, + loadingView: { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + ).scaledToFill() + .frame(width: imageSize, height: imageSize) + .background(Color(UIColor.secondarySystemBackground)) + .aspectRatio(1, contentMode: .fill) + .shape(RoundedRectangle(cornerRadius: AvatarGridConstants.avatarCornerRadius)) + } +} + +extension AltTextEditorView { + fileprivate enum Localized { + static let pageTitle = SDKLocalizedString( + "AltText.Editor.title", + value: "Alt Text", + comment: "The title of Alt Text editor screen." + ) + static let altTextPlaceholder = SDKLocalizedString( + "AltText.Editor.placeholder", + value: "Write alt text...", + comment: "Placeholder text for Alt Text editor text field." + ) + static let saveButtonTitle = SDKLocalizedString( + "AltText.Editor.saveButtonTitle", + value: "Save", + comment: "Title for Save button." + ) + static let cancelButtonTitle = SDKLocalizedString( + "AltText.Editor.cancelButtonTitle", + value: "Cancel", + comment: "Title for Cancel button." + ) + static let helpButtonTitle = SDKLocalizedString( + "AltText.Editor.helpButtonTitle", + value: "What is alt text?", + comment: "Title for Help button which opens a view explaining what alt text is." + ) + } +} + +#Preview { + struct AltTextPreview: View { + @State var text = "" + let avatar = AvatarImageModel.preview_init( + id: "1", + source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256") + ) + + var body: some View { + AltTextEditorView( + avatar: avatar, + email: .init("some@email.com") + ) { _ in } onCancel: {} + } + } + + return AltTextPreview() +} diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarGrid.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarGrid.swift index 23758f3e..84eb083a 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarGrid.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarGrid.swift @@ -58,7 +58,7 @@ struct AvatarGrid: View { #Preview { let newAvatarModel: @Sendable (UIImage?) -> AvatarImageModel = { image in - AvatarImageModel(id: UUID().uuidString, source: .local(image: image ?? UIImage())) + AvatarImageModel.preview_init(id: UUID().uuidString, source: .local(image: image ?? UIImage())) } let initialAvatarCell = newAvatarModel(nil) let grid = AvatarGridModel( diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift index 179b4015..9e82b98c 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift @@ -91,6 +91,7 @@ struct AvatarPickerAvatarView: View { } } Section { + button(for: .altText) Menu { ForEach(AvatarRating.allCases, id: \.self) { rating in button(for: .rating(rating), isSelected: rating == avatar.rating) @@ -124,7 +125,7 @@ struct AvatarPickerAvatarView: View { } else { Text(buttonTitle) } - case .delete, .playground, .share: + case .altText, .delete, .playground, .share: label(forAction: action) } } @@ -175,7 +176,7 @@ extension AvatarRating { } #Preview { - let avatar = AvatarImageModel( + let avatar = AvatarImageModel.preview_init( id: "1", source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256"), rating: .pg diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/HorizontalAvatarGrid.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/HorizontalAvatarGrid.swift index 76af9be4..b69eb4fd 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/HorizontalAvatarGrid.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/HorizontalAvatarGrid.swift @@ -40,10 +40,10 @@ struct HorizontalAvatarGrid: View { #Preview { let grid = AvatarGridModel( avatars: [ - .init(id: "1", source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")), - .init(id: "2", source: .remote(url: "https://gravatar.com/userimage/110207384/db73834576b01b69dd8da1e29877ca07.jpeg?size=256")), - .init(id: "3", source: .remote(url: "https://gravatar.com/userimage/110207384/3f7095bf2580265d1801d128c6410016.jpeg?size=256")), - .init(id: "4", source: .remote(url: "https://gravatar.com/userimage/110207384/fbbd335e57862e19267679f19b4f9db8.jpeg?size=256")), + .preview_init(id: "1", source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")), + .preview_init(id: "2", source: .remote(url: "https://gravatar.com/userimage/110207384/db73834576b01b69dd8da1e29877ca07.jpeg?size=256")), + .preview_init(id: "3", source: .remote(url: "https://gravatar.com/userimage/110207384/3f7095bf2580265d1801d128c6410016.jpeg?size=256")), + .preview_init(id: "4", source: .remote(url: "https://gravatar.com/userimage/110207384/fbbd335e57862e19267679f19b4f9db8.jpeg?size=256")), ] ) grid.selectAvatar(grid.avatars.first) diff --git a/Sources/GravatarUI/SwiftUI/GravatarNavigationModifier.swift b/Sources/GravatarUI/SwiftUI/GravatarNavigationModifier.swift index 7d4f602d..5f1777a4 100644 --- a/Sources/GravatarUI/SwiftUI/GravatarNavigationModifier.swift +++ b/Sources/GravatarUI/SwiftUI/GravatarNavigationModifier.swift @@ -1,20 +1,29 @@ import SwiftUI struct GravatarNavigationModifier: ViewModifier { - var title: String + var title: String? + var doneButtonTitle: String? var actionButtonDisabled: Bool + var shouldEmitInnerHeight: Bool + + @Environment(\.colorScheme) var colorScheme + @State private var safariURL: URL? var onActionButtonPressed: (() -> Void)? = nil var onDoneButtonPressed: (() -> Void)? = nil func body(content: Content) -> some View { content - .navigationTitle(title) + .navigationTitle(title ?? Constants.gravatarNavigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { - onActionButtonPressed?() + if let action = onActionButtonPressed { + action() + } else { + openProfileEditInSafari() + } }) { Image("gravatar", bundle: .module) .tint(Color(UIColor.gravatarBlue)) @@ -26,7 +35,7 @@ struct GravatarNavigationModifier: ViewModifier { Button(action: { onDoneButtonPressed?() }) { - Text(Localized.doneButtonTitle) + Text(doneButtonTitle ?? Localized.doneButtonTitle) .tint(Color(UIColor.gravatarBlue)) } } @@ -36,16 +45,28 @@ struct GravatarNavigationModifier: ViewModifier { // This works to detect the navigation bar height. // AFAIU, SwiftUI calculates the `safeAreaInsets.top` based on the actual visible content area. // When a NavigationView is present, it accounts for the navigation bar being part of that system-provided safe area. - Color.clear.preference( - key: InnerHeightPreferenceKey.self, - value: geometry.safeAreaInsets.top - ) + if shouldEmitInnerHeight { + Color.clear.preference( + key: InnerHeightPreferenceKey.self, + value: geometry.safeAreaInsets.top + ) + } } } + .presentSafariView(url: $safariURL, colorScheme: colorScheme) + } + + private func openProfileEditInSafari() { + guard let url = URL(string: "https://gravatar.com/profile") else { return } + safariURL = url } } extension GravatarNavigationModifier { + enum Constants { + static let gravatarNavigationTitle = "Gravatar" + } + private enum Localized { static let doneButtonTitle = SDKLocalizedString( "GravatarNavigationModifier.Button.Done.title", @@ -57,15 +78,19 @@ extension GravatarNavigationModifier { extension View { func gravatarNavigation( - title: String, + title: String? = nil, + doneButtonTitle: String? = nil, actionButtonDisabled: Bool, + shouldEmitInnerHeight: Bool = true, onActionButtonPressed: (() -> Void)? = nil, onDoneButtonPressed: (() -> Void)? = nil ) -> some View { modifier( GravatarNavigationModifier( title: title, + doneButtonTitle: doneButtonTitle, actionButtonDisabled: actionButtonDisabled, + shouldEmitInnerHeight: shouldEmitInnerHeight, onActionButtonPressed: onActionButtonPressed, onDoneButtonPressed: onDoneButtonPressed ) diff --git a/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift b/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift index 0e278e04..6724eb56 100644 --- a/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift +++ b/Sources/GravatarUI/SwiftUI/ProfileEditor/QuickEditor.swift @@ -16,10 +16,6 @@ public enum QuickEditorScope: Sendable { } } -private enum QuickEditorConstants { - static let title: String = "Gravatar" // defined here to avoid translations -} - struct QuickEditor: View { fileprivate typealias Constants = QuickEditorConstants @@ -123,7 +119,6 @@ struct QuickEditor: View { ProgressView() } }.gravatarNavigation( - title: Constants.title, actionButtonDisabled: true, onDoneButtonPressed: { isPresented = false @@ -162,7 +157,7 @@ struct QuickEditor: View { } } -extension QuickEditorConstants { +enum QuickEditorConstants { enum ErrorView { static func title(for oauthError: OAuthError?) -> String { switch oauthError { diff --git a/Sources/GravatarUI/SwiftUI/View+Additions.swift b/Sources/GravatarUI/SwiftUI/View+Additions.swift index fae042ae..e071c4f1 100644 --- a/Sources/GravatarUI/SwiftUI/View+Additions.swift +++ b/Sources/GravatarUI/SwiftUI/View+Additions.swift @@ -151,4 +151,12 @@ extension View { self } } + + func presentSafariView(url: Binding, colorScheme: ColorScheme) -> some View { + self.sheet(item: url) { url in + SafariView(url: url) + .edgesIgnoringSafeArea(.all) + .colorScheme(colorScheme) + } + } } diff --git a/Tests/GravatarTests/AvatarServiceTests.swift b/Tests/GravatarTests/AvatarServiceTests.swift index 18cfb1ef..1a524090 100644 --- a/Tests/GravatarTests/AvatarServiceTests.swift +++ b/Tests/GravatarTests/AvatarServiceTests.swift @@ -128,6 +128,41 @@ final class AvatarServiceTests: XCTestCase { XCTAssertEqual(request?.url?.query, expectedQuery) XCTAssertNotNil(imageResponse.image) } + + func testSetRatingReturnsAvatar() async throws { + let data = Bundle.setRatingJsonData + let session = URLSessionMock(returnData: data, response: .successResponse()) + let service = avatarService(with: session) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let referenceAvatar = try decoder.decode(Avatar.self, from: data) + let avatar = try await service.update( + rating: .g, + avatarID: AvatarIdentifier.email("test@example.com"), + accessToken: "faketoken" + ) + + XCTAssertEqual(avatar, referenceAvatar) + } + + func testSetRatingHandlesError() async { + let session = URLSessionMock(returnData: Data(), response: .errorResponse(code: 403)) + let service = avatarService(with: session) + + do { + try await service.update( + rating: .g, + avatarID: AvatarIdentifier.email("test@example.com"), + accessToken: "faketoken" + ) + } catch APIError.responseError(reason: .invalidHTTPStatusCode(let response, _)) { + XCTAssertEqual(response.statusCode, 403) + } catch { + XCTFail(error.localizedDescription) + } + } } private func avatarService(with session: URLSessionProtocol, cache: ImageCaching? = nil) -> AvatarService { diff --git a/Tests/GravatarTests/ProfileServiceTests.swift b/Tests/GravatarTests/ProfileServiceTests.swift index 1bf6c60d..56b5f6ae 100644 --- a/Tests/GravatarTests/ProfileServiceTests.swift +++ b/Tests/GravatarTests/ProfileServiceTests.swift @@ -70,31 +70,4 @@ final class ProfileServiceTests: XCTestCase { XCTFail(error.localizedDescription) } } - - func testSetRatingReturnsAvatar() async throws { - let data = Bundle.setRatingJsonData - let session = URLSessionMock(returnData: data, response: .successResponse()) - let service = ProfileService(urlSession: session) - - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - let referenceAvatar = try decoder.decode(Avatar.self, from: data) - let avatar = try await service.setRating(.g, for: .email("test@example.com"), token: "faketoken") - - XCTAssertEqual(avatar, referenceAvatar) - } - - func testSetRatingHandlesError() async { - let session = URLSessionMock(returnData: Data(), response: .errorResponse(code: 403)) - let service = ProfileService(urlSession: session) - - do { - try await service.setRating(.g, for: .email("test@example.com"), token: "faketoken") - } catch APIError.responseError(reason: .invalidHTTPStatusCode(let response, _)) { - XCTAssertEqual(response.statusCode, 403) - } catch { - XCTFail(error.localizedDescription) - } - } } diff --git a/Tests/GravatarUITests/AvatarGridModelTests.swift b/Tests/GravatarUITests/AvatarGridModelTests.swift index 078e0501..6b206b74 100644 --- a/Tests/GravatarUITests/AvatarGridModelTests.swift +++ b/Tests/GravatarUITests/AvatarGridModelTests.swift @@ -3,11 +3,11 @@ import TestHelpers import Testing let initialAvatars: [AvatarImageModel] = [ - .init(id: "0", source: .remote(url: "https://example.com/1.jpg")), - .init(id: "1", source: .remote(url: "https://example.com/1.jpg"), isSelected: true), - .init(id: "2", source: .remote(url: "https://example.com/1.jpg")), - .init(id: "3", source: .remote(url: "https://example.com/1.jpg")), - .init(id: "4", source: .remote(url: "https://example.com/1.jpg")), + .preview_init(id: "0", source: .remote(url: "https://example.com/1.jpg")), + .preview_init(id: "1", source: .remote(url: "https://example.com/1.jpg"), isSelected: true), + .preview_init(id: "2", source: .remote(url: "https://example.com/1.jpg")), + .preview_init(id: "3", source: .remote(url: "https://example.com/1.jpg")), + .preview_init(id: "4", source: .remote(url: "https://example.com/1.jpg")), ] let initiallySelectedAvatarID = "1" @@ -26,7 +26,7 @@ struct AvatarGridModelTests { @Test("Test append function") func testAvatarGridModelAppend() async throws { - let appendedAvatar = AvatarImageModel(id: "new", source: .remote(url: "https://example.com/1.jpg")) + let appendedAvatar = AvatarImageModel.preview_init(id: "new", source: .remote(url: "https://example.com/1.jpg")) model.append(appendedAvatar) #expect(model.index(of: "new") == 0) @@ -102,7 +102,7 @@ struct AvatarGridModelTests { @Test("Test insert function") func testAvatarGridModelInsert() async throws { - let toInsert = AvatarImageModel(id: "new", source: .remote(url: "https://example.com")) + let toInsert = AvatarImageModel.preview_init(id: "new", source: .remote(url: "https://example.com")) model.insert(toInsert, at: 2) #expect(model.index(of: "new") == 2) diff --git a/Tests/GravatarUITests/AvatarImageModelTests.swift b/Tests/GravatarUITests/AvatarImageModelTests.swift index 72e6a150..387a9530 100644 --- a/Tests/GravatarUITests/AvatarImageModelTests.swift +++ b/Tests/GravatarUITests/AvatarImageModelTests.swift @@ -6,14 +6,14 @@ struct AvatarImageModelTests { @Test("Check URL exists") func testURLExists() async throws { let imageURL = "https://example.com/avatar.jpg" - let model = AvatarImageModel(id: "someID", source: .remote(url: imageURL)) + let model = AvatarImageModel.preview_init(id: "someID", source: .remote(url: imageURL)) #expect(model.url?.absoluteString == imageURL) #expect(model.localImage == nil) } @Test("Check local image exists") func testLocalImageExists() async throws { - let model = AvatarImageModel(id: "someID", source: .local(image: ImageHelper.testImage)) + let model = AvatarImageModel.preview_init(id: "someID", source: .local(image: ImageHelper.testImage)) #expect(model.localImage != nil) #expect(model.localUIImage != nil) #expect(model.url == nil) @@ -21,19 +21,19 @@ struct AvatarImageModelTests { @Test("Check state change from loading to loaded") func testStateChangeLoadingLoaded() async throws { - let model = AvatarImageModel(id: "someID", source: .local(image: ImageHelper.testImage), state: .loading) + let model = AvatarImageModel.preview_init(id: "someID", source: .local(image: ImageHelper.testImage), state: .loading) #expect(model.state == .loading) - let loadedModel = model.settingStatus(to: .loaded) + let loadedModel = model.updating { $0.state = .loaded } #expect(loadedModel.state == .loaded, "The state should be .loaded") } @Test("Check state change from loading to error") func testStateChangeLoadingError() async throws { - let model = AvatarImageModel(id: "someID", source: .local(image: ImageHelper.testImage), state: .loading) + let model = AvatarImageModel.preview_init(id: "someID", source: .local(image: ImageHelper.testImage), state: .loading) #expect(model.state == .loading) - let loadedModel = model.settingStatus(to: .error(supportsRetry: true, errorMessage: "Some Error")) + let loadedModel = model.updating { $0.state = .error(supportsRetry: true, errorMessage: "Some Error") } switch loadedModel.state { case .error: #expect(Bool(true)) diff --git a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift index bb113355..8b82e2ff 100644 --- a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift +++ b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift @@ -231,7 +231,7 @@ final class AvatarPickerViewModelTests { } }.store(in: &cancellables) - await model.setRating(.pg, for: avatar) + await model.update(rating: .pg, for: avatar) } let resultAvatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID })) #expect(resultAvatar.rating == .pg) @@ -259,7 +259,7 @@ final class AvatarPickerViewModelTests { } }.store(in: &cancellables) - await model.setRating(.pg, for: avatar) + await model.update(rating: .pg, for: avatar) } let resultAvatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID }))