Skip to content

Commit 5c6ba69

Browse files
authored
Alt text UX changes (#608)
2 parents f96e799 + 999a9aa commit 5c6ba69

File tree

8 files changed

+143
-84
lines changed

8 files changed

+143
-84
lines changed

Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,10 +193,10 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
193193
.altTextSheet(
194194
model: $altTextEditorAvatar,
195195
email: model.email,
196+
toastManager: model.toastManager,
196197
onSave: { modifiedModel in
197-
altTextEditorAvatar = nil
198-
Task {
199-
await model.update(altText: modifiedModel.altText, for: modifiedModel)
198+
if await model.update(altText: modifiedModel.altText, for: modifiedModel) {
199+
altTextEditorAvatar = nil
200200
}
201201
},
202202
onCancel: {
@@ -656,7 +656,7 @@ private enum AvatarPicker {
656656
#Preview("Existing elements") {
657657
struct PreviewModel: ProfileSummaryModel {
658658
var avatarIdentifier: Gravatar.AvatarIdentifier? {
659-
.email("xxx@gmail.com")
659+
.email("some@email.com")
660660
}
661661

662662
var displayName: String {

Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,8 @@ class AvatarPickerViewModel: ObservableObject {
88
private let avatarService: AvatarService
99
private let imageDownloader: ImageDownloader
1010

11-
private(set) var email: Email? {
11+
private(set) var email: Email {
1212
didSet {
13-
guard let email else {
14-
avatarIdentifier = nil
15-
return
16-
}
1713
avatarIdentifier = .email(email)
1814
}
1915
}
@@ -82,13 +78,19 @@ class AvatarPickerViewModel: ObservableObject {
8278
self.selectedAvatarResult = .success(selectedImageID)
8379
}
8480

81+
self.email = .init("some@email.com")
82+
8583
grid.setAvatars(avatarImageModels)
8684
grid.selectAvatar(withID: selectedImageID)
8785
gridResponseStatus = .success(())
8886

8987
if let profileModel {
9088
self.profileResult = .success(profileModel)
91-
self.profileModel = .init(displayName: profileModel.displayName, location: profileModel.location, profileURL: profileModel.profileURL)
89+
self.profileModel = .init(
90+
displayName: profileModel.displayName,
91+
location: profileModel.location,
92+
profileURL: profileModel.profileURL
93+
)
9294
switch profileModel.avatarIdentifier {
9395
case .email(let email):
9496
self.email = email
@@ -100,7 +102,6 @@ class AvatarPickerViewModel: ObservableObject {
100102

101103
func selectAvatar(with id: String) async -> Avatar? {
102104
guard
103-
let email,
104105
let authToken,
105106
grid.selectedAvatar?.id != id,
106107
grid.model(with: id)?.state == .loaded
@@ -167,7 +168,7 @@ class AvatarPickerViewModel: ObservableObject {
167168
}
168169

169170
func fetchAvatars() async {
170-
guard let authToken, let email else { return }
171+
guard let authToken else { return }
171172

172173
do {
173174
isAvatarsLoading = true
@@ -186,7 +187,6 @@ class AvatarPickerViewModel: ObservableObject {
186187
}
187188

188189
func fetchProfile() async {
189-
guard let email else { return }
190190
do {
191191
isProfileLoading = true
192192
let profile = try await profileService.fetch(with: .email(email))
@@ -229,7 +229,6 @@ class AvatarPickerViewModel: ObservableObject {
229229
}
230230

231231
private func doUpload(squareImage: UIImage, localID: String, accessToken: String) async {
232-
guard let email else { return }
233232
do {
234233
let avatar = try await avatarService.upload(
235234
squareImage,
@@ -346,7 +345,6 @@ class AvatarPickerViewModel: ObservableObject {
346345
guard let token = self.authToken else { return false }
347346
do {
348347
let updatedAvatar = try await avatarService.update(altText: altText, avatarID: .hashID(avatar.id), accessToken: token)
349-
toastManager.showToast(Localized.avatarAltTextSuccess + "\n\n \"\(altText)\"")
350348
withAnimation {
351349
grid.replaceModel(withID: avatar.id, with: .init(with: updatedAvatar))
352350
}

Sources/GravatarUI/SwiftUI/AvatarPicker/Views/AltTextEditorView.swift

Lines changed: 84 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,60 +2,59 @@ import SwiftUI
22

33
struct AltTextEditorView: View {
44
let avatar: AvatarImageModel?
5-
let email: Email?
6-
7-
var shouldShowCharCount: Bool {
8-
altText.count > 0
9-
}
5+
let email: Email
106

117
@Environment(\.colorScheme) var colorScheme
128

139
@State var altText: String = ""
1410
@State var charCount: Int = 0
1511
@State var safariURL: URL? = nil
12+
@State var isLoading: Bool = false
13+
@ObservedObject var toastManager: ToastManager
1614

1715
@FocusState var focused: Bool
1816

19-
let onSave: (AvatarImageModel) -> Void
17+
let onSave: (AvatarImageModel) async -> Void
2018
let onCancel: () -> Void
2119

2220
var body: some View {
2321
// Scroll view helps detaching the height of the child view from the height of the parent view.
24-
// This avoids a UI problem while scrolling down the sheet whith the keyboard being present.
22+
// This avoids a UI problem while scrolling down the sheet with the keyboard being present.
2523
// GeometryReader also has the same effect. For now we want the content to scroll when the content grows.
2624
ScrollView {
27-
VStack {
28-
if let email {
25+
ZStack {
26+
VStack {
2927
EmailText(email: email)
30-
}
31-
VStack(alignment: .leading) {
32-
HStack {
33-
titleText
34-
Spacer()
35-
altTextHelpButton
36-
}
37-
ZStack(alignment: .bottomTrailing) {
38-
HStack(alignment: .top) {
39-
imageView
40-
altTextField
28+
VStack(alignment: .leading) {
29+
HStack {
30+
titleText
31+
Spacer()
32+
altTextHelpButton
4133
}
42-
if shouldShowCharCount {
34+
ZStack(alignment: .bottomTrailing) {
35+
HStack(alignment: .top) {
36+
imageView
37+
altTextField
38+
}
4339
characterCountText
4440
}
41+
Spacer()
42+
actionButton
43+
.disabled(isLoading)
44+
.padding(.top)
4545
}
46-
Spacer()
47-
actionButton
46+
.padding()
47+
.avatarPickerBorder(colorScheme: .light)
4848
}
49-
.padding()
50-
.avatarPickerBorder(colorScheme: .light)
49+
.padding(.bottom)
50+
.padding(.horizontal)
51+
errorToast
5152
}
52-
.padding(.bottom)
53-
.padding(.horizontal)
5453
}
5554
.gravatarNavigation(
5655
doneButtonTitle: Localized.cancelButtonTitle,
56+
doneButtonDisabled: isLoading,
5757
actionButtonDisabled: false,
58-
shouldEmitInnerHeight: false,
5958
onDoneButtonPressed: {
6059
onCancel()
6160
},
@@ -69,16 +68,21 @@ struct AltTextEditorView: View {
6968

7069
var altTextField: some View {
7170
ZStack(alignment: .topLeading) {
72-
TextEditor(text: $altText)
73-
.multilineTextAlignment(.leading)
74-
.frame(height: 100)
75-
.font(.footnote)
76-
.focused($focused)
77-
.onAppear { focused = true }
78-
.onChange(of: altText) { _ in
79-
// Crops text to fit char limit.
80-
altText = String(altText.prefix(Constants.characterLimit))
71+
TextEditor(text: Binding(
72+
get: { altText.normalizedAltText },
73+
set: { newAltText in
74+
if newAltText.contains("\n") {
75+
focused = false
76+
}
77+
altText = newAltText.normalizedAltText
8178
}
79+
))
80+
.multilineTextAlignment(.leading)
81+
.frame(height: 100)
82+
.font(.footnote)
83+
.focused($focused)
84+
.submitLabel(.done)
85+
.onAppear { focused = true }
8286
if altText.count == 0 {
8387
Text(Localized.altTextPlaceholder)
8488
.padding(8)
@@ -97,18 +101,29 @@ struct AltTextEditorView: View {
97101
}
98102

99103
var actionButton: some View {
100-
Button {
101-
if let avatar {
102-
onSave(avatar.updating { $0.altText = altText })
104+
ZStack(alignment: .center) {
105+
Button {
106+
if let avatar {
107+
isLoading = true
108+
Task {
109+
await onSave(avatar.updating { $0.altText = altText })
110+
isLoading = false
111+
}
112+
}
113+
} label: {
114+
CTAButtonView(Localized.saveButtonTitle)
103115
}
104-
} label: {
105-
CTAButtonView(Localized.saveButtonTitle)
106-
}.padding(.top)
116+
.disabled(isLoading)
117+
if isLoading {
118+
ProgressView()
119+
}
120+
}
107121
}
108122

109123
var characterCountText: some View {
110-
Text("\(altText.count)")
111-
.font(.callout)
124+
Text("\(Constants.characterLimit - altText.count)")
125+
.font(.footnote)
126+
.monospacedDigit()
112127
.foregroundColor(altText.count >= Constants.characterLimit ? .red : .secondary)
113128
}
114129

@@ -132,6 +147,11 @@ struct AltTextEditorView: View {
132147
.aspectRatio(1, contentMode: .fill)
133148
.shape(RoundedRectangle(cornerRadius: AvatarGridConstants.avatarCornerRadius))
134149
}
150+
151+
var errorToast: some View {
152+
ToastContainerView(toastManager: toastManager)
153+
.padding(.horizontal)
154+
}
135155
}
136156

137157
extension AltTextEditorView {
@@ -173,19 +193,34 @@ extension AltTextEditorView {
173193
}
174194
}
175195

196+
extension String {
197+
fileprivate var normalizedAltText: String {
198+
String(self.prefix(AltTextEditorView.Constants.characterLimit))
199+
.replacingOccurrences(of: "\n", with: "")
200+
}
201+
}
202+
176203
#Preview {
177204
struct AltTextPreview: View {
178205
@State var text = ""
179206
let avatar = AvatarImageModel.preview_init(
180207
id: "1",
181208
source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")
182209
)
210+
@ObservedObject var toast = ToastManager()
183211

184212
var body: some View {
185-
AltTextEditorView(
186-
avatar: avatar,
187-
email: .init("some@email.com")
188-
) { _ in } onCancel: {}
213+
NavigationView {
214+
AltTextEditorView(
215+
avatar: avatar,
216+
email: .init("some@email.com"),
217+
toastManager: toast
218+
) { _ in
219+
try? await Task.sleep(nanoseconds: 1_000_000_000)
220+
} onCancel: {
221+
toast.showToast("Error", type: .error)
222+
}
223+
}
189224
}
190225
}
191226

Sources/GravatarUI/SwiftUI/AvatarPicker/Views/CTAButtonView.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import SwiftUI
22

33
struct CTAButtonView: View {
44
let title: String
5+
@Environment(\.isEnabled) var isEnabled
56

67
public init(_ title: String) {
78
self.title = title
@@ -14,13 +15,19 @@ struct CTAButtonView: View {
1415
.foregroundColor(.white)
1516
.padding(.vertical, .DS.Padding.split)
1617
.padding(.horizontal, .DS.Padding.double)
17-
.background(RoundedRectangle(cornerRadius: 4).fill(Color(uiColor: .gravatarBlue)))
18+
.background(
19+
RoundedRectangle(cornerRadius: 4)
20+
.fill(Color(uiColor: isEnabled ? .gravatarBlue : UIColor.systemFill))
21+
)
1822
}
1923
}
2024

2125
#Preview {
2226
CTAButtonView("I am a button")
2327
.padding()
28+
CTAButtonView("I am a disabled button")
29+
.padding()
30+
.disabled(true)
2431
}
2532

2633
#Preview("Dark mode") {

Sources/GravatarUI/SwiftUI/GravatarNavigationModifier.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import SwiftUI
33
struct GravatarNavigationModifier<K: PreferenceKey>: ViewModifier where K.Value == CGFloat {
44
var title: String?
55
var doneButtonTitle: String?
6+
var doneButtonDisabled: Bool
67
var actionButtonDisabled: Bool
78

89
@Environment(\.colorScheme) var colorScheme
@@ -38,6 +39,7 @@ struct GravatarNavigationModifier<K: PreferenceKey>: ViewModifier where K.Value
3839
Text(doneButtonTitle ?? GravatarNavigationModifierConstants.Localized.doneButtonTitle)
3940
.tint(Color(UIColor.gravatarBlue))
4041
}
42+
.disabled(doneButtonDisabled)
4143
}
4244
}
4345
.background {
@@ -76,8 +78,8 @@ extension View {
7678
func gravatarNavigation<K>(
7779
title: String? = nil,
7880
doneButtonTitle: String? = nil,
81+
doneButtonDisabled: Bool = false,
7982
actionButtonDisabled: Bool,
80-
shouldEmitInnerHeight: Bool = true,
8183
onActionButtonPressed: (() -> Void)? = nil,
8284
onDoneButtonPressed: (() -> Void)? = nil,
8385
preferenceKey: K.Type
@@ -86,6 +88,7 @@ extension View {
8688
GravatarNavigationModifier<K>(
8789
title: title,
8890
doneButtonTitle: doneButtonTitle,
91+
doneButtonDisabled: doneButtonDisabled,
8992
actionButtonDisabled: actionButtonDisabled,
9093
onActionButtonPressed: onActionButtonPressed,
9194
onDoneButtonPressed: onDoneButtonPressed,

Sources/GravatarUI/SwiftUI/ModalPresentationModifier.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,23 @@ struct ModalPresentationModifier<ModalView: View>: ViewModifier {
1818
}
1919
}
2020
}
21+
22+
struct ModalItemPresentationModifier<ModalView: View, T>: ViewModifier where T: Identifiable {
23+
@Binding var item: T?
24+
25+
let onDismiss: (() -> Void)?
26+
let modalViewBuilder: (T) -> ModalView
27+
28+
init(item: Binding<T?>, onDismiss: (() -> Void)? = nil, @ViewBuilder modalView: @escaping (T) -> ModalView) {
29+
self._item = item
30+
self.onDismiss = onDismiss
31+
self.modalViewBuilder = modalView
32+
}
33+
34+
func body(content: Content) -> some View {
35+
content
36+
.sheet(item: $item) { item in
37+
modalViewBuilder(item)
38+
}
39+
}
40+
}

0 commit comments

Comments
 (0)