From 0d83dd40ea80ac8eb18c552f62247a316fc72aa9 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Wed, 4 Dec 2024 11:22:48 -0600 Subject: [PATCH 01/19] Update OpenAPI spec --- openapi/openapi.yaml | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 992972d0..1eee9014 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -738,7 +738,7 @@ paths: '403': description: Insufficient Scope $ref: '#/components/responses/insufficient_scope' - /me/avatars/{imageHash}: + /me/avatars/{imageId}: delete: summary: Delete avatar description: Deletes a specific avatar for the authenticated user. @@ -748,14 +748,14 @@ paths: security: - oauth: [] parameters: - - name: imageHash + - name: imageId in: path required: true - description: The hash of the avatar to delete. + description: The ID of the avatar to delete. schema: type: string responses: - '200': + '204': description: Avatar deleted successfully '401': description: Not Authorized @@ -763,8 +763,6 @@ paths: '403': description: Insufficient Scope $ref: '#/components/responses/insufficient_scope' - '404': - description: Avatar not found patch: summary: Update avatar data description: Updates the avatar data for a given avatar for the authenticated user. @@ -795,10 +793,10 @@ paths: providing globally unique avatars. required: [] parameters: - - name: imageHash + - name: imageId in: path required: true - description: The hash of the avatar to update. + description: The ID of the avatar to update. schema: type: string responses: @@ -814,8 +812,6 @@ paths: '403': description: Insufficient Scope $ref: '#/components/responses/insufficient_scope' - '404': - description: Avatar not found /me/avatars/{imageId}/email: post: summary: Set avatar for the hashed email @@ -849,7 +845,7 @@ paths: security: - oauth: [] responses: - '200': + '204': description: Avatar successfully set '401': description: Not Authorized From 83f1de69609b4e81a8b40e861180fdea36644896 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Wed, 4 Dec 2024 11:23:34 -0600 Subject: [PATCH 02/19] Add setRating function --- .../Network/Services/ProfileService.swift | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/Sources/Gravatar/Network/Services/ProfileService.swift b/Sources/Gravatar/Network/Services/ProfileService.swift index 776ec143..228154c7 100644 --- a/Sources/Gravatar/Network/Services/ProfileService.swift +++ b/Sources/Gravatar/Network/Services/ProfileService.swift @@ -3,8 +3,12 @@ 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? { - URL(string: "https://api.gravatar.com/v3/me/avatars/\(avatarID)/email") + avatarBaseURL(with: avatarID)?.appendingPathComponent("email") } /// A service to perform Profile related tasks. @@ -59,6 +63,32 @@ 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 + .settingQueryItems([.init(name: "raiting", value: rating.rawValue)]).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 { From 242ccf14fb8015a385cce4675631d448e481d331 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Thu, 5 Dec 2024 17:19:18 -0600 Subject: [PATCH 03/19] Update OpenAPI spec: Avatar.Rating --> AvatarRating --- Sources/Gravatar/OpenApi/Generated/Avatar.swift | 12 ++---------- openapi/openapi.yaml | 8 +------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/Sources/Gravatar/OpenApi/Generated/Avatar.swift b/Sources/Gravatar/OpenApi/Generated/Avatar.swift index 386b62d7..6cdb000e 100644 --- a/Sources/Gravatar/OpenApi/Generated/Avatar.swift +++ b/Sources/Gravatar/OpenApi/Generated/Avatar.swift @@ -3,19 +3,11 @@ import Foundation /// An avatar that the user has already uploaded to their Gravatar account. /// package struct Avatar: Codable, Hashable, Sendable { - package enum Rating: String, Codable, CaseIterable, Sendable { - case g = "G" - case pg = "PG" - case r = "R" - case x = "X" - } - /// Unique identifier for the image. package private(set) var imageId: String /// Image URL package private(set) var imageUrl: String - /// Rating associated with the image. - package private(set) var rating: Rating + package private(set) var rating: AvatarRating /// Alternative text description of the image. package private(set) var altText: String /// Whether the image is currently selected as the provided selected email's avatar. @@ -23,7 +15,7 @@ package struct Avatar: Codable, Hashable, Sendable { /// Date and time when the image was last updated. package private(set) var updatedDate: Date - package init(imageId: String, imageUrl: String, rating: Rating, altText: String, selected: Bool? = nil, updatedDate: Date) { + package init(imageId: String, imageUrl: String, rating: AvatarRating, altText: String, selected: Bool? = nil, updatedDate: Date) { self.imageId = imageId self.imageUrl = imageUrl self.rating = rating diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 1eee9014..2784282f 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -54,13 +54,7 @@ components: - >- https://gravatar.com/userimage/252014526/d38bele5a98a2bbc40df69172a2a8348.jpeg rating: - type: string - description: Rating associated with the image. - enum: - - G - - PG - - R - - X + $ref: '#/components/schemas/AvatarRating' alt_text: type: string description: Alternative text description of the image. From b948f5153daff3ac90d125f51acb3c5da15f3af5 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Thu, 5 Dec 2024 17:42:33 -0600 Subject: [PATCH 04/19] Add Rating functionality --- .../SwiftUI/AvatarPicker/AvatarAction.swift | 32 ++++--- .../AvatarPicker/AvatarImageModel.swift | 6 +- .../AvatarPicker/AvatarPickerView.swift | 4 + .../AvatarPicker/AvatarPickerViewModel.swift | 21 ++++- .../Views/AvatarPickerAvatarView.swift | 92 +++++++++++++++++-- 5 files changed, 131 insertions(+), 24 deletions(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift index d8cac7a4..ad8f9c76 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift @@ -1,24 +1,21 @@ import Foundation import SwiftUI -enum AvatarAction: String, CaseIterable, Identifiable { +enum AvatarAction: Identifiable { case share case delete + case rating(AvatarRating) case playground - static var allCases: [AvatarAction] { - var cases: [AvatarAction] = [] - if #available(iOS 18.2, *) { - if EnvironmentValues().supportsImagePlayground { - cases.append(.playground) - } + var id: String { + switch self { + case .share: "share" + case .delete: "delete" + case .rating(let rating): rating.rawValue + case .playground: "playground" } - cases.append(contentsOf: [.share, .delete]) - return cases } - var id: String { rawValue } - var icon: Image { switch self { case .delete: @@ -27,6 +24,8 @@ enum AvatarAction: String, CaseIterable, Identifiable { Image(systemName: "square.and.arrow.up") case .playground: Image(systemName: "apple.image.playground") + case .rating: + Image(systemName: "eye") } } @@ -50,6 +49,15 @@ enum AvatarAction: String, CaseIterable, Identifiable { value: "Playground", comment: "An option to show the image playground" ) + case .rating(let rating): + String( + format: SDKLocalizedString( + "AvatarPicker.AvatarAction.rate", + value: "Rating: %@", + comment: "An option in the avatar menu that shows the current rating, and allows the user to change that rating. The rating is used to indicate the appropriateness of an avatar for different audiences, and follows the US system of Motion Picture ratings: G, PG, R, and X." + ), + rating.rawValue + ) } } @@ -57,7 +65,7 @@ enum AvatarAction: String, CaseIterable, Identifiable { switch self { case .delete: .destructive - case .share, .playground: + case .share, .rating, .playground: nil } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift index 33e4c629..93dbaf14 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 rating: AvatarRating var url: URL? { guard case .remote(let url) = source else { @@ -46,14 +47,15 @@ struct AvatarImageModel: Hashable, Identifiable, Sendable { return image } - init(id: String, source: Source, state: State = .loaded, isSelected: Bool = false) { + init(id: String, source: Source, state: State = .loaded, isSelected: Bool = false, rating: AvatarRating = .g) { self.id = id self.source = source self.state = state self.isSelected = isSelected + self.rating = rating } func settingStatus(to newStatus: State) -> AvatarImageModel { - AvatarImageModel(id: id, source: source, state: newStatus, isSelected: isSelected) + AvatarImageModel(id: id, source: source, state: newStatus, isSelected: isSelected, rating: rating) } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift index 0b6b4516..05e45db1 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift @@ -383,6 +383,10 @@ struct AvatarPickerView: View { playgroundInputItem = PlaygroundInputItem(id: avatar.id, image: Image(uiImage: image)) } } + case .rating(let rating): + Task { + await model.setRating(rating, for: avatar) + } } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index 48ac556a..b07f9284 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) + let localImageModel = AvatarImageModel(id: localID, source: .local(image: image), state: .loading, rating: .g) grid.append(localImageModel) await doUpload(squareImage: image, localID: localID, accessToken: authToken) @@ -290,7 +290,8 @@ class AvatarPickerViewModel: ObservableObject { let newModel = AvatarImageModel( id: imageID, source: .local(image: squareImage), - state: .error(supportsRetry: supportsRetry, errorMessage: errorMessage) + state: .error(supportsRetry: supportsRetry, errorMessage: errorMessage), + rating: grid.model(with: imageID)?.rating ?? .g ) grid.replaceModel(withID: imageID, with: newModel) } @@ -337,6 +338,21 @@ class AvatarPickerViewModel: ObservableObject { await profile } + func setRating(_ rating: AvatarRating, for avatar: AvatarImageModel) async { + guard let authToken else { return } + + do { + let updatedAvatar = try await profileService.setRating( + rating, + for: .hashID(avatar.id), + token: authToken + ) + grid.replaceModel(withID: avatar.id, with: .init(with: updatedAvatar)) + } catch { + // TODO: Handle error + } + } + func delete(_ avatar: AvatarImageModel) async -> Bool { guard let token = self.authToken else { return false } defer { @@ -440,5 +456,6 @@ extension AvatarImageModel { source = .remote(url: avatar.url(withSize: String(avatarGridItemSize))) state = .loaded isSelected = avatar.isSelected + rating = avatar.rating } } diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift index 225ec099..bbaa1d65 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift @@ -80,25 +80,101 @@ struct AvatarPickerAvatarView: View { func actionsMenu() -> some View { Menu { - ForEach(AvatarAction.allCases) { action in + Section { + button(for: .share) + if #available(iOS 18.2, *) { + if EnvironmentValues().supportsImagePlayground { + button(for: .playground) + } + } + } + Section { + Menu { + ForEach(AvatarRating.allCases, id: \.self) { rating in + button(for: .rating(rating)) + } + } label: { + label(forAction: AvatarAction.rating(avatar.rating)) + } + } + Section { + button(for: .delete) + } + } label: { + ellipsisView() + } + } + + func button(for action: AvatarAction) -> some View { + Group { + switch action { + case .rating(let rating): Button(role: action.role) { onActionTap(action) } label: { - Label { - Text(action.localizedTitle) - } icon: { - action.icon + let buttonTitle = "\(rating.rawValue) (\(rating.localizedSubtitle))" + if rating == avatar.rating { + Label(buttonTitle, systemImage: "checkmark") + } else { + Text(buttonTitle) } } + case .delete, .playground, .share: + Button(role: action.role) { + onActionTap(action) + } label: { + label(forAction: action) + } } - } label: { - ellipsisView() + } + } + + func label(forAction action: AvatarAction) -> Label { + Label { + Text(action.localizedTitle) + } icon: { + action.icon + } + } +} + +extension AvatarRating { + fileprivate var localizedSubtitle: String { + switch self { + case .g: + SDKLocalizedString( + "Avatar.Rating.G.subtitle", + value: "General", + comment: "Rating that indicates that the avatar is suitable for everyone" + ) + case .pg: + SDKLocalizedString( + "Avatar.Rating.PG.subtitle", + value: "Parental Guidance", + comment: "Rating that indicates that the avatar may not be suitable for children" + ) + case .r: + SDKLocalizedString( + "Avatar.Rating.R.subtitle", + value: "Restricted", + comment: "Rating that indicates that the avatar may not be suitable for children" + ) + case .x: + SDKLocalizedString( + "Avatar.Rating.X.subtitle", + value: "Extreme", + comment: "Rating that indicates that the avatar is obviously and extremely suitable for children" + ) } } } #Preview { - let avatar = AvatarImageModel(id: "1", source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")) + let avatar = AvatarImageModel( + id: "1", + source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256"), + rating: .pg + ) return AvatarPickerAvatarView(avatar: avatar, maxLength: AvatarGridConstants.maxAvatarWidth, minLength: AvatarGridConstants.minAvatarWidth) { false } onAvatarTap: { _ in From c2874766e5dd7731af5d051b4e51f49e2cdb884e Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 6 Dec 2024 08:30:13 -0600 Subject: [PATCH 05/19] Revert "Update OpenAPI spec: Avatar.Rating --> AvatarRating" This reverts commit 242ccf14fb8015a385cce4675631d448e481d331. --- Sources/Gravatar/OpenApi/Generated/Avatar.swift | 12 ++++++++++-- openapi/openapi.yaml | 8 +++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/Sources/Gravatar/OpenApi/Generated/Avatar.swift b/Sources/Gravatar/OpenApi/Generated/Avatar.swift index 6cdb000e..386b62d7 100644 --- a/Sources/Gravatar/OpenApi/Generated/Avatar.swift +++ b/Sources/Gravatar/OpenApi/Generated/Avatar.swift @@ -3,11 +3,19 @@ import Foundation /// An avatar that the user has already uploaded to their Gravatar account. /// package struct Avatar: Codable, Hashable, Sendable { + package enum Rating: String, Codable, CaseIterable, Sendable { + case g = "G" + case pg = "PG" + case r = "R" + case x = "X" + } + /// Unique identifier for the image. package private(set) var imageId: String /// Image URL package private(set) var imageUrl: String - package private(set) var rating: AvatarRating + /// Rating associated with the image. + package private(set) var rating: Rating /// Alternative text description of the image. package private(set) var altText: String /// Whether the image is currently selected as the provided selected email's avatar. @@ -15,7 +23,7 @@ package struct Avatar: Codable, Hashable, Sendable { /// Date and time when the image was last updated. package private(set) var updatedDate: Date - package init(imageId: String, imageUrl: String, rating: AvatarRating, altText: String, selected: Bool? = nil, updatedDate: Date) { + package init(imageId: String, imageUrl: String, rating: Rating, altText: String, selected: Bool? = nil, updatedDate: Date) { self.imageId = imageId self.imageUrl = imageUrl self.rating = rating diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 2784282f..1eee9014 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -54,7 +54,13 @@ components: - >- https://gravatar.com/userimage/252014526/d38bele5a98a2bbc40df69172a2a8348.jpeg rating: - $ref: '#/components/schemas/AvatarRating' + type: string + description: Rating associated with the image. + enum: + - G + - PG + - R + - X alt_text: type: string description: Alternative text description of the image. From eade50617e75202986a4d4d5c69e41fc06875c48 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 6 Dec 2024 08:44:05 -0600 Subject: [PATCH 06/19] Bridge `Avatar.Rating` --> `AvatarRating` --- Sources/GravatarUI/Avatar+AvatarRating.swift | 13 +++++++++++++ .../AvatarPicker/AvatarPickerViewModel.swift | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 Sources/GravatarUI/Avatar+AvatarRating.swift diff --git a/Sources/GravatarUI/Avatar+AvatarRating.swift b/Sources/GravatarUI/Avatar+AvatarRating.swift new file mode 100644 index 00000000..489473cb --- /dev/null +++ b/Sources/GravatarUI/Avatar+AvatarRating.swift @@ -0,0 +1,13 @@ +extension Avatar { + /// Transforms `Avatar.Rating` into `AvatarRating` + /// This is only necessary while we maintain both enums. For our next major realease, `Avatar` will use the `AvatarRating` enum + /// rather than defining its own. + var avatarRating: AvatarRating { + switch self.rating { + case .g: .g + case .pg: .pg + case .r: .r + case .x: .x + } + } +} diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index b07f9284..f15a7186 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -456,6 +456,6 @@ extension AvatarImageModel { source = .remote(url: avatar.url(withSize: String(avatarGridItemSize))) state = .loaded isSelected = avatar.isSelected - rating = avatar.rating + rating = avatar.avatarRating } } From b2dfa180e1fc3e2eb0b7b089772b36536a019e05 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 6 Dec 2024 08:59:41 -0600 Subject: [PATCH 07/19] Change icon for Rating menu item --- Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift index ad8f9c76..b41ea362 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift @@ -25,7 +25,7 @@ enum AvatarAction: Identifiable { case .playground: Image(systemName: "apple.image.playground") case .rating: - Image(systemName: "eye") + Image(systemName: "star.leadinghalf.filled") } } From 32698cc9f058e09e934c0792a6a8dde06685a7c2 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 6 Dec 2024 12:37:37 -0600 Subject: [PATCH 08/19] Refactor into helper functions --- .../Views/AvatarPickerAvatarView.swift | 43 ++++++++++--------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift index bbaa1d65..d3171775 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift @@ -91,7 +91,7 @@ struct AvatarPickerAvatarView: View { Section { Menu { ForEach(AvatarRating.allCases, id: \.self) { rating in - button(for: .rating(rating)) + button(for: .rating(rating), isSelected: rating == avatar.rating) } } label: { label(forAction: AvatarAction.rating(avatar.rating)) @@ -105,35 +105,38 @@ struct AvatarPickerAvatarView: View { } } - func button(for action: AvatarAction) -> some View { - Group { + private func button( + for action: AvatarAction, + isSelected selected: Bool = false, + systemImageWhenSelected systemImage: String = "checkmark" + ) -> some View { + Button(role: action.role) { + onActionTap(action) + } label: { switch action { case .rating(let rating): - Button(role: action.role) { - onActionTap(action) - } label: { - let buttonTitle = "\(rating.rawValue) (\(rating.localizedSubtitle))" - if rating == avatar.rating { - Label(buttonTitle, systemImage: "checkmark") - } else { - Text(buttonTitle) - } + let buttonTitle = "\(rating.rawValue) (\(rating.localizedSubtitle))" + + if selected { + label(forAction: action, title: buttonTitle, systemImage: systemImage) + } else { + Text(buttonTitle) } case .delete, .playground, .share: - Button(role: action.role) { - onActionTap(action) - } label: { - label(forAction: action) - } + label(forAction: action) } } } - func label(forAction action: AvatarAction) -> Label { + private func label(forAction action: AvatarAction, title: String? = nil, systemImage: String) -> Label { + label(forAction: action, title: title, image: Image(systemName: systemImage)) + } + + private func label(forAction action: AvatarAction, title: String? = nil, image: Image? = nil) -> Label { Label { - Text(action.localizedTitle) + Text(title ?? action.localizedTitle) } icon: { - action.icon + image ?? action.icon } } } From 9cd0a3765bdc7abfc6b05498a50488d8601997be Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 6 Dec 2024 15:32:52 -0600 Subject: [PATCH 09/19] Handle errors --- .../AvatarPicker/AvatarPickerViewModel.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index f15a7186..6b874f56 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -348,8 +348,14 @@ class AvatarPickerViewModel: ObservableObject { token: authToken ) grid.replaceModel(withID: avatar.id, with: .init(with: updatedAvatar)) + } catch APIError.responseError(let reason) where reason.urlSessionErrorLocalizedDescription != nil { + handleError(message: reason.urlSessionErrorLocalizedDescription ?? Localized.avatarRatingError) } catch { - // TODO: Handle error + handleError(message: Localized.avatarRatingError) + } + + func handleError(message: String) { + toastManager.showToast(message, type: .error) } } @@ -435,6 +441,11 @@ 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 avatarRatingError = SDKLocalizedString( + "AvatarPickerViewModel.Rating.Error", + value: "Oops, something didn't quite work out while trying to rate your avatar.", + comment: "This error message shows when the user attempts to change the rating of an avatar and fails." + ) } } From 338c965b57325ea37024d3d7c44661e96fa20cc5 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Fri, 6 Dec 2024 17:44:45 -0600 Subject: [PATCH 10/19] Add unit tests --- .../TestHelpers/Bundle+ResourceBundle.swift | 4 +++ .../Resources/avatarSetRatingResponse.json | 7 +++++ Tests/GravatarTests/ProfileServiceTests.swift | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 Sources/TestHelpers/Resources/avatarSetRatingResponse.json diff --git a/Sources/TestHelpers/Bundle+ResourceBundle.swift b/Sources/TestHelpers/Bundle+ResourceBundle.swift index 2bbdda1a..5bf590b7 100644 --- a/Sources/TestHelpers/Bundle+ResourceBundle.swift +++ b/Sources/TestHelpers/Bundle+ResourceBundle.swift @@ -44,6 +44,10 @@ extension Bundle { testsBundle.jsonData(forResource: "avatarUploadResponse") } + public static var setRatingJsonData: Data { + testsBundle.jsonData(forResource: "avatarSetRatingResponse") + } + public static var getAvatarsJsonData: Data { testsBundle.jsonData(forResource: "avatarsResponse") } diff --git a/Sources/TestHelpers/Resources/avatarSetRatingResponse.json b/Sources/TestHelpers/Resources/avatarSetRatingResponse.json new file mode 100644 index 00000000..798b12ae --- /dev/null +++ b/Sources/TestHelpers/Resources/avatarSetRatingResponse.json @@ -0,0 +1,7 @@ +{ + "image_id": "6f3eac1c67f970f2a0c2ea8", + "image_url": "https://2.gravatar.com/userimage/133992/9862792c5653946c?size=512", + "rating": "G", + "updated_date": "2024-09-19T11:46:04Z", + "alt_text": "John Appleseed's avatar" +} diff --git a/Tests/GravatarTests/ProfileServiceTests.swift b/Tests/GravatarTests/ProfileServiceTests.swift index 56b5f6ae..1bf6c60d 100644 --- a/Tests/GravatarTests/ProfileServiceTests.swift +++ b/Tests/GravatarTests/ProfileServiceTests.swift @@ -70,4 +70,31 @@ 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) + } + } } From ed91a63e633fb2ec9aa1c36211fb48eaabae18ff Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Tue, 10 Dec 2024 10:40:40 -0600 Subject: [PATCH 11/19] Fix typo in localized string comment --- .../SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift index d3171775..3d0c0ef7 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AvatarPickerAvatarView.swift @@ -166,7 +166,7 @@ extension AvatarRating { SDKLocalizedString( "Avatar.Rating.X.subtitle", value: "Extreme", - comment: "Rating that indicates that the avatar is obviously and extremely suitable for children" + comment: "Rating that indicates that the avatar is obviously and extremely unsuitable for children" ) } } From 8f690dd9bac402c75d04f5a77585e3bce235f7a2 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Tue, 10 Dec 2024 10:44:10 -0600 Subject: [PATCH 12/19] Use animation when updating the grid after setting the avatar rating --- .../SwiftUI/AvatarPicker/AvatarPickerViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index 6b874f56..f33c5c87 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -347,7 +347,9 @@ class AvatarPickerViewModel: ObservableObject { for: .hashID(avatar.id), token: authToken ) - grid.replaceModel(withID: avatar.id, with: .init(with: updatedAvatar)) + withAnimation { + grid.replaceModel(withID: avatar.id, with: .init(with: updatedAvatar)) + } } catch APIError.responseError(let reason) where reason.urlSessionErrorLocalizedDescription != nil { handleError(message: reason.urlSessionErrorLocalizedDescription ?? Localized.avatarRatingError) } catch { From 4d8222dbe3861036a46222125d1b8c5d53cc82a1 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Tue, 10 Dec 2024 10:47:28 -0600 Subject: [PATCH 13/19] Add toast message when avatar rating update succeeds --- .../SwiftUI/AvatarPicker/AvatarPickerViewModel.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift index f33c5c87..8ea65c42 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -349,6 +349,7 @@ class AvatarPickerViewModel: ObservableObject { ) withAnimation { grid.replaceModel(withID: avatar.id, with: .init(with: updatedAvatar)) + toastManager.showToast(Localized.avatarRatingUpdateSuccess, type: .info) } } catch APIError.responseError(let reason) where reason.urlSessionErrorLocalizedDescription != nil { handleError(message: reason.urlSessionErrorLocalizedDescription ?? Localized.avatarRatingError) @@ -443,6 +444,11 @@ 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 avatarRatingUpdateSuccess = SDKLocalizedString( + "AvatarPickerViewModel.RatingUpdate.Success", + value: "Avatar rating was changed successfully", + comment: "This confirmation message shows when the user picks a different avatar rating and the change was applied successfully." + ) static let avatarRatingError = SDKLocalizedString( "AvatarPickerViewModel.Rating.Error", value: "Oops, something didn't quite work out while trying to rate your avatar.", From 18701f331f1aa3aaf1d23b7cf4c68e4830f2d43a Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Tue, 10 Dec 2024 12:40:19 -0600 Subject: [PATCH 14/19] Fix typo in `rating` query item --- Sources/Gravatar/Network/Services/ProfileService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Gravatar/Network/Services/ProfileService.swift b/Sources/Gravatar/Network/Services/ProfileService.swift index 228154c7..0cd18f5f 100644 --- a/Sources/Gravatar/Network/Services/ProfileService.swift +++ b/Sources/Gravatar/Network/Services/ProfileService.swift @@ -71,7 +71,7 @@ public struct ProfileService: ProfileFetching, Sendable { token: String ) async throws -> Avatar { guard let url = avatarsBaseURLComponents - .settingQueryItems([.init(name: "raiting", value: rating.rawValue)]).url? + .settingQueryItems([.init(name: "rating", value: rating.rawValue)]).url? .appendingPathComponent(avatar.id) else { throw APIError.requestError(reason: .urlInitializationFailed) From 5cd6c41d8b2ae9e94c5913a13ea1b0bf36c43d96 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Tue, 10 Dec 2024 12:41:04 -0600 Subject: [PATCH 15/19] Add unit tests AvatarPickerViewModel --- .../Resources/avatarSetRatingResponse.json | 10 +-- .../AvatarPickerViewModelTests.swift | 74 +++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/Sources/TestHelpers/Resources/avatarSetRatingResponse.json b/Sources/TestHelpers/Resources/avatarSetRatingResponse.json index 798b12ae..6a853791 100644 --- a/Sources/TestHelpers/Resources/avatarSetRatingResponse.json +++ b/Sources/TestHelpers/Resources/avatarSetRatingResponse.json @@ -1,7 +1,7 @@ { - "image_id": "6f3eac1c67f970f2a0c2ea8", - "image_url": "https://2.gravatar.com/userimage/133992/9862792c5653946c?size=512", - "rating": "G", - "updated_date": "2024-09-19T11:46:04Z", - "alt_text": "John Appleseed's avatar" + "image_id": "991a7b71cf9f34...", + "image_url": "https://2.gravatar.com/userimage/1/991a7b71cf934ea2...?size=512", + "rating": "PG", + "updated_date": "2024-09-18T10:17:53Z", + "alt_text": "John Appleseed's avatar" } diff --git a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift index a19eba58..4571bba6 100644 --- a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift +++ b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift @@ -211,6 +211,59 @@ final class AvatarPickerViewModelTests { #expect(model.grid.avatars.count == 1) } } + + @Test("Handle avatar rating change: Success") + func changeAvatarRatingSucceeds() async throws { + let testAvatarID = "991a7b71cf9f34..." + + await model.refresh() + let avatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID }), "No avatar found") + try #require(avatar.rating == .g) + + await confirmation { confirmation in + model.toastManager.$toasts.sink { toasts in + #expect(toasts.count <= 1) + if toasts.count == 1 { + #expect(toasts.first?.message == AvatarPickerViewModel.Localized.avatarRatingUpdateSuccess) + #expect(toasts.first?.type == .info) + confirmation.confirm() + } + }.store(in: &cancellables) + + await model.setRating(.pg, for: avatar) + } + let resultAvatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID })) + #expect(resultAvatar.rating == .pg) + } + + @Test( + "Handle avatar rating change: Failure", + arguments: [HTTPStatus.unauthorized, .forbidden] + ) + func changeAvatarRatingReturnsError(httpStatus: HTTPStatus) async throws { + let testAvatarID = "991a7b71cf9f34..." + model = Self.createModel(session: .init(returnErrorCode: httpStatus.rawValue)) + + await model.refresh() + let avatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID }), "No avatar found") + try #require(avatar.rating == .g) + + await confirmation { confirmation in + model.toastManager.$toasts.sink { toasts in + #expect(toasts.count <= 1) + if toasts.count == 1 { + #expect(toasts.first?.message == AvatarPickerViewModel.Localized.avatarRatingError) + #expect(toasts.first?.type == .error) + confirmation.confirm() + } + }.store(in: &cancellables) + + await model.setRating(.pg, for: avatar) + } + + let resultAvatar = try #require(model.grid.avatars.first(where: { $0.id == testAvatarID })) + #expect(resultAvatar.rating == .g, "The rating should not be changed") + } } final class URLSessionAvatarPickerMock: URLSessionProtocol { @@ -221,6 +274,10 @@ final class URLSessionAvatarPickerMock: URLSessionProtocol { case avatars } + enum QueryType: String { + case rating + } + init(returnErrorCode: Int? = nil) { self.returnErrorCode = returnErrorCode } @@ -231,6 +288,13 @@ final class URLSessionAvatarPickerMock: URLSessionProtocol { return (Bundle.postAvatarSelectedJsonData, HTTPURLResponse.successResponse()) // Avatars data } } + if isSetAvatarRatingRequest(request) { + if let returnErrorCode { + return (Data("".utf8), HTTPURLResponse.errorResponse(code: returnErrorCode)) + } else { + return (Bundle.setRatingJsonData, HTTPURLResponse.successResponse()) + } + } if request.url?.absoluteString.contains(RequestType.profiles.rawValue) == true { return (Bundle.fullProfileJsonData, HTTPURLResponse.successResponse()) // Profile data } else if request.url?.absoluteString.contains(RequestType.avatars.rawValue) == true { @@ -245,4 +309,14 @@ final class URLSessionAvatarPickerMock: URLSessionProtocol { } return (Bundle.postAvatarUploadJsonData, HTTPURLResponse.successResponse()) } + + private func isSetAvatarRatingRequest(_ request: URLRequest) -> Bool { + guard request.httpMethod == "PATCH", + request.url?.absoluteString.contains(RequestType.avatars.rawValue) == true, + request.url?.query?.contains(QueryType.rating.rawValue) == true + else { + return false + } + return true + } } From 2eb1a9b8dcc102c0d2aaf7d83135eec82802085f Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Tue, 10 Dec 2024 14:51:59 -0600 Subject: [PATCH 16/19] Remove unused queryItem --- Sources/Gravatar/Network/Services/ProfileService.swift | 4 +--- Tests/GravatarUITests/AvatarPickerViewModelTests.swift | 9 ++++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Sources/Gravatar/Network/Services/ProfileService.swift b/Sources/Gravatar/Network/Services/ProfileService.swift index 0cd18f5f..0f1bdf73 100644 --- a/Sources/Gravatar/Network/Services/ProfileService.swift +++ b/Sources/Gravatar/Network/Services/ProfileService.swift @@ -70,9 +70,7 @@ public struct ProfileService: ProfileFetching, Sendable { for avatar: AvatarIdentifier, token: String ) async throws -> Avatar { - guard let url = avatarsBaseURLComponents - .settingQueryItems([.init(name: "rating", value: rating.rawValue)]).url? - .appendingPathComponent(avatar.id) + guard let url = avatarsBaseURLComponents.url?.appendingPathComponent(avatar.id) else { throw APIError.requestError(reason: .urlInitializationFailed) } diff --git a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift index 4571bba6..0b56b0b7 100644 --- a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift +++ b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift @@ -274,10 +274,6 @@ final class URLSessionAvatarPickerMock: URLSessionProtocol { case avatars } - enum QueryType: String { - case rating - } - init(returnErrorCode: Int? = nil) { self.returnErrorCode = returnErrorCode } @@ -313,7 +309,10 @@ final class URLSessionAvatarPickerMock: URLSessionProtocol { private func isSetAvatarRatingRequest(_ request: URLRequest) -> Bool { guard request.httpMethod == "PATCH", request.url?.absoluteString.contains(RequestType.avatars.rawValue) == true, - request.url?.query?.contains(QueryType.rating.rawValue) == true + let bodyData = request.httpBody, + let updateAvatarRequestBody = try? JSONDecoder().decode(UpdateAvatarRequest.self, from: bodyData), + updateAvatarRequestBody.rating != nil + else { return false } From a840f86ac61872801fc1413690908fd0a01d8497 Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Tue, 10 Dec 2024 16:00:04 -0600 Subject: [PATCH 17/19] Refactor data for URLSessionAvatarPickerMock --- .../AvatarPickerViewModelTests.swift | 66 +++++++++++++------ 1 file changed, 46 insertions(+), 20 deletions(-) diff --git a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift index 0b56b0b7..bb113355 100644 --- a/Tests/GravatarUITests/AvatarPickerViewModelTests.swift +++ b/Tests/GravatarUITests/AvatarPickerViewModelTests.swift @@ -1,5 +1,6 @@ import Combine import Foundation +@testable import Gravatar @testable import GravatarUI import TestHelpers import Testing @@ -269,53 +270,78 @@ final class AvatarPickerViewModelTests { final class URLSessionAvatarPickerMock: URLSessionProtocol { let returnErrorCode: Int? - enum RequestType: String { - case profiles - case avatars - } - init(returnErrorCode: Int? = nil) { self.returnErrorCode = returnErrorCode } func data(for request: URLRequest) async throws -> (Data, URLResponse) { - if request.httpMethod == "POST" { - if request.url?.absoluteString.contains(RequestType.avatars.rawValue) == true { - return (Bundle.postAvatarSelectedJsonData, HTTPURLResponse.successResponse()) // Avatars data - } + if request.isSetAvatarForEmailRequest { + return (Bundle.postAvatarSelectedJsonData, HTTPURLResponse.successResponse()) // Avatars data } - if isSetAvatarRatingRequest(request) { + + if request.isSetAvatarRatingRequest { if let returnErrorCode { return (Data("".utf8), HTTPURLResponse.errorResponse(code: returnErrorCode)) } else { - return (Bundle.setRatingJsonData, HTTPURLResponse.successResponse()) + return (Bundle.setRatingJsonData, HTTPURLResponse.successResponse()) // Avatar data } } - if request.url?.absoluteString.contains(RequestType.profiles.rawValue) == true { + + if request.isProfilesRequest { return (Bundle.fullProfileJsonData, HTTPURLResponse.successResponse()) // Profile data - } else if request.url?.absoluteString.contains(RequestType.avatars.rawValue) == true { + } else if request.isAvatarsRequest == true { return (Bundle.getAvatarsJsonData, HTTPURLResponse.successResponse()) // Avatars data } + fatalError("Request not mocked: \(request.url?.absoluteString ?? "unknown request")") } func upload(for request: URLRequest, from bodyData: Data) async throws -> (Data, URLResponse) { if let returnErrorCode { - return ("".data(using: .utf8)!, HTTPURLResponse.errorResponse(code: returnErrorCode)) + return (Data("".utf8), HTTPURLResponse.errorResponse(code: returnErrorCode)) } return (Bundle.postAvatarUploadJsonData, HTTPURLResponse.successResponse()) } +} - private func isSetAvatarRatingRequest(_ request: URLRequest) -> Bool { - guard request.httpMethod == "PATCH", - request.url?.absoluteString.contains(RequestType.avatars.rawValue) == true, - let bodyData = request.httpBody, - let updateAvatarRequestBody = try? JSONDecoder().decode(UpdateAvatarRequest.self, from: bodyData), - updateAvatarRequestBody.rating != nil +extension URLRequest { + private enum RequestType: String { + case profiles + case avatars + } + fileprivate var isAvatarsRequest: Bool { + self.url?.absoluteString.contains(RequestType.avatars.rawValue) == true + } + + fileprivate var isProfilesRequest: Bool { + self.url?.absoluteString.contains(RequestType.profiles.rawValue) == true + } + + fileprivate var isSetAvatarRatingRequest: Bool { + guard self.httpMethod == "PATCH", + self.isAvatarsRequest, + self.httpBody.isDecodable(asType: UpdateAvatarRequest.self) + else { + return false + } + return true + } + + fileprivate var isSetAvatarForEmailRequest: Bool { + guard self.httpMethod == "POST", + self.isAvatarsRequest, + self.httpBody.isDecodable(asType: SetEmailAvatarRequest.self) else { return false } return true } } + +extension Data? { + fileprivate func isDecodable(asType type: T.Type, using decoder: JSONDecoder = JSONDecoder()) -> Bool { + guard let self else { return false } + return (try? decoder.decode(T.self, from: self)) != nil + } +} From 89751beef3b6401f6ee4b08c4d05a8725262661e Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Wed, 11 Dec 2024 09:52:43 -0600 Subject: [PATCH 18/19] Fix typo in localized string source --- .../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 8ea65c42..b0d058fa 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -446,7 +446,7 @@ extension AvatarPickerViewModel { ) static let avatarRatingUpdateSuccess = SDKLocalizedString( "AvatarPickerViewModel.RatingUpdate.Success", - value: "Avatar rating was changed successfully", + value: "Avatar rating was changed successfully.", comment: "This confirmation message shows when the user picks a different avatar rating and the change was applied successfully." ) static let avatarRatingError = SDKLocalizedString( From 72329daa3f8de0bee6619ae6a17ec14a99c23baa Mon Sep 17 00:00:00 2001 From: Andrew Montgomery Date: Wed, 11 Dec 2024 09:52:59 -0600 Subject: [PATCH 19/19] Update strings in base locale --- .../Resources/en.lproj/Localizable.strings | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Sources/GravatarUI/Resources/en.lproj/Localizable.strings b/Sources/GravatarUI/Resources/en.lproj/Localizable.strings index a1e77aed..d4982f0d 100644 --- a/Sources/GravatarUI/Resources/en.lproj/Localizable.strings +++ b/Sources/GravatarUI/Resources/en.lproj/Localizable.strings @@ -1,6 +1,21 @@ +/* Rating that indicates that the avatar is suitable for everyone */ +"Avatar.Rating.G.subtitle" = "General"; + +/* Rating that indicates that the avatar may not be suitable for children */ +"Avatar.Rating.PG.subtitle" = "Parental Guidance"; + +/* Rating that indicates that the avatar may not be suitable for children */ +"Avatar.Rating.R.subtitle" = "Restricted"; + +/* Rating that indicates that the avatar is obviously and extremely unsuitable for children */ +"Avatar.Rating.X.subtitle" = "Extreme"; + /* An option in the avatar menu that deletes the avatar */ "AvatarPicker.AvatarAction.delete" = "Delete"; +/* An option in the avatar menu that shows the current rating, and allows the user to change that rating. The rating is used to indicate the appropriateness of an avatar for different audiences, and follows the US system of Motion Picture ratings: G, PG, R, and X. */ +"AvatarPicker.AvatarAction.rate" = "Rating: %@"; + /* An option in the avatar menu that shares the avatar */ "AvatarPicker.AvatarAction.share" = "Share..."; @@ -82,6 +97,12 @@ /* This error message shows when the user attempts to delete an avatar and fails. */ "AvatarPickerViewModel.Delete.Error" = "Oops, there was an error deleting the image."; +/* This error message shows when the user attempts to change the rating of an avatar and fails. */ +"AvatarPickerViewModel.Rating.Error" = "Oops, something didn't quite work out while trying to rate your avatar."; + +/* This confirmation message shows when the user picks a different avatar rating and the change was applied successfully. */ +"AvatarPickerViewModel.RatingUpdate.Success" = "Avatar rating was changed successfully."; + /* 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.";