Skip to content

Commit

Permalink
Add basic SwiftUI Avatar picker (#318)
Browse files Browse the repository at this point in the history
* Add CachedAsyncImage and AvatarView

* Some improvements

* Add demo page

* Format

* Small update

* Remove unused

* Fix typo

* Replace the init parameters with a decorator function

* Format

* Remove DefaultAvatarContent

* Simplification

* One more simplification

* Revert "One more simplification"

This reverts commit 43880de.

* Make swiftformat

* Update ProfileService

* Add AvatarPicker

* Add support for using `package` access on Swift types

* Use an enum for the state of the models array

* Use Result enum

---------

Co-authored-by: Andrew Montgomery <[email protected]>
  • Loading branch information
pinarol and andrewdmontgomery authored Jul 23, 2024
1 parent d154972 commit 1aefee3
Show file tree
Hide file tree
Showing 14 changed files with 486 additions and 34 deletions.
7 changes: 6 additions & 1 deletion Demo/Demo/Gravatar-SwiftUI-Demo/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ struct ContentView: View {

enum Page: Int, CaseIterable, Identifiable {
case avatarView = 0

case avatarPickerView

var id: Int {
self.rawValue
}
Expand All @@ -21,6 +22,8 @@ struct ContentView: View {
switch self {
case .avatarView:
"Avatar View"
case .avatarPickerView:
"Avatar Picker View"
}
}
}
Expand All @@ -39,6 +42,8 @@ struct ContentView: View {
switch value {
case Page.avatarView.title:
DemoAvatarView()
case Page.avatarPickerView.title:
DemoAvatarPickerView()
default:
Text("-")
}
Expand Down
62 changes: 62 additions & 0 deletions Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarPickerView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import SwiftUI
@testable import GravatarUI

@MainActor
struct DemoAvatarPickerView: View {

@AppStorage("pickerEmail") private var email: String = ""
@AppStorage("pickerToken") private var token: String = ""
@State private var isSecure: Bool = true
@StateObject private var avatarPickerModel = AvatarPickerViewModel(email: .init(""), authToken: "")

var body: some View {
VStack(alignment: .leading, spacing: 5) {
VStack(alignment: .leading, spacing: 5) {
TextField("Email", text: $email)
.font(.callout)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
.disableAutocorrection(true)
.onChange(of: email) { oldValue, newValue in
avatarPickerModel.update(email: email)
}
HStack {
tokenField()
.font(.callout)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.onChange(of: token) { oldValue, newValue in
avatarPickerModel.update(authToken: token)
}
Button(action: {
isSecure.toggle()
}) {
Image(systemName: isSecure ? "eye.slash" : "eye")
.foregroundColor(.gray)
}
}
Divider()
}
.padding(.horizontal)

AvatarPickerView(model: avatarPickerModel).onAppear() {
avatarPickerModel.update(email: email)
avatarPickerModel.update(authToken: token)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

@ViewBuilder
func tokenField() -> some View {
if isSecure {
SecureField("Token", text: $token)
} else {
TextField("Token", text: $token)
}
}
}

#Preview {
DemoAvatarPickerView()
}
8 changes: 4 additions & 4 deletions Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct DemoAvatarView: View {
Toggle("Animated", isOn: $isAnimated)

AvatarView(
avatarURL: avatarURL,
url: avatarURL?.url,
placeholder: Image("profileAvatar").renderingMode(.template),
forceRefresh: $forceRefresh,
loadingView: {
Expand All @@ -51,9 +51,9 @@ struct DemoAvatarView: View {
},
transaction: Transaction(animation: isAnimated ? .easeInOut(duration: 0.3) : nil)
)
.avatarShape(RoundedRectangle(cornerRadius: 8),
borderColor: .purple,
borderWidth: borderWidth)
.shape(RoundedRectangle(cornerRadius: 8),
borderColor: .purple,
borderWidth: borderWidth)
.foregroundColor(.purple)
.frame(width: Constants.avatarWidth)
}
Expand Down
4 changes: 4 additions & 0 deletions Demo/Gravatar-Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
914AC0202BDAAC3C005DA4A5 /* DemoRemoteSVGViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 914AC01F2BDAAC3C005DA4A5 /* DemoRemoteSVGViewController.swift */; };
91956A522B6793AF00BF3CF0 /* SwitchWithLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91956A512B6793AF00BF3CF0 /* SwitchWithLabel.swift */; };
91956A542B67943A00BF3CF0 /* DemoUIImageViewExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91956A532B67943A00BF3CF0 /* DemoUIImageViewExtensionViewController.swift */; };
91B73B372C404F6E00E7D325 /* DemoAvatarPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B73B362C404F6E00E7D325 /* DemoAvatarPickerView.swift */; };
91E2FB042BC0276E00265E8E /* DemoProfileViewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91E2FB032BC0276E00265E8E /* DemoProfileViewsViewController.swift */; };
91F0B3DD2B62815F0025C4F8 /* MainTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F0B3DB2B62815F0025C4F8 /* MainTableViewController.swift */; };
91F0B3DE2B62815F0025C4F8 /* DemoAvatarDownloadViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F0B3DC2B62815F0025C4F8 /* DemoAvatarDownloadViewController.swift */; };
Expand Down Expand Up @@ -58,6 +59,7 @@
914AC01F2BDAAC3C005DA4A5 /* DemoRemoteSVGViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoRemoteSVGViewController.swift; sourceTree = "<group>"; };
91956A512B6793AF00BF3CF0 /* SwitchWithLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchWithLabel.swift; sourceTree = "<group>"; };
91956A532B67943A00BF3CF0 /* DemoUIImageViewExtensionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoUIImageViewExtensionViewController.swift; sourceTree = "<group>"; };
91B73B362C404F6E00E7D325 /* DemoAvatarPickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoAvatarPickerView.swift; sourceTree = "<group>"; };
91E2FB032BC0276E00265E8E /* DemoProfileViewsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoProfileViewsViewController.swift; sourceTree = "<group>"; };
91F0B3DB2B62815F0025C4F8 /* MainTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTableViewController.swift; sourceTree = "<group>"; };
91F0B3DC2B62815F0025C4F8 /* DemoAvatarDownloadViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoAvatarDownloadViewController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -99,6 +101,7 @@
49C5D60D2B5B33E20067C2A8 /* DemoApp.swift */,
49C5D60F2B5B33E20067C2A8 /* ContentView.swift */,
9146A7AD2C3BD8F000E07C63 /* DemoAvatarView.swift */,
91B73B362C404F6E00E7D325 /* DemoAvatarPickerView.swift */,
49C5D6112B5B33E20067C2A8 /* Assets.xcassets */,
49C5D6132B5B33E20067C2A8 /* Preview Content */,
);
Expand Down Expand Up @@ -331,6 +334,7 @@
files = (
49C5D6102B5B33E20067C2A8 /* ContentView.swift in Sources */,
9146A7AE2C3BD8F000E07C63 /* DemoAvatarView.swift in Sources */,
91B73B372C404F6E00E7D325 /* DemoAvatarPickerView.swift in Sources */,
49C5D60E2B5B33E20067C2A8 /* DemoApp.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
6 changes: 6 additions & 0 deletions Gravatar.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,10 @@ Pod::Spec.new do |s|
s.ios.deployment_target = ios_deployment_target

s.source_files = 'Sources/Gravatar/**/*.swift'

# Using the `package` access level for types requires us to pass `-package-name`
# as a swift flag, with the same name for each module/pod
s.pod_target_xcconfig = {
'OTHER_SWIFT_FLAGS' => '-Xfrontend -package-name -Xfrontend gravatar_sdk_ios'
}
end
6 changes: 6 additions & 0 deletions GravatarUI.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,10 @@ Pod::Spec.new do |s|
}
s.dependency 'Gravatar', s.version.to_s
s.ios.framework = 'UIKit'

