Skip to content
This repository was archived by the owner on Aug 21, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
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
5 changes: 4 additions & 1 deletion GravatarApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
INCLUDED_SOURCE_FILE_NAMES = "";
INFOPLIST_KEY_NSCameraUsageDescription = "Upload new avatars";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
Expand Down Expand Up @@ -404,6 +405,7 @@
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = YES;
INCLUDED_SOURCE_FILE_NAMES = "";
INFOPLIST_KEY_NSCameraUsageDescription = "Upload new avatars";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
Expand Down Expand Up @@ -536,6 +538,7 @@
CODE_SIGN_ENTITLEMENTS = GravatarApp/GravatarApp.entitlements;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSCameraUsageDescription = "Upload new avatars";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
Expand Down Expand Up @@ -634,7 +637,7 @@
repositoryURL = "https://github.com/Automattic/Gravatar-SDK-iOS.git";
requirement = {
kind = revision;
revision = f32fe832d31fd19df4b6d94800abb231bfd085c8;
revision = 69723225f3b65bd443f281d9eb71b6d94c5c6a43;
};
};
/* End XCRemoteSwiftPackageReference section */
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 60 additions & 0 deletions GravatarApp/AvatarPicker/Avatar/AvatarActionsMenu.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import SwiftUI

struct AvatarActionsMenu<Label>: View where Label: View {
let isAvatarSelected: Bool
let label: () -> Label
let onActionSelected: (AvatarAction) -> Void

var body: some View {
actionsMenu(isSelected: isAvatarSelected, label: label)
}

func actionsMenu(isSelected: Bool, label: () -> Label) -> some View {
Menu {
Section {
if !isSelected {
button(for: .select)
}
button(for: .share)
// TODO: We might use this soon, so keeping it commented for now
/**
if #available(iOS 18.2, *) {
if EnvironmentValues().supportsImagePlayground {
button(for: .playground)
}
}
*/
button(for: .altText)
}
Section {
button(for: .delete)
}
} label: {
label()
}
}

private func button(
for action: AvatarAction,
isSelected selected: Bool = false,
systemImageWhenSelected systemImage: String = "checkmark"
) -> some View {
Button(role: action.role) {
onActionSelected(action)
} label: {
buttonLabel(forAction: action)
}
}

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

private func buttonLabel(forAction action: AvatarAction, title: String? = nil, image: Image? = nil) -> SwiftUI.Label<Text, Image> {
SwiftUI.Label {
Text(title ?? action.localizedTitle)
} icon: {
image ?? action.icon
}
}
}
7 changes: 6 additions & 1 deletion GravatarApp/AvatarPicker/Avatar/AvatarImageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ extension AvatarImageModel {

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) -> Self {
static func preview_init(
id: String = "1",
source: Source = .remote(url: "https://gravatar.com/"),
state: State = .loaded,
isSelected: Bool = false
) -> Self {
AvatarImageModel(id: id, source: source, state: state, isSelected: isSelected, altText: "")
}
}
185 changes: 112 additions & 73 deletions GravatarApp/AvatarPicker/Avatar/AvatarPickerAvatarView.swift
Original file line number Diff line number Diff line change
@@ -1,96 +1,135 @@
import GravatarUI
import SwiftUI

struct FailedUploadInfo {
let avatarLocalID: String
let supportsRetry: Bool
let errorMessage: String
}

extension CGFloat {
fileprivate static let horizontalPadding: CGFloat = .DS.Padding.double
fileprivate static let selectedBorderWidth: CGFloat = 3
fileprivate static let avatarCornerRadius: CGFloat = 6
}

