Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

QE: Avatar Rating #589

Merged
merged 20 commits into from
Dec 11, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0d83dd4
Update OpenAPI spec
andrewdmontgomery Dec 4, 2024
83f1de6
Add setRating function
andrewdmontgomery Dec 4, 2024
242ccf1
Update OpenAPI spec: Avatar.Rating --> AvatarRating
andrewdmontgomery Dec 5, 2024
b948f51
Add Rating functionality
andrewdmontgomery Dec 5, 2024
c287476
Revert "Update OpenAPI spec: Avatar.Rating --> AvatarRating"
andrewdmontgomery Dec 6, 2024
eade506
Bridge `Avatar.Rating` --> `AvatarRating`
andrewdmontgomery Dec 6, 2024
b2dfa18
Change icon for Rating menu item
andrewdmontgomery Dec 6, 2024
32698cc
Refactor into helper functions
andrewdmontgomery Dec 6, 2024
9cd0a37
Handle errors
andrewdmontgomery Dec 6, 2024
338c965
Add unit tests
andrewdmontgomery Dec 6, 2024
ed91a63
Fix typo in localized string comment
andrewdmontgomery Dec 10, 2024
8f690dd
Use animation when updating the grid after setting the avatar rating
andrewdmontgomery Dec 10, 2024
4d8222d
Add toast message when avatar rating update succeeds
andrewdmontgomery Dec 10, 2024
18701f3
Fix typo in `rating` query item
andrewdmontgomery Dec 10, 2024
5cd6c41
Add unit tests AvatarPickerViewModel
andrewdmontgomery Dec 10, 2024
2eb1a9b
Remove unused queryItem
andrewdmontgomery Dec 10, 2024
a840f86
Refactor data for URLSessionAvatarPickerMock
andrewdmontgomery Dec 10, 2024
89751be
Fix typo in localized string source
andrewdmontgomery Dec 11, 2024
72329da
Update strings in base locale
andrewdmontgomery Dec 11, 2024
b535bac
Merge branch 'trunk' into andrewdmontgomery/qe-avatar-rating
andrewdmontgomery Dec 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 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,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 {
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 {
Copy link
Contributor Author

@andrewdmontgomery andrewdmontgomery Dec 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We only need this in one place, currently, and only until we release a new major release. But I wonder if it still makes sense to move it to Gravatar and use package, to match the access of Avatar.Rating.

switch self.rating {
case .g: .g
case .pg: .pg
case .r: .r
case .x: .x
}
}
}
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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we are laying out the menu manually, the CaseIterable is no longer needed.

I'm using an enum with an associated value (rating(AvatarRating)) in order to pass values back up the chain when a rating is tapped. So conforming to String is no longer possible.

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,27 @@ 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))
andrewdmontgomery marked this conversation as resolved.
Show resolved Hide resolved
andrewdmontgomery marked this conversation as resolved.
Show resolved Hide resolved
} 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 +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."
)
}
}

Expand All @@ -440,5 +467,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 suitable for children"
andrewdmontgomery marked this conversation as resolved.
Show resolved Hide resolved
)
}
}
}

#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
Loading