Skip to content
This repository was archived by the owner on Aug 21, 2025. It is now read-only.

Commit a7a7744

Browse files
authored
Avatar upload (#21)
1 parent 9a16be2 commit a7a7744

23 files changed

+1246
-163
lines changed

GravatarApp.xcodeproj/project.pbxproj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@
364364
ENABLE_USER_SCRIPT_SANDBOXING = YES;
365365
GENERATE_INFOPLIST_FILE = YES;
366366
INCLUDED_SOURCE_FILE_NAMES = "";
367+
INFOPLIST_KEY_NSCameraUsageDescription = "Upload new avatars";
367368
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
368369
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
369370
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -404,6 +405,7 @@
404405
ENABLE_USER_SCRIPT_SANDBOXING = YES;
405406
GENERATE_INFOPLIST_FILE = YES;
406407
INCLUDED_SOURCE_FILE_NAMES = "";
408+
INFOPLIST_KEY_NSCameraUsageDescription = "Upload new avatars";
407409
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
408410
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
409411
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -536,6 +538,7 @@
536538
CODE_SIGN_ENTITLEMENTS = GravatarApp/GravatarApp.entitlements;
537539
ENABLE_PREVIEWS = YES;
538540
GENERATE_INFOPLIST_FILE = YES;
541+
INFOPLIST_KEY_NSCameraUsageDescription = "Upload new avatars";
539542
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
540543
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
541544
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@@ -634,7 +637,7 @@
634637
repositoryURL = "https://github.com/Automattic/Gravatar-SDK-iOS.git";
635638
requirement = {
636639
kind = revision;
637-
revision = f32fe832d31fd19df4b6d94800abb231bfd085c8;
640+
revision = 356d43dacd780d6476f503e0a4c0af090fd83762;
638641
};
639642
};
640643
/* End XCRemoteSwiftPackageReference section */

GravatarApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import SwiftUI
2+
3+
struct AvatarActionsMenu<Label>: View where Label: View {
4+
let isAvatarSelected: Bool
5+
let label: () -> Label
6+
let onActionSelected: (AvatarAction) -> Void
7+
8+
var body: some View {
9+
actionsMenu(isSelected: isAvatarSelected, label: label)
10+
}
11+
12+
func actionsMenu(isSelected: Bool, label: () -> Label) -> some View {
13+
Menu {
14+
Section {
15+
if !isSelected {
16+
button(for: .select)
17+
}
18+
button(for: .share)
19+
// TODO: We might use this soon, so keeping it commented for now
20+
/**
21+
if #available(iOS 18.2, *) {
22+
if EnvironmentValues().supportsImagePlayground {
23+
button(for: .playground)
24+
}
25+
}
26+
*/
27+
button(for: .altText)
28+
}
29+
Section {
30+
button(for: .delete)
31+
}
32+
} label: {
33+
label()
34+
}
35+
}
36+
37+
private func button(
38+
for action: AvatarAction,
39+
isSelected selected: Bool = false,
40+
systemImageWhenSelected systemImage: String = "checkmark"
41+
) -> some View {
42+
Button(role: action.role) {
43+
onActionSelected(action)
44+
} label: {
45+
buttonLabel(forAction: action)
46+
}
47+
}
48+
49+
private func buttonLabel(forAction action: AvatarAction, title: String? = nil, systemImage: String) -> SwiftUI.Label<Text, Image> {
50+
buttonLabel(forAction: action, title: title, image: Image(systemName: systemImage))
51+
}
52+
53+
private func buttonLabel(forAction action: AvatarAction, title: String? = nil, image: Image? = nil) -> SwiftUI.Label<Text, Image> {
54+
SwiftUI.Label {
55+
Text(title ?? action.localizedTitle)
56+
} icon: {
57+
image ?? action.icon
58+
}
59+
}
60+
}

