diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 93e1de4d..b3c004e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ jobs: name: Test Library steps: - uses: actions/checkout@v3 - - name: Select Xcode 14.3 - run: sudo xcode-select -s /Applications/Xcode_14.3.app + - name: Select Xcode 15.0.1 + run: sudo xcode-select -s /Applications/Xcode_15.0.1.app - name: Run tests run: make test-library @@ -29,8 +29,8 @@ jobs: name: Build Examples steps: - uses: actions/checkout@v3 - - name: Select Xcode 14.3 - run: sudo xcode-select -s /Applications/Xcode_14.3.app + - name: Select Xcode 15.0.1 + run: sudo xcode-select -s /Applications/Xcode_15.0.1.app - name: Build examples run: make build-examples diff --git a/Examples/Examples.xcodeproj/project.pbxproj b/Examples/Examples.xcodeproj/project.pbxproj index dc1ec8b5..414035f5 100644 --- a/Examples/Examples.xcodeproj/project.pbxproj +++ b/Examples/Examples.xcodeproj/project.pbxproj @@ -39,6 +39,7 @@ 79FEFFC32B078CD800D36347 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FEFFC22B078CD800D36347 /* ProfileView.swift */; }; 79FEFFC52B078D7900D36347 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FEFFC42B078D7900D36347 /* Models.swift */; }; 79FEFFC72B078FB000D36347 /* SwiftUIHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FEFFC62B078FB000D36347 /* SwiftUIHelpers.swift */; }; + 79FEFFC92B0797F600D36347 /* AvatarImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FEFFC82B0797F600D36347 /* AvatarImage.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -76,6 +77,7 @@ 79FEFFC22B078CD800D36347 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 79FEFFC42B078D7900D36347 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; 79FEFFC62B078FB000D36347 /* SwiftUIHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIHelpers.swift; sourceTree = ""; }; + 79FEFFC82B0797F600D36347 /* AvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarImage.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -190,17 +192,18 @@ 79FEFFAD2B07873600D36347 /* UserManagement */ = { isa = PBXGroup; children = ( - 79FEFFC12B078B6100D36347 /* Info.plist */, - 79FEFFAE2B07873600D36347 /* UserManagementApp.swift */, 79FEFFB02B07873600D36347 /* AppView.swift */, 79FEFFB22B07873700D36347 /* Assets.xcassets */, - 79FEFFB42B07873700D36347 /* UserManagement.entitlements */, - 79FEFFB52B07873700D36347 /* Preview Content */, - 79FEFFBD2B07894700D36347 /* Supabase.swift */, 79FEFFBF2B07895900D36347 /* AuthView.swift */, - 79FEFFC22B078CD800D36347 /* ProfileView.swift */, + 79FEFFC82B0797F600D36347 /* AvatarImage.swift */, + 79FEFFC12B078B6100D36347 /* Info.plist */, 79FEFFC42B078D7900D36347 /* Models.swift */, + 79FEFFB52B07873700D36347 /* Preview Content */, + 79FEFFC22B078CD800D36347 /* ProfileView.swift */, + 79FEFFBD2B07894700D36347 /* Supabase.swift */, 79FEFFC62B078FB000D36347 /* SwiftUIHelpers.swift */, + 79FEFFB42B07873700D36347 /* UserManagement.entitlements */, + 79FEFFAE2B07873600D36347 /* UserManagementApp.swift */, ); path = UserManagement; sourceTree = ""; @@ -393,6 +396,7 @@ 79FEFFC52B078D7900D36347 /* Models.swift in Sources */, 79FEFFC72B078FB000D36347 /* SwiftUIHelpers.swift in Sources */, 79FEFFC02B07895900D36347 /* AuthView.swift in Sources */, + 79FEFFC92B0797F600D36347 /* AvatarImage.swift in Sources */, 79FEFFAF2B07873600D36347 /* UserManagementApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Examples/UserManagement/AvatarImage.swift b/Examples/UserManagement/AvatarImage.swift new file mode 100644 index 00000000..191fb8ae --- /dev/null +++ b/Examples/UserManagement/AvatarImage.swift @@ -0,0 +1,55 @@ +// +// AvatarImage.swift +// UserManagement +// +// Created by Guilherme Souza on 17/11/23. +// + +import SwiftUI + +#if canImport(UIKit) + typealias PlatformImage = UIImage + extension Image { + init(platformImage: PlatformImage) { + self.init(uiImage: platformImage) + } + } + +#elseif canImport(AppKit) + typealias PlatformImage = NSImage + extension Image { + init(platformImage: PlatformImage) { + self.init(nsImage: platformImage) + } + } +#endif + +struct AvatarImage: Transferable, Equatable { + let image: Image + let data: Data + + static var transferRepresentation: some TransferRepresentation { + DataRepresentation(importedContentType: .image) { data in + guard let image = AvatarImage(data: data) else { + throw TransferError.importFailed + } + + return image + } + } +} + +extension AvatarImage { + init?(data: Data) { + guard let uiImage = PlatformImage(data: data) else { + return nil + } + + let image = Image(platformImage: uiImage) + self.init(image: image, data: data) + } +} + +enum TransferError: Error { + case importFailed +} diff --git a/Examples/UserManagement/Models.swift b/Examples/UserManagement/Models.swift index 8d18d317..f94f05ce 100644 --- a/Examples/UserManagement/Models.swift +++ b/Examples/UserManagement/Models.swift @@ -7,26 +7,16 @@ import Foundation -struct Profile: Decodable { +struct Profile: Codable { let username: String? let fullName: String? let website: String? + let avatarURL: String? enum CodingKeys: String, CodingKey { case username case fullName = "full_name" case website - } -} - -struct UpdateProfileParams: Encodable { - let username: String - let fullName: String - let website: String - - enum CodingKeys: String, CodingKey { - case username - case fullName = "full_name" - case website + case avatarURL = "avatar_url" } } diff --git a/Examples/UserManagement/ProfileView.swift b/Examples/UserManagement/ProfileView.swift index 85b19673..404958f3 100644 --- a/Examples/UserManagement/ProfileView.swift +++ b/Examples/UserManagement/ProfileView.swift @@ -5,6 +5,8 @@ // Created by Guilherme Souza on 17/11/23. // +import PhotosUI +import Supabase import SwiftUI struct ProfileView: View { @@ -14,9 +16,35 @@ struct ProfileView: View { @State var isLoading = false + @State var imageSelection: PhotosPickerItem? + @State var avatarImage: AvatarImage? + var body: some View { NavigationStack { Form { + Section { + HStack { + Group { + if let avatarImage { + avatarImage.image.resizable() + } else { + Color.clear + } + } + .scaledToFit() + .frame(width: 80, height: 80) + + Spacer() + + PhotosPicker(selection: $imageSelection, matching: .images) { + Image(systemName: "pencil.circle.fill") + .symbolRenderingMode(.multicolor) + .font(.system(size: 30)) + .foregroundColor(.accentColor) + } + } + } + Section { TextField("Username", text: $username) .textContentType(.username) @@ -54,6 +82,10 @@ struct ProfileView: View { } } }) + .onChange(of: imageSelection) { _, newValue in + guard let newValue else { return } + loadTransferable(from: newValue) + } } .task { await getInitialProfile() @@ -76,6 +108,10 @@ struct ProfileView: View { fullName = profile.fullName ?? "" website = profile.website ?? "" + if let avatarURL = profile.avatarURL, !avatarURL.isEmpty { + try await downloadImage(path: avatarURL) + } + } catch { debugPrint(error) } @@ -86,17 +122,20 @@ struct ProfileView: View { isLoading = true defer { isLoading = false } do { + let imageURL = try await uploadImage() + let currentUser = try await supabase.auth.session.user + let updatedProfile = Profile( + username: username, + fullName: fullName, + website: website, + avatarURL: imageURL + ) + try await supabase.database .from("profiles") - .update( - UpdateProfileParams( - username: username, - fullName: fullName, - website: website - ) - ) + .update(updatedProfile) .eq("id", value: currentUser.id) .execute() } catch { @@ -104,6 +143,37 @@ struct ProfileView: View { } } } + + private func loadTransferable(from imageSelection: PhotosPickerItem) { + Task { + do { + avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self) + } catch { + debugPrint(error) + } + } + } + + private func downloadImage(path: String) async throws { + let data = try await supabase.storage.from("avatars").download(path: path) + avatarImage = AvatarImage(data: data) + } + + private func uploadImage() async throws -> String? { + guard let data = avatarImage?.data else { return nil } + + let filePath = "\(UUID().uuidString).jpeg" + + try await supabase.storage + .from("avatars") + .upload( + path: filePath, + file: data, + options: FileOptions(contentType: "image/jpeg") + ) + + return filePath + } } #if swift(>=5.9) diff --git a/Makefile b/Makefile index 4ac17deb..32cb41f6 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ -PLATFORM_IOS = iOS Simulator,name=iPhone 14 Pro +PLATFORM_IOS = iOS Simulator,name=iPhone 15 Pro PLATFORM_MACOS = macOS PLATFORM_MAC_CATALYST = macOS,variant=Mac Catalyst PLATFORM_TVOS = tvOS Simulator,name=Apple TV -PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 8 (41mm) +PLATFORM_WATCHOS = watchOS Simulator,name=Apple Watch Series 9 (41mm) EXAMPLE = Examples test-library: diff --git a/Sources/Storage/StorageFileApi.swift b/Sources/Storage/StorageFileApi.swift index d3aba5e6..85dfa9c6 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -36,14 +36,14 @@ public class StorageFileApi: StorageApi { method: Request.Method, path: String, file: Data, - fileOptions: FileOptions + options: FileOptions ) async throws -> String { - let contentType = fileOptions.contentType + let contentType = options.contentType var headers = [ - "x-upsert": "\(fileOptions.upsert)", + "x-upsert": "\(options.upsert)", ] - headers["duplex"] = fileOptions.duplex + headers["duplex"] = options.duplex let fileName = fileName(fromPath: path) @@ -57,7 +57,7 @@ public class StorageFileApi: StorageApi { path: "/object/\(bucketId)/\(path)", method: method, formData: form, - options: fileOptions, + options: options, headers: headers ) ) @@ -68,26 +68,26 @@ public class StorageFileApi: StorageApi { /// - Parameters: /// - path: The relative file path. Should be of the format `folder/subfolder/filename.png`. The /// bucket must already exist before attempting to upload. - /// - file: The File object to be stored in the bucket. - /// - fileOptions: HTTP headers. For example `cacheControl` + /// - file: The Data to be stored in the bucket. + /// - options: HTTP headers. For example `cacheControl` @discardableResult - public func upload(path: String, file: File, fileOptions: FileOptions = FileOptions()) + public func upload(path: String, file: Data, options: FileOptions = FileOptions()) async throws -> String { - try await uploadOrUpdate(method: .post, path: path, file: file.data, fileOptions: fileOptions) + try await uploadOrUpdate(method: .post, path: path, file: file, options: options) } /// Replaces an existing file at the specified path with a new one. /// - Parameters: /// - path: The relative file path. Should be of the format `folder/subfolder`. The bucket /// already exist before attempting to upload. - /// - file: The file object to be stored in the bucket. - /// - fileOptions: HTTP headers. For example `cacheControl` + /// - file: The Data to be stored in the bucket. + /// - options: HTTP headers. For example `cacheControl` @discardableResult - public func update(path: String, file: File, fileOptions: FileOptions = FileOptions()) + public func update(path: String, file: Data, options: FileOptions = FileOptions()) async throws -> String { - try await uploadOrUpdate(method: .put, path: path, file: file.data, fileOptions: fileOptions) + try await uploadOrUpdate(method: .put, path: path, file: file, options: options) } /// Moves an existing file, optionally renaming it at the same time. diff --git a/Tests/StorageTests/StorageClientIntegrationTests.swift b/Tests/StorageTests/StorageClientIntegrationTests.swift index 17ed7e74..d3d69193 100644 --- a/Tests/StorageTests/StorageClientIntegrationTests.swift +++ b/Tests/StorageTests/StorageClientIntegrationTests.swift @@ -144,16 +144,13 @@ final class StorageClientIntegrationTests: XCTestCase { try await storage.from(bucketId).update( path: "README.md", - file: File(name: "README.md", data: dataToUpdate ?? Data(), fileName: nil, contentType: nil) + file: dataToUpdate ?? Data() ) } private func uploadTestData() async throws { - let file = File( - name: "README.md", data: uploadData ?? Data(), fileName: "README.md", contentType: "text/html" - ) _ = try await storage.from(bucketId).upload( - path: "README.md", file: file, fileOptions: FileOptions(cacheControl: "3600") + path: "README.md", file: uploadData ?? Data(), options: FileOptions(cacheControl: "3600") ) } }