From 1aefee357aeb57bb8f7c786e6eb8f18f424ec55c Mon Sep 17 00:00:00 2001 From: Pinar Olguc Date: Tue, 23 Jul 2024 14:56:37 +0300 Subject: [PATCH] Add basic SwiftUI Avatar picker (#318) * 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 43880dee8fdb1ef86dc8effed96d698fd1294478. * 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 --- .../Gravatar-SwiftUI-Demo/ContentView.swift | 7 +- .../DemoAvatarPickerView.swift | 62 ++++++ .../DemoAvatarView.swift | 8 +- Demo/Gravatar-Demo.xcodeproj/project.pbxproj | 4 + Gravatar.podspec | 6 + GravatarUI.podspec | 6 + Sources/Gravatar/Network/Data+Extension.swift | 6 +- .../Network/Services/ProfileService.swift | 71 +++++++ Sources/GravatarUI/Base/Result+Gravatar.swift | 11 ++ .../AvatarPicker/AvatarImageModel.swift | 29 +++ .../AvatarPicker/AvatarPickerView.swift | 177 ++++++++++++++++++ .../AvatarPicker/AvatarPickerViewModel.swift | 84 +++++++++ Sources/GravatarUI/SwiftUI/AvatarView.swift | 37 +--- .../GravatarUI/SwiftUI/View+Additions.swift | 12 ++ 14 files changed, 486 insertions(+), 34 deletions(-) create mode 100644 Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarPickerView.swift create mode 100644 Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift create mode 100644 Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift create mode 100644 Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift create mode 100644 Sources/GravatarUI/SwiftUI/View+Additions.swift diff --git a/Demo/Demo/Gravatar-SwiftUI-Demo/ContentView.swift b/Demo/Demo/Gravatar-SwiftUI-Demo/ContentView.swift index db8f484e..8c7eecee 100644 --- a/Demo/Demo/Gravatar-SwiftUI-Demo/ContentView.swift +++ b/Demo/Demo/Gravatar-SwiftUI-Demo/ContentView.swift @@ -12,7 +12,8 @@ struct ContentView: View { enum Page: Int, CaseIterable, Identifiable { case avatarView = 0 - + case avatarPickerView + var id: Int { self.rawValue } @@ -21,6 +22,8 @@ struct ContentView: View { switch self { case .avatarView: "Avatar View" + case .avatarPickerView: + "Avatar Picker View" } } } @@ -39,6 +42,8 @@ struct ContentView: View { switch value { case Page.avatarView.title: DemoAvatarView() + case Page.avatarPickerView.title: + DemoAvatarPickerView() default: Text("-") } diff --git a/Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarPickerView.swift b/Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarPickerView.swift new file mode 100644 index 00000000..4938f7df --- /dev/null +++ b/Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarPickerView.swift @@ -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() +} diff --git a/Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarView.swift b/Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarView.swift index 32d42e67..5ca72584 100644 --- a/Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarView.swift +++ b/Demo/Demo/Gravatar-SwiftUI-Demo/DemoAvatarView.swift @@ -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: { @@ -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) } diff --git a/Demo/Gravatar-Demo.xcodeproj/project.pbxproj b/Demo/Gravatar-Demo.xcodeproj/project.pbxproj index 1943178d..7300fb68 100644 --- a/Demo/Gravatar-Demo.xcodeproj/project.pbxproj +++ b/Demo/Gravatar-Demo.xcodeproj/project.pbxproj @@ -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 */; }; @@ -58,6 +59,7 @@ 914AC01F2BDAAC3C005DA4A5 /* DemoRemoteSVGViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoRemoteSVGViewController.swift; sourceTree = ""; }; 91956A512B6793AF00BF3CF0 /* SwitchWithLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchWithLabel.swift; sourceTree = ""; }; 91956A532B67943A00BF3CF0 /* DemoUIImageViewExtensionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoUIImageViewExtensionViewController.swift; sourceTree = ""; }; + 91B73B362C404F6E00E7D325 /* DemoAvatarPickerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoAvatarPickerView.swift; sourceTree = ""; }; 91E2FB032BC0276E00265E8E /* DemoProfileViewsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoProfileViewsViewController.swift; sourceTree = ""; }; 91F0B3DB2B62815F0025C4F8 /* MainTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainTableViewController.swift; sourceTree = ""; }; 91F0B3DC2B62815F0025C4F8 /* DemoAvatarDownloadViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DemoAvatarDownloadViewController.swift; sourceTree = ""; }; @@ -99,6 +101,7 @@ 49C5D60D2B5B33E20067C2A8 /* DemoApp.swift */, 49C5D60F2B5B33E20067C2A8 /* ContentView.swift */, 9146A7AD2C3BD8F000E07C63 /* DemoAvatarView.swift */, + 91B73B362C404F6E00E7D325 /* DemoAvatarPickerView.swift */, 49C5D6112B5B33E20067C2A8 /* Assets.xcassets */, 49C5D6132B5B33E20067C2A8 /* Preview Content */, ); @@ -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; diff --git a/Gravatar.podspec b/Gravatar.podspec index 44f00c1c..7474c4ec 100644 --- a/Gravatar.podspec +++ b/Gravatar.podspec @@ -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 diff --git a/GravatarUI.podspec b/GravatarUI.podspec index e0701c2e..a2046bf9 100644 --- a/GravatarUI.podspec +++ b/GravatarUI.podspec @@ -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 diff --git a/Sources/Gravatar/Network/Data+Extension.swift b/Sources/Gravatar/Network/Data+Extension.swift index 68a8f406..b89528a6 100644 --- a/Sources/Gravatar/Network/Data+Extension.swift +++ b/Sources/Gravatar/Network/Data+Extension.swift @@ -1,9 +1,13 @@ import Foundation extension Data { - func decode(dateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .iso8601) throws -> T { + func decode( + 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 } diff --git a/Sources/Gravatar/Network/Services/ProfileService.swift b/Sources/Gravatar/Network/Services/ProfileService.swift index 316cff21..e50aa6a1 100644 --- a/Sources/Gravatar/Network/Services/ProfileService.swift +++ b/Sources/Gravatar/Network/Services/ProfileService.swift @@ -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. /// @@ -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 { @@ -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) + } + } +} diff --git a/Sources/GravatarUI/Base/Result+Gravatar.swift b/Sources/GravatarUI/Base/Result+Gravatar.swift index 6d9f7e78..ca91eaef 100644 --- a/Sources/GravatarUI/Base/Result+Gravatar.swift +++ b/Sources/GravatarUI/Base/Result+Gravatar.swift @@ -1,6 +1,17 @@ import Foundation import Gravatar +extension Result { + func value() -> Success? { + switch self { + case .success(let value): + value + default: + nil + } + } +} + extension Result { func map() -> Result { switch self { diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift new file mode 100644 index 00000000..77b9ea9c --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarImageModel.swift @@ -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) + } +} diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift new file mode 100644 index 00000000..a7b57f02 --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerView.swift @@ -0,0 +1,177 @@ +import Gravatar +import SwiftUI + +@MainActor +struct AvatarPickerView: View { + enum Constants { + static let horizontalPadding: CGFloat = .DS.Padding.double + static let maxAvatarWidth: CGFloat = 100 + static let minAvatarWidth: CGFloat = 80 + static let avatarSpacing: CGFloat = 20 + static let padding: EdgeInsets = .init( + top: .DS.Padding.double, + leading: horizontalPadding, + bottom: .DS.Padding.double, + trailing: horizontalPadding + ) + static let errorPadding: EdgeInsets = .init( + top: .DS.Padding.double, + leading: horizontalPadding * 2, + bottom: .DS.Padding.double, + trailing: horizontalPadding * 2 + ) + static let selectedBorderWidth: CGFloat = .DS.Padding.half + static let avatarCornerRadius: CGFloat = .DS.Padding.single + } + + @StateObject var model: AvatarPickerViewModel + + var body: some View { + ScrollView { + header() + errorMessages() + + if case .success(let avatarImageModels) = model.avatarsResult { + avatarGrid(with: avatarImageModels) + } else if model.isAvatarsLoading { + avatarsLoadingView() + } + } + .task { + model.refresh() + } + } + + @ViewBuilder + private func header() -> some View { + VStack(alignment: .leading) { + Text("Avatars").font(.largeTitle.weight(.bold)) + Text("Upload or create your favorite avatar images and connect them to your email address.").font(.footnote) + } + .padding(.init(top: .DS.Padding.double, leading: Constants.horizontalPadding, bottom: .DS.Padding.half, trailing: Constants.horizontalPadding)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func errorMessages() -> some View { + VStack(alignment: .center) { + switch model.avatarsResult { + case .success(let models) where models.isEmpty: + errorText("You don't have any avatars yet. Why not start uploading some now?") + case .failure: + Spacer(minLength: .DS.Padding.large * 2) + errorText("Sorry, it seems like something didn't quite work out when getting your avatars.") + tryAgainButton() + default: + EmptyView() + } + } + .foregroundColor(.secondary) + } + + @ViewBuilder + private func tryAgainButton() -> some View { + Button(action: { + model.refresh() + }, label: { + VStack { + Image(systemName: "arrow.clockwise") + .resizable() + .scaledToFit() + .font(.largeTitle) + .frame(width: .DS.Padding.medium) + + Spacer() + Text("Try Again") + .font(.subheadline) + } + }) + } + + private func errorText(_ message: String) -> some View { + Text(message) + .font(.subheadline) + .padding(Constants.errorPadding) + .multilineTextAlignment(.center) + } + + @ViewBuilder + private func avatarGrid(with avatarImageModels: [AvatarImageModel]) -> some View { + let gridItems = [GridItem( + .adaptive( + minimum: Constants.minAvatarWidth, + maximum: Constants.maxAvatarWidth + ), + spacing: Constants.avatarSpacing + )] + + LazyVGrid(columns: gridItems, spacing: Constants.avatarSpacing) { + ForEach(avatarImageModels) { avatar in + AvatarView( + url: avatar.url, + placeholder: nil, + loadingView: { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + ) + .scaledToFill() + .frame( + minWidth: Constants.minAvatarWidth, + maxWidth: Constants.maxAvatarWidth, + minHeight: Constants.minAvatarWidth, + maxHeight: Constants.maxAvatarWidth + ) + .background(Color(UIColor.secondarySystemBackground)) + .aspectRatio(1, contentMode: .fill) + .shape( + RoundedRectangle(cornerRadius: Constants.avatarCornerRadius), + borderColor: .accentColor, + borderWidth: model.currentAvatarResult?.value() == avatar.id ? Constants.selectedBorderWidth : 0 + ) + } + } + .padding(Constants.padding) + } + + @ViewBuilder + private func avatarsLoadingView() -> some View { + VStack { + switch model.avatarsResult { + case .failure: + Spacer(minLength: .DS.Padding.large * 2) + default: + Spacer(minLength: .DS.Padding.medium) + } + + ProgressView() + .progressViewStyle( + CircularProgressViewStyle() + ) + .controlSize(.regular) + } + } +} + +#Preview("Existing elements") { + AvatarPickerView(model: .init( + avatarImageModels: [ + .init(id: "1", source: .remote(url: "https://gravatar.com/userimage/110207384/aa5f129a2ec75162cee9a1f0c472356a.jpeg?size=256")), + .init(id: "2", source: .remote(url: "https://gravatar.com/userimage/110207384/db73834576b01b69dd8da1e29877ca07.jpeg?size=256")), + .init(id: "3", source: .remote(url: "https://gravatar.com/userimage/110207384/3f7095bf2580265d1801d128c6410016.jpeg?size=256")), + .init(id: "4", source: .remote(url: "https://gravatar.com/userimage/110207384/fbbd335e57862e19267679f19b4f9db8.jpeg?size=256")), + .init(id: "5", source: .remote(url: "https://gravatar.com/userimage/110207384/96c6950d6d8ce8dd1177a77fe738101e.jpeg?size=256")), + .init(id: "6", source: .remote(url: "https://gravatar.com/userimage/110207384/4a4f9385b0a6fa5c00342557a098f480.jpeg?size=256")), + ], + selectedImageID: "5" + )) +} + +#Preview("Empty elements") { + AvatarPickerView(model: .init(avatarImageModels: [])) +} + +#Preview("Load from network") { + /// Enter valid email and auth token. + AvatarPickerView(model: .init(email: .init(""), authToken: "")) +} diff --git a/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift new file mode 100644 index 00000000..65cce514 --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/AvatarPicker/AvatarPickerViewModel.swift @@ -0,0 +1,84 @@ +import Foundation +import SwiftUI + +@MainActor +class AvatarPickerViewModel: ObservableObject { + private let profileService: ProfileService = .init() + private var email: Email? + private var authToken: String? + @Published private(set) var avatarsResult: Result<[AvatarImageModel], Error>? + @Published private(set) var currentAvatarResult: Result? + @Published private(set) var isAvatarsLoading: Bool = false + + init(email: Email, authToken: String) { + self.email = email + self.authToken = authToken + } + + /// Internal init for previewing purposes. Do not make this public. + init(avatarImageModels: [AvatarImageModel], selectedImageID: String? = nil) { + if let selectedImageID { + self.currentAvatarResult = .success(selectedImageID) + } else { + self.currentAvatarResult = nil + } + self.avatarsResult = .success(avatarImageModels) + } + + func fetchAvatars() async { + guard let authToken else { return } + do { + isAvatarsLoading = true + let images = try await profileService.fetchAvatars(with: authToken) + var avatarModels: [AvatarImageModel] = [] + for image in images { + avatarModels.append(AvatarImageModel(id: image.id, source: .remote(url: image.url))) + } + avatarsResult = .success(avatarModels) + isAvatarsLoading = false + } catch { + avatarsResult = .failure(error) + isAvatarsLoading = false + } + } + + func fetchIdentity() async { + guard let authToken, let email else { return } + do { + let identity = try await profileService.fetchIdentity(token: authToken, profileID: .email(email)) + currentAvatarResult = .success(identity.imageId) + } catch { + currentAvatarResult = .failure(error) + } + } + + func update(email: String) { + self.email = .init(email) + Task { + await fetchIdentity() + } + } + + func update(authToken: String) { + self.authToken = authToken + refresh() + } + + func refresh() { + Task { + await fetchAvatars() + await fetchIdentity() + } + } +} + +extension Result<[AvatarImageModel], Error> { + func isEmpty() -> Bool { + switch self { + case .success(let models): + models.isEmpty + default: + false + } + } +} diff --git a/Sources/GravatarUI/SwiftUI/AvatarView.swift b/Sources/GravatarUI/SwiftUI/AvatarView.swift index 0d558fb4..38eba5ca 100644 --- a/Sources/GravatarUI/SwiftUI/AvatarView.swift +++ b/Sources/GravatarUI/SwiftUI/AvatarView.swift @@ -7,14 +7,14 @@ struct AvatarView: View { @ViewBuilder private let loadingView: LoadingViewBlock? @Binding private var forceRefresh: Bool @State private var isLoading: Bool = false - private var avatarURL: AvatarURL? + private var url: URL? private let placeholder: Image? private let cache: ImageCaching private let urlSession: URLSession private let transaction: Transaction init( - avatarURL: AvatarURL?, + url: URL?, placeholder: Image?, cache: ImageCaching = ImageCache.shared, urlSession: URLSession = .shared, @@ -22,7 +22,7 @@ struct AvatarView: View { loadingView: LoadingViewBlock?, transaction: Transaction = Transaction() ) { - self.avatarURL = avatarURL + self.url = url self.placeholder = placeholder self.cache = cache self.loadingView = loadingView @@ -33,7 +33,7 @@ struct AvatarView: View { var body: some View { CachedAsyncImage( - url: avatarURL?.url, + url: url, cache: cache, urlSession: urlSession, forceRefresh: $forceRefresh, @@ -56,32 +56,13 @@ struct AvatarView: View { private func content(for phase: AsyncImagePhase) -> some View { switch phase { case .success(let image): - scaledImage(image) + image.resizable() case .failure, .empty: - if let placeholder { - scaledImage(placeholder) - } + placeholder?.resizable() @unknown default: - if let placeholder { - scaledImage(placeholder) - } + placeholder?.resizable() } } - - private func scaledImage(_ image: Image) -> some View { - image - .resizable() - .scaledToFit() - } - - func avatarShape(_ shape: some Shape, borderColor: Color = .clear, borderWidth: CGFloat = 0) -> some View { - self - .clipShape(shape) - .overlay( - shape - .stroke(borderColor, lineWidth: borderWidth) - ) - } } #Preview { @@ -90,7 +71,7 @@ struct AvatarView: View { options: .init(preferredSize: .points(100)) ) return AvatarView( - avatarURL: avatarURL, + url: avatarURL?.url, placeholder: Image(systemName: "person") .renderingMode(.template) .resizable(), @@ -100,6 +81,6 @@ struct AvatarView: View { }, transaction: Transaction(animation: .easeInOut(duration: 1)) ) - .avatarShape(RoundedRectangle(cornerRadius: 20), borderColor: Color.accentColor, borderWidth: 2) + .shape(RoundedRectangle(cornerRadius: 20), borderColor: Color.accentColor, borderWidth: 2) .frame(width: 100, height: 100, alignment: .center) } diff --git a/Sources/GravatarUI/SwiftUI/View+Additions.swift b/Sources/GravatarUI/SwiftUI/View+Additions.swift new file mode 100644 index 00000000..83a3934b --- /dev/null +++ b/Sources/GravatarUI/SwiftUI/View+Additions.swift @@ -0,0 +1,12 @@ +import SwiftUI + +extension View { + func shape(_ shape: some Shape, borderColor: Color = .clear, borderWidth: CGFloat = 0) -> some View { + self + .clipShape(shape) + .overlay( + shape + .stroke(borderColor, lineWidth: borderWidth) + ) + } +}