GravatarApp/AvatarPicker/Avatar/AvatarImageModel.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,12 @@ extension AvatarImageModel {
103103

104104
extension AvatarImageModel {
105105
/// This is meant to be used in previews and unit tests only.
106-
static func preview_init(id: String, source: Source, state: State = .loaded, isSelected: Bool = false) -> Self {
106+
static func preview_init(
107+
id: String = "1",
108+
source: Source = .remote(url: "https://gravatar.com/"),
109+
state: State = .loaded,
110+
isSelected: Bool = false
111+
) -> Self {
107112
AvatarImageModel(id: id, source: source, state: state, isSelected: isSelected, altText: "")
108113
}
109114
}
Lines changed: 113 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,135 @@
11
import GravatarUI
22
import SwiftUI
33

4-
struct FailedUploadInfo {
5-
let avatarLocalID: String
6-
let supportsRetry: Bool
7-
let errorMessage: String
8-
}
9-
10-
extension CGFloat {
11-
fileprivate static let horizontalPadding: CGFloat = .DS.Padding.double
12-
fileprivate static let selectedBorderWidth: CGFloat = 3
13-
fileprivate static let avatarCornerRadius: CGFloat = 6
14-
}
15-
164
struct AvatarPickerAvatarView: View {
175
let avatar: AvatarImageModel
186
let maxSize: CGFloat
197
let minSize: CGFloat
208
let shouldSelect: () -> Bool
21-
let onFailedUploadTapped: (FailedUploadInfo) -> Void
22-
let onActionTap: (AvatarAction) -> Void
9+
let avatarUploadErrorAction: (AvatarUploadErrorAction) -> Void
10+
let onActionSelected: (AvatarAction) -> Void
11+
12+
@State private var uploadError: AvatarUploadErrorInfo?
13+
@State private var presentUploadErrorActions: Bool = false
2314

2415
var body: some View {
25-
ZStack(alignment: .bottomTrailing) {
26-
AvatarView(
27-
url: avatar.url,
28-
placeholderView: {
29-
avatar.localImage?.resizable()
30-
},
31-
loadingView: {
32-
ProgressView()
33-
.progressViewStyle(CircularProgressViewStyle())
34-
},
35-
transaction: .init(animation: .smooth)
36-
)
37-
.scaledToFill()
38-
.frame(minWidth: minSize, maxWidth: maxSize, minHeight: minSize, maxHeight: maxSize)
39-
.background(Color(UIColor.secondarySystemBackground))
40-
.aspectRatio(1, contentMode: .fill)
41-
.shape(
42-
RoundedRectangle(cornerRadius: .avatarCornerRadius),
43-
borderColor: Color.clear,
44-
borderWidth: 0
45-
)
46-
.overlay {
47-
switch avatar.state {
48-
case .loading:
49-
DimmingActivityIndicator()
50-
.cornerRadius(.avatarCornerRadius)
51-
case .error(let supportsRetry, let errorMessage):
52-
DimmingErrorButton {
53-
onFailedUploadTapped(
54-
.init(
55-
avatarLocalID: avatar.id,
56-
supportsRetry: supportsRetry,
57-
errorMessage: errorMessage
58-
)
59-
)
60-
}
61-
.cornerRadius(.avatarCornerRadius)
62-
case .loaded:
63-
if shouldSelect() {
64-
ZStack {
65-
// We want an inner border, so we draw it in the overlay
66-
RoundedRectangle(cornerRadius: .avatarCornerRadius)
67-
.stroke(Color.primary, lineWidth: .selectedBorderWidth)
68-
.padding(1)
69-
CheckmarkCircleView()
70-
.transition(.scale)
71-
}
72-
} else {
73-
EmptyView()
74-
}
75-
}
76-
}
77-
.transition(.opacity)
16+
AvatarView(
17+
url: avatar.url,
18+
placeholderView: {
19+
avatar.localImage?.resizable()
20+
},
21+
loadingView: {
22+
ProgressView()
23+
.progressViewStyle(CircularProgressViewStyle())
24+
},
25+
transaction: .init(animation: .smooth)
26+
)
27+
.scaledToFill()
28+
.frame(minWidth: minSize, maxWidth: maxSize, minHeight: minSize, maxHeight: maxSize)
29+
.background(Color(UIColor.secondarySystemBackground))
30+
.aspectRatio(1, contentMode: .fill)
31+
.shape(
32+
RoundedRectangle(cornerRadius: .avatarCornerRadius),
33+
borderColor: Color.clear,
34+
borderWidth: 0
35+
)
36+
.overlay {
37+
avatarOverlayView(for: avatar.state)
7838
}
39+
.transition(.opacity)
40+
.avatarUploadErrorDialog(isPresented: $presentUploadErrorActions, uploadError: $uploadError, action: { action in
41+
avatarUploadErrorAction(action)
42+
})
7943
.accessibilityElement(children: .combine)
8044
.accessibilityAddTraits(.isButton)
8145
.accessibilityAddTraits(shouldSelect() ? .isSelected : [])
8246
.accessibilityLabel(Text(avatar.accessibilityLabel(altText: avatar.altText)))
8347
}
48+
49+
@ViewBuilder
50+
private func avatarOverlayView(for state: AvatarImageModel.State) -> some View {
51+
switch state {
52+
case .loading:
53+
loadingOverlayView()
54+
case .error(let supportsRetry, let errorMessage):
55+
errorOverlayView(supportsRetry: supportsRetry, errorMessage: errorMessage)
56+
case .loaded:
57+
loadedOverlayView(avatarSelected: shouldSelect())
58+
}
59+
}
60+
61+
private func loadingOverlayView() -> some View {
62+
DimmingActivityIndicator()
63+
.cornerRadius(.avatarCornerRadius)
64+
}
65+
66+
private func errorOverlayView(supportsRetry: Bool, errorMessage: String) -> some View {
67+
DimmingErrorButton {
68+
uploadError = AvatarUploadErrorInfo(avatarLocalID: avatar.id, supportsRetry: supportsRetry, errorMessage: errorMessage)
69+
presentUploadErrorActions = true
70+
}
71+
.cornerRadius(.avatarCornerRadius)
72+
}
73+
74+
@ViewBuilder
75+
private func loadedOverlayView(avatarSelected: Bool) -> some View {
76+
if avatarSelected {
77+
selectedCheckmarkView()
78+
}
79+
AvatarActionsMenu(isAvatarSelected: avatarSelected) {
80+
Color.clear
81+
} onActionSelected: { action in
82+
onActionSelected(action)
83+
}
84+
}
85+
86+
private func selectedCheckmarkView() -> some View {
87+
ZStack {
88+
// We want an inner border, so we draw it in the overlay
89+
RoundedRectangle(cornerRadius: .avatarCornerRadius)
90+
.stroke(Color.primary, lineWidth: .selectedBorderWidth)
91+
.padding(1)
92+
CheckmarkCircleView()
93+
.transition(.scale)
94+
}
95+
}
96+
}
97+
98+
extension CGFloat {
99+
fileprivate static let horizontalPadding: CGFloat = .DS.Padding.double
100+
fileprivate static let selectedBorderWidth: CGFloat = 3
101+
fileprivate static let avatarCornerRadius: CGFloat = 6
84102
}
85103

86104
#Preview {
87-
let avatar = AvatarImageModel.preview_init(
88-
id: "1",
89-
source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")
90-
)
105+
let avatar = AvatarImageModel.preview_init()
106+
let avatarLoading = AvatarImageModel.preview_init(state: .loading)
107+
let avatarError = AvatarImageModel.preview_init(state: .error(
108+
supportsRetry: true,
109+
errorMessage: "Something went wrong. Retry?"
110+
))
111+
let avatarErrorNoRetry = AvatarImageModel.preview_init(state: .error(
112+
supportsRetry: false,
113+
errorMessage: "Something terrible happened."
114+
))
91115
AvatarPickerAvatarView(avatar: avatar, maxSize: 90, minSize: 80) {
92116
false
93-
} onFailedUploadTapped: { _ in
94-
} onActionTap: { _ in
95-
}
117+
} avatarUploadErrorAction: { _ in
118+
} onActionSelected: { _ in }
119+
AvatarPickerAvatarView(avatar: avatar, maxSize: 90, minSize: 80) {
120+
true
121+
} avatarUploadErrorAction: { _ in
122+
} onActionSelected: { _ in }
123+
AvatarPickerAvatarView(avatar: avatarLoading, maxSize: 90, minSize: 80) {
124+
true
125+
} avatarUploadErrorAction: { _ in
126+
} onActionSelected: { _ in }
127+
AvatarPickerAvatarView(avatar: avatarError, maxSize: 90, minSize: 80) {
128+
true
129+
} avatarUploadErrorAction: { _ in
130+
} onActionSelected: { _ in }
131+
AvatarPickerAvatarView(avatar: avatarErrorNoRetry, maxSize: 90, minSize: 80) {
132+
true
133+
} avatarUploadErrorAction: { _ in
134+
} onActionSelected: { _ in }
96135
}

0 commit comments

Comments
 (0)