Skip to content

Commit

Permalink
Alt text editor (#590)
Browse files Browse the repository at this point in the history
  • Loading branch information
etoledom authored Dec 13, 2024
2 parents 0f32fed + ecf2df6 commit 8a262a2
Show file tree
Hide file tree
Showing 20 changed files with 448 additions and 131 deletions.
21 changes: 21 additions & 0 deletions Sources/Gravatar/Network/Services/AvatarService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}
30 changes: 1 addition & 29 deletions Sources/Gravatar/Network/Services/ProfileService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions Sources/GravatarUI/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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?";
12 changes: 11 additions & 1 deletion Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ enum AvatarAction: Identifiable {
case delete
case rating(AvatarRating)
case playground
case altText

var id: String {
switch self {
case .share: "share"
case .delete: "delete"
case .rating(let rating): rating.rawValue
case .playground: "playground"
case .altText: "altText"
}
}

Expand All @@ -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")
}
Expand All @@ -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(
Expand All @@ -65,7 +75,7 @@ enum AvatarAction: Identifiable {
switch self {
case .delete:
.destructive
case .share, .rating, .playground:
case .share, .rating, .playground, .altText:
nil
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
41 changes: 38 additions & 3 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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: "")
}
}
54 changes: 30 additions & 24 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: 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
Expand Down Expand Up @@ -152,19 +153,12 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: 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)
}
Expand Down Expand Up @@ -195,6 +189,18 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: 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() {
Expand Down Expand Up @@ -396,13 +402,19 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: 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 {
Expand Down Expand Up @@ -442,11 +454,6 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: 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 {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
Expand Down
Loading

0 comments on commit 8a262a2

Please sign in to comment.