Repository: Automattic/Gravatar-SDK-iOS
Pull request #738: Adding an initial loading state for the about editor
Author: login: etoledom
Labels: name: [Feature] Gravatar-QuickEditor
description: Gravatar Quick Editor
name: [Priority] High
name: QE About editing
Created: 2025-05-08T21:40:30Z | Merged: 2025-05-09T15:32:24Z

Closes #715 

### Description

This PR adds an initial loading indicator state to the About Editor View.

The loading indicator is the same type than the one used on the Avatar Picker.

![CleanShot 2025-05-08 at 23 33 03](https://github.com/user-attachments/assets/41828a6a-f4c7-4004-8668-c035fba2df1f)


### Testing Steps

- Run the demo app.
- Open the Quick Editor demo screen.
- Select the About Editor scope.
- Present the QE.
  - Check that the loading indicator appears while the content is loading.
  - Check that the loading indicator will NOT appear while saving changes. 

Linked issue #715: About editing loading state
Implement an initial loading state for the about editing section.
It could be a shimmer skeleton or similar.

Diff:
diff --git a/Sources/GravatarUI/SwiftUI/AboutEditor/AboutEditorView.swift b/Sources/GravatarUI/SwiftUI/AboutEditor/AboutEditorView.swift
index 10765fac1..41ab5c121 100644
--- a/Sources/GravatarUI/SwiftUI/AboutEditor/AboutEditorView.swift
+++ b/Sources/GravatarUI/SwiftUI/AboutEditor/AboutEditorView.swift
@@ -14,6 +14,15 @@ struct AboutEditorView: View {
     var aboutUpdateHandler: (() -> Void)?
 
     var body: some View {
+        if model.isProfileLoading {
+            LoadingIndicatorView()
+        } else {
+            content()
+        }
+    }
+
+    @ViewBuilder
+    private func content() -> some View {
         VStack {
             ScrollView {
                 VStack(alignment: .leading, spacing: 0) {
diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift
index 17eb275d4..58bbaa2a2 100644
--- a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift
+++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift
@@ -75,7 +75,7 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
                         if !model.grid.isEmpty {
                             content()
                         } else if model.isAvatarsLoading {
-                            avatarsLoadingView()
+                            LoadingIndicatorView()
                         }
                         Spacer()
                             .frame(height: Constants.vStackVerticalSpacing)
@@ -406,20 +406,6 @@ struct AvatarPickerView<ImageEditor: ImageEditorView>: View {
         .padding(.horizontal, Constants.horizontalPadding)
     }
 
-    private func avatarsLoadingView() -> some View {
-        VStack {
-            Spacer(minLength: .DS.Padding.large)
-
-            ProgressView()
-                .progressViewStyle(
-                    CircularProgressViewStyle()
-                )
-                .controlSize(.regular)
-
-            Spacer()
-        }
-    }
-
     private func openProfileInSafari() {
         safariURL = IdentifiableURL(url: model.profileModel?.profileURL)
     }
diff --git a/Sources/GravatarUI/SwiftUI/LoadingIndicatorView.swift b/Sources/GravatarUI/SwiftUI/LoadingIndicatorView.swift
new file mode 100644
index 000000000..c04dfc516
--- /dev/null
+++ b/Sources/GravatarUI/SwiftUI/LoadingIndicatorView.swift
@@ -0,0 +1,17 @@
+import SwiftUI
+
+struct LoadingIndicatorView: View {
+    var body: some View {
+        VStack {
+            ProgressView()
+                .progressViewStyle(CircularProgressViewStyle())
+                .controlSize(.regular)
+            Spacer()
+        }
+        .padding(.top, .DS.Padding.large)
+    }
+}
+
+#Preview {
+    LoadingIndicatorView()
+}
diff --git a/Tests/GravatarUITests/AboutEditorViewTests.swift b/Tests/GravatarUITests/AboutEditorViewTests.swift
index d4b2a0b54..6fd1661ed 100644
--- a/Tests/GravatarUITests/AboutEditorViewTests.swift
+++ b/Tests/GravatarUITests/AboutEditorViewTests.swift
@@ -114,6 +114,30 @@ struct AboutEditorViewTests {
         )
     }
 
+    @MainActor
+    @Test("Test about editor loading state snapshot")
+    func testLoadingState() async throws {
+        let testModel = testModel()
+
+        let view = AboutEditorView(
+            model: testModel,
+            fields: .all
+        )
+
+        Task(priority: .high) {
+            await testModel.fetchProfile()
+        }
+        // Awaits for the previous task to start executing, and the view to start loading.
+        try await Task.sleep(nanoseconds: 1)
+        assertSnapshots(
+            of: view,
+            as: [
+                .testStrategy(userInterfaceStyle: .light, layout: .fixed(width: 200, height: 300)),
+                .testStrategy(userInterfaceStyle: .dark, layout: .fixed(width: 200, height: 300)),
+            ]
+        )
+    }
+
     @MainActor
     private func testModel() -> AvatarPickerViewModel {
         let profileService = ProfileService(urlSession: URLSessionMock(returnData: Bundle.fullProfileJsonData, response: .successResponse()))
diff --git a/Tests/GravatarUITests/__Snapshots__/AboutEditorViewTests/testLoadingState.1.png b/Tests/GravatarUITests/__Snapshots__/AboutEditorViewTests/testLoadingState.1.png
new file mode 100644
index 000000000..9839bf962
Binary files /dev/null and b/Tests/GravatarUITests/__Snapshots__/AboutEditorViewTests/testLoadingState.1.png differ
diff --git a/Tests/GravatarUITests/__Snapshots__/AboutEditorViewTests/testLoadingState.2.png b/Tests/GravatarUITests/__Snapshots__/AboutEditorViewTests/testLoadingState.2.png
new file mode 100644
index 000000000..5211d442e
Binary files /dev/null and b/Tests/GravatarUITests/__Snapshots__/AboutEditorViewTests/testLoadingState.2.png differ


Reviews:
user: login: pinarol
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T09:54:25Z
user: login: pinarol
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T10:58:35Z
user: login: etoledom
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T12:04:54Z
user: login: etoledom
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T13:02:58Z
user: login: etoledom
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T13:04:19Z
user: login: pinarol
body: Looks good! 🎉 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T13:33:59Z

Review comments:
diff_hunk: @@ -14,6 +14,15 @@ struct AboutEditorView: View {
     var aboutUpdateHandler: (() -> Void)?
 
     var body: some View {
+        if model.profileResult == nil {
path: Sources/GravatarUI/SwiftUI/AboutEditor/AboutEditorView.swift
user: login: pinarol
body: We can't only use `profileResult == nil` condition here, no? We wouldn't want to show the loading indicator in case the service fails. We need to have a loading boolean. Can you check the logic on the avatar picker side? There, we avoid showing the loading indicator if the content is loaded once already. 
created_at: 2025-05-09T09:41:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,6 +45,15 @@ struct AboutEditorView: View {
             .padding(.bottom, .DS.Padding.double)
     }
 
+    private func loadingView() -> some View {
+        VStack {
+            ProgressView()
+                .progressViewStyle(CircularProgressViewStyle())
+                .controlSize(.regular)
path: Sources/GravatarUI/SwiftUI/AboutEditor/AboutEditorView.swift
user: login: pinarol
body: Can we add a top padding? 
                `.padding(.top, .DS.Padding.large)`

Currently it looks a bit too close to the top.
And it would match the avatar picker side also.

Can you also update AvatarPickerView's `private func avatarsLoadingView() -> some View {` to use `.padding(.top, .DS.Padding.large)` instead of `Spacer(minLength: .DS.Padding.large)`. Not sure why it was done that way in the first place. 🤔 We could even consider sharing the view. 
created_at: 2025-05-09T09:49:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,6 +114,25 @@ struct AboutEditorViewTests {
         )
     }
 
+    @MainActor
+    @Test("Test about editor loading state snapshot")
+    func testLoadingState() async throws {
path: Tests/GravatarUITests/AboutEditorViewTests.swift
user: login: pinarol
body: It's good to have tests although I am not sure if the snapshot images can be generated consistently when there's an animating view in it. But anyway, we can consider saving in other forms if it becomes flaky.
created_at: 2025-05-09T09:53:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,6 +14,15 @@ struct AboutEditorView: View {
     var aboutUpdateHandler: (() -> Void)?
 
     var body: some View {
+        if model.profileResult == nil {
path: Sources/GravatarUI/SwiftUI/AboutEditor/AboutEditorView.swift
user: login: pinarol
body: Oh wait, does this hold the error object in case it fails? In that case it could work. But having a loading specific published variable is still the safer route imo for possible future changes.
created_at: 2025-05-09T10:58:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -114,6 +114,25 @@ struct AboutEditorViewTests {
         )
     }
 
+    @MainActor
+    @Test("Test about editor loading state snapshot")
+    func testLoadingState() async throws {
path: Tests/GravatarUITests/AboutEditorViewTests.swift
user: login: etoledom
body: I had the same concern and runed it many times with a 100% success rate. 

> we can consider saving in other forms if it becomes flaky.

💯 
created_at: 2025-05-09T12:04:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,6 +14,15 @@ struct AboutEditorView: View {
     var aboutUpdateHandler: (() -> Void)?
 
     var body: some View {
+        if model.profileResult == nil {
path: Sources/GravatarUI/SwiftUI/AboutEditor/AboutEditorView.swift
user: login: etoledom
body: Yes, `profileResult` is only nil once at the first load. But now I noticed that this is not good enought either.
We want the loading state againg when we retry after an error on the initial loading.

I've switches to use `isProfileLoading`. I had a confusion thinking that this would be true while saving, but that's not true and this property works well, and will be helpful also when retrying after an error 👍 
created_at: 2025-05-09T13:02:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,6 +45,15 @@ struct AboutEditorView: View {
             .padding(.bottom, .DS.Padding.double)
     }
 
+    private func loadingView() -> some View {
+        VStack {
+            ProgressView()
+                .progressViewStyle(CircularProgressViewStyle())
+                .controlSize(.regular)
path: Sources/GravatarUI/SwiftUI/AboutEditor/AboutEditorView.swift
user: login: etoledom
body: I agree. With the `Separator` the behavior of the loading view was different in Avatars and in About screens, but now using padding they behave in the same way and we can safely share them 🎉 
created_at: 2025-05-09T13:04:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: wpmobilebot
created_at: 2025-05-08T21:49:57Z
body: <!-- REUSE_ID: prototype-build-link-gravtar-demo -->

<details><summary><img align='top' src='https://raw.githubusercontent.com/buildkite/emojis/main/img-buildkite-64/firebase.png' width='20px' alt='App Icon' />📲 You can test the changes from this Pull Request in <b>Gravatar Prototype Build</b> by scanning the QR code below to install the corresponding build.</summary>
<table>
<tr>
  <td rowspan='6' width='260px'><img src='https://api.qrserver.com/v1/create-qr-code/?size=500x500&qzone=4&data=https%3A%2F%2Fappdistribution.firebase.google.com%2Ftesterapps%2F1%3A124902176124%3Aios%3A0fc3cbe4d84305531620f9%2Freleases%2F0cjpaid015q9g%3Futm_source%3Dfastlane' width='250' height='250' /></td>
  <td><b>App Name</b></td><td>Gravatar Prototype Build</td>
</tr>
<tr><td><b>Build Number</b></td><td><code>2293</code></td></tr>
<tr><td><b>Version</b></td><td><code>PR #738</code></td></tr>
<tr><td><b>Bundle ID</b></td><td><code>com.automattic.gravatar-sdk-demo-uikit.prototype-build</code></td></tr>
<tr><td><b>Commit</b></td><td>b4a353220fa7ab762458ebcd4c807621b0c249df</td></tr>
<tr><td><b>Installation URL</b></td><td><a href='https://appdistribution.firebase.google.com/testerapps/1:124902176124:ios:0fc3cbe4d84305531620f9/releases/0cjpaid015q9g?utm_source=fastlane'>0cjpaid015q9g</a></td></tr>
</table>
<em>Automatticians: You can use our internal self-serve MC tool to give yourself access to those builds if needed.</em>
</details>

author_association: NONE
user: login: etoledom
created_at: 2025-05-09T13:05:11Z
body: @pinarol - Thank you for the review. Ready for another look 👀 
author_association: CONTRIBUTOR