Skip to content

Commit

Permalink
QE: Avatar Rating (#589)
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewdmontgomery authored Dec 11, 2024
2 parents 4639942 + b535bac commit 2c54ae0
Show file tree
Hide file tree
Showing 13 changed files with 372 additions and 48 deletions.
30 changes: 29 additions & 1 deletion Sources/Gravatar/Network/Services/ProfileService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -59,6 +63,30 @@ 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
13 changes: 13 additions & 0 deletions Sources/GravatarUI/Avatar+AvatarRating.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
21 changes: 21 additions & 0 deletions Sources/GravatarUI/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -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...";

Expand Down Expand Up @@ -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.";

Expand Down
32 changes: 20 additions & 12 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarAction.swift
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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: "star.leadinghalf.filled")
}
}

Expand All @@ -50,14 +49,23 @@ 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
)
}
}

var role: ButtonRole? {
switch self {
case .delete:
.destructive
case .share, .playground:
case .share, .rating, .playground:
nil
}
}
Expand Down
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 rating: AvatarRating

var url: URL? {
guard case .remote(let url) = source else {
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,10 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
playgroundInputItem = PlaygroundInputItem(id: avatar.id, image: Image(uiImage: image))
}
}
case .rating(let rating):
Task {
await model.setRating(rating, for: avatar)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -337,6 +338,30 @@ 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
)
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)
} catch {
handleError(message: Localized.avatarRatingError)
}

func handleError(message: String) {
toastManager.showToast(message, type: .error)
}
}

func delete(_ avatar: AvatarImageModel) async -> Bool {
guard let token = self.authToken else { return false }
defer {
Expand Down Expand Up @@ -419,6 +444,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 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.",
comment: "This error message shows when the user attempts to change the rating of an avatar and fails."
)
}
}

Expand All @@ -440,5 +475,6 @@ extension AvatarImageModel {
source = .remote(url: avatar.url(withSize: String(avatarGridItemSize)))
state = .loaded
isSelected = avatar.isSelected
rating = avatar.avatarRating
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,25 +80,104 @@ struct AvatarPickerAvatarView: View {

func actionsMenu() -> some View {
Menu {
ForEach(AvatarAction.allCases) { action in
Button(role: action.role) {
onActionTap(action)
} label: {
Label {
Text(action.localizedTitle)
} icon: {
action.icon
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), isSelected: rating == avatar.rating)
}
} label: {
label(forAction: AvatarAction.rating(avatar.rating))
}
}
Section {
button(for: .delete)
}
} label: {
ellipsisView()
}
}

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):
let buttonTitle = "\(rating.rawValue) (\(rating.localizedSubtitle))"

if selected {
label(forAction: action, title: buttonTitle, systemImage: systemImage)
} else {
Text(buttonTitle)
}
case .delete, .playground, .share:
label(forAction: action)
}
}
}

private func label(forAction action: AvatarAction, title: String? = nil, systemImage: String) -> Label<Text, Image> {
label(forAction: action, title: title, image: Image(systemName: systemImage))
}

private func label(forAction action: AvatarAction, title: String? = nil, image: Image? = nil) -> Label<Text, Image> {
Label {
Text(title ?? action.localizedTitle)
} icon: {
image ?? 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 unsuitable 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
Expand Down
4 changes: 4 additions & 0 deletions Sources/TestHelpers/Bundle+ResourceBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Loading

0 comments on commit 2c54ae0

Please sign in to comment.