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 all 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 = 356d43dacd780d6476f503e0a4c0af090fd83762;
};
};
/* 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: "")
}
}
187 changes: 113 additions & 74 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 onActionTap: (AvatarAction) -> Void
let avatarUploadErrorAction: (AvatarUploadErrorAction) -> Void
let onActionSelected: (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)
.avatarUploadErrorDialog(isPresented: $presentUploadErrorActions, uploadError: $uploadError, action: { action in
avatarUploadErrorAction(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
onActionSelected(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
}
} avatarUploadErrorAction: { _ in
} onActionSelected: { _ in }
AvatarPickerAvatarView(avatar: avatar, maxSize: 90, minSize: 80) {
true
} avatarUploadErrorAction: { _ in
} onActionSelected: { _ in }
AvatarPickerAvatarView(avatar: avatarLoading, maxSize: 90, minSize: 80) {
true
} avatarUploadErrorAction: { _ in
} onActionSelected: { _ in }
AvatarPickerAvatarView(avatar: avatarError, maxSize: 90, minSize: 80) {
true
} avatarUploadErrorAction: { _ in
} onActionSelected: { _ in }
AvatarPickerAvatarView(avatar: avatarErrorNoRetry, maxSize: 90, minSize: 80) {
true
} avatarUploadErrorAction: { _ in
} onActionSelected: { _ in }
}
Loading