struct AvatarPickerAvatarView: View {
let avatar: AvatarImageModel
let maxSize: CGFloat
let minSize: CGFloat
let shouldSelect: () -> Bool
let onFailedUploadTapped: (FailedUploadInfo) -> Void
let onUploadFailedAction: (AvatarUploadFailedAction) -> Void
let onActionTap: (AvatarAction) -> Void

@State private var uploadError: AvatarUploadErrorInfo?
@State private var presentUploadErrorActions: Bool = false

var body: some View {
ZStack(alignment: .bottomTrailing) {
AvatarView(
url: avatar.url,
placeholderView: {
avatar.localImage?.resizable()
},
loadingView: {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
},
transaction: .init(animation: .smooth)
)
.scaledToFill()
.frame(minWidth: minSize, maxWidth: maxSize, minHeight: minSize, maxHeight: maxSize)
.background(Color(UIColor.secondarySystemBackground))
.aspectRatio(1, contentMode: .fill)
.shape(
RoundedRectangle(cornerRadius: .avatarCornerRadius),
borderColor: Color.clear,
borderWidth: 0
)
.overlay {
switch avatar.state {
case .loading:
DimmingActivityIndicator()
.cornerRadius(.avatarCornerRadius)
case .error(let supportsRetry, let errorMessage):
DimmingErrorButton {
onFailedUploadTapped(
.init(
avatarLocalID: avatar.id,
supportsRetry: supportsRetry,
errorMessage: errorMessage
)
)
}
.cornerRadius(.avatarCornerRadius)
case .loaded:
if shouldSelect() {
ZStack {
// We want an inner border, so we draw it in the overlay
RoundedRectangle(cornerRadius: .avatarCornerRadius)
.stroke(Color.primary, lineWidth: .selectedBorderWidth)
.padding(1)
CheckmarkCircleView()
.transition(.scale)
}
} else {
EmptyView()
}
}
}
.transition(.opacity)
AvatarView(
url: avatar.url,
placeholderView: {
avatar.localImage?.resizable()
},
loadingView: {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
},
transaction: .init(animation: .smooth)
)
.scaledToFill()
.frame(minWidth: minSize, maxWidth: maxSize, minHeight: minSize, maxHeight: maxSize)
.background(Color(UIColor.secondarySystemBackground))
.aspectRatio(1, contentMode: .fill)
.shape(
RoundedRectangle(cornerRadius: .avatarCornerRadius),
borderColor: Color.clear,
borderWidth: 0
)
.overlay {
avatarOverlayView(for: avatar.state)
}
.transition(.opacity)
.avatarErrorDialog(isPresented: $presentUploadErrorActions, uploadError: $uploadError, action: { action in
onUploadFailedAction(action)
})
.accessibilityElement(children: .combine)
.accessibilityAddTraits(.isButton)
.accessibilityAddTraits(shouldSelect() ? .isSelected : [])
.accessibilityLabel(Text(avatar.accessibilityLabel(altText: avatar.altText)))
}

@ViewBuilder
private func avatarOverlayView(for state: AvatarImageModel.State) -> some View {
switch state {
case .loading:
loadingOverlayView()
case .error(let supportsRetry, let errorMessage):
errorOverlayView(supportsRetry: supportsRetry, errorMessage: errorMessage)
case .loaded:
loadedOverlayView(avatarSelected: shouldSelect())
}
}

private func loadingOverlayView() -> some View {
DimmingActivityIndicator()
.cornerRadius(.avatarCornerRadius)
}

private func errorOverlayView(supportsRetry: Bool, errorMessage: String) -> some View {
DimmingErrorButton {
uploadError = AvatarUploadErrorInfo(avatarLocalID: avatar.id, supportsRetry: supportsRetry, errorMessage: errorMessage)
presentUploadErrorActions = true
}
.cornerRadius(.avatarCornerRadius)
}

@ViewBuilder
private func loadedOverlayView(avatarSelected: Bool) -> some View {
if avatarSelected {
selectedCheckmarkView()
}
AvatarActionsMenu(isAvatarSelected: avatarSelected) {
Color.clear
} onActionSelected: { action in
onActionTap(action)
}
}

private func selectedCheckmarkView() -> some View {
ZStack {
// We want an inner border, so we draw it in the overlay
RoundedRectangle(cornerRadius: .avatarCornerRadius)
.stroke(Color.primary, lineWidth: .selectedBorderWidth)
.padding(1)
CheckmarkCircleView()
.transition(.scale)
}
}
}

extension CGFloat {
fileprivate static let horizontalPadding: CGFloat = .DS.Padding.double
fileprivate static let selectedBorderWidth: CGFloat = 3
fileprivate static let avatarCornerRadius: CGFloat = 6
}

#Preview {
let avatar = AvatarImageModel.preview_init(
id: "1",
source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")
)
let avatar = AvatarImageModel.preview_init()
let avatarLoading = AvatarImageModel.preview_init(state: .loading)
let avatarError = AvatarImageModel.preview_init(state: .error(
supportsRetry: true,
errorMessage: "Something went wrong. Retry?"
))
let avatarErrorNoRetry = AvatarImageModel.preview_init(state: .error(
supportsRetry: false,
errorMessage: "Something terrible happened."
))
AvatarPickerAvatarView(avatar: avatar, maxSize: 90, minSize: 80) {
false
} onFailedUploadTapped: { _ in
} onActionTap: { _ in
}
} onUploadFailedAction: { _ in
} onActionTap: { _ in }
AvatarPickerAvatarView(avatar: avatar, maxSize: 90, minSize: 80) {
true
} onUploadFailedAction: { _ in
} onActionTap: { _ in }
AvatarPickerAvatarView(avatar: avatarLoading, maxSize: 90, minSize: 80) {
true
} onUploadFailedAction: { _ in
} onActionTap: { _ in }
AvatarPickerAvatarView(avatar: avatarError, maxSize: 90, minSize: 80) {
true
} onUploadFailedAction: { _ in
} onActionTap: { _ in }
AvatarPickerAvatarView(avatar: avatarErrorNoRetry, maxSize: 90, minSize: 80) {
true
} onUploadFailedAction: { _ in
} onActionTap: { _ in }
}
Loading