# Using the `package` access level for types requires us to pass `-package-name`
# as a swift flag, with the same name for each module/pod
s.pod_target_xcconfig = {
'OTHER_SWIFT_FLAGS' => '-Xfrontend -package-name -Xfrontend gravatar_sdk_ios'
}
end
6 changes: 5 additions & 1 deletion Sources/Gravatar/Network/Data+Extension.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import Foundation

extension Data {
func decode<T: Decodable>(dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .iso8601) throws -> T {
func decode<T: Decodable>(
dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .iso8601,
keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys
) throws -> T {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = dateDecodingStrategy
decoder.keyDecodingStrategy = keyDecodingStrategy
let result = try decoder.decode(T.self, from: self)
return result
}
Expand Down
71 changes: 71 additions & 0 deletions Sources/Gravatar/Network/Services/ProfileService.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import Foundation

private let baseURL = URL(string: "https://api.gravatar.com/v3/profiles/")!
private let avatarsBaseURL = URL(string: "https://api.gravatar.com/v3/me/avatars")!
private let identitiesBaseURL = "https://api.gravatar.com/v3/me/identities/"

private func selectAvatarBaseURL(with profileID: ProfileIdentifier) -> URL? {
URL(string: "https://api.gravatar.com/v3/me/identities/\(profileID.id)/avatar")
}

/// A service to perform Profile related tasks.
///
Expand All @@ -23,6 +29,35 @@ public struct ProfileService: ProfileFetching, Sendable {
let request = await URLRequest(url: url).authorized()
return try await fetch(with: request)
}

package func fetchAvatars(with token: String) async throws -> [Avatar] {
let url = avatarsBaseURL
let request = URLRequest(url: url).settingAuthorizationHeaderField(with: token)
let (data, _) = try await client.fetchData(with: request)
return try data.decode(keyDecodingStrategy: .convertFromSnakeCase)
}

package func fetchIdentity(token: String, profileID: ProfileIdentifier) async throws -> ProfileIdentity {
guard let url = URL(string: identitiesBaseURL + profileID.id) else {
throw APIError.requestError(reason: .urlInitializationFailed)
}

let request = URLRequest(url: url).settingAuthorizationHeaderField(with: token)
let (data, _) = try await client.fetchData(with: request)
return try data.decode(keyDecodingStrategy: .convertFromSnakeCase)
}

package func selectAvatar(token: String, profileID: ProfileIdentifier, avatarID: String) async throws -> ProfileIdentity {
guard let url = selectAvatarBaseURL(with: profileID) else {
throw APIError.requestError(reason: .urlInitializationFailed)
}

var request = URLRequest(url: url).settingAuthorizationHeaderField(with: token)
request.httpMethod = "POST"
request.httpBody = try SelectAvatarBody(avatarId: avatarID).data
let (data, _) = try await client.fetchData(with: request)
return try data.decode(keyDecodingStrategy: .convertFromSnakeCase)
}
}

extension ProfileService {
Expand Down Expand Up @@ -50,3 +85,39 @@ extension URLRequest {
return copy
}
}

package struct ProfileIdentity: Decodable, Sendable {
package let emailHash: String
package let rating: String
package let imageId: String
package let imageUrl: String
}

package struct Avatar: Decodable, Sendable {
private let imageId: String
private let imageUrl: String

package var id: String {
imageId
}

package var url: String {
"https://gravatar.com\(imageUrl)?size=256"
}
}

private struct SelectAvatarBody: Encodable, Sendable {
private let avatarId: String

init(avatarId: String) {
self.avatarId = avatarId
}

var data: Data {
get throws {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return try encoder.encode(self)
}
}
}
11 changes: 11 additions & 0 deletions Sources/GravatarUI/Base/Result+Gravatar.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import Foundation
import Gravatar

extension Result {
func value() -> Success? {
switch self {
case .success(let value):
value
default:
nil
}
}
}

extension Result<ImageDownloadResult, ImageFetchingError> {
func map() -> Result<ImageDownloadResult, ImageFetchingComponentError> {
switch self {
Expand Down
29 changes: 29 additions & 0 deletions Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import UIKit

struct AvatarImageModel: Hashable, Identifiable {
enum Source: Hashable {
case remote(url: String)
case local(image: UIImage)
}

let id: String
let isLoading: Bool
let source: Source

var url: URL? {
guard case .remote(let url) = source else {
return nil
}
return URL(string: url)
}

init(id: String, source: Source, isLoading: Bool = false) {
self.id = id
self.source = source
self.isLoading = isLoading
}

func togglingLoading() -> AvatarImageModel {
AvatarImageModel(id: id, source: source, isLoading: !isLoading)
}
}
Loading

0 comments on commit 1aefee3

Please sign in to comment.