diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 6b625f1ad1..9a29fded31 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -318,6 +318,10 @@ export const gettingstarted: NavMenuConstant = { name: 'Ionic Angular', url: '/guides/getting-started/tutorials/with-ionic-angular', }, + { + name: 'Swift', + url: '/guides/getting-started/tutorials/with-swift', + }, ], }, ], diff --git a/apps/docs/pages/guides/auth/native-mobile-deep-linking.mdx b/apps/docs/pages/guides/auth/native-mobile-deep-linking.mdx index c3ba884e73..a612d8b1a6 100644 --- a/apps/docs/pages/guides/auth/native-mobile-deep-linking.mdx +++ b/apps/docs/pages/guides/auth/native-mobile-deep-linking.mdx @@ -307,6 +307,44 @@ In certain auth scenarios you will need to handle linking back into your applica + + + ### Deep link config + + 1. Go to your [auth settings](https://supabase.com/dashboard/project/_/auth/url-configuration) page. + 2. Enter your app redirect URL in the `Additional Redirect URLs` field. This is the URL that the user gets redirected to after clicking a magic link. + + The redirect callback url should have the format `[YOUR_SCHEME]://[YOUR_HOSTNAME]`. Here, `io.supabase.user-management://login-callback` is just an example. You can choose whatever you would like for `YOUR_SCHEME` and `YOUR_HOSTNAME` as long as the scheme is unique across the user's device. For this reason, typically a reverse domain of your website is used. + + ![Supabase console deep link setting](/docs/img/deeplink-setting.png) + + Now add a cutom URL to your application, so the OS knows how to redirect back your application once the user clicks the magic link. + + You have the option to use Xcode's Target Info Editor following [official Apple documentation](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app#Register-your-URL-scheme). + + Or, declare the URL scheme manually in your `Info.plist` file. + + ```xml Info.plist + + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + io.supabase.user-management + + + + + + ``` + diff --git a/apps/docs/pages/guides/getting-started/tutorials/with-swift.mdx b/apps/docs/pages/guides/getting-started/tutorials/with-swift.mdx new file mode 100644 index 0000000000..15b988ae53 --- /dev/null +++ b/apps/docs/pages/guides/getting-started/tutorials/with-swift.mdx @@ -0,0 +1,527 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + title: 'Build a User Management App with Swift and SwiftUI', + description: 'Learn how to use Supabase in your SwiftUI App.', +} + + + +![Supabase User Management example](/docs/img/supabase-swift-demo.png) + + + +If you get stuck while working through this guide, refer to the [full example on GitHub](https://github.com/supabase/supabase/tree/master/examples/user-management/swift-user-management). + + + + + +## Building the App + +Let's start building the SwiftUI app from scratch. + +### Create a SwiftUI app in Xcode + +Open Xcode and create a new SwiftUI project. + +Add the [supabase-swift](https://github.com/supabase-community/supabase-swift) dependency. + +Add the `https://github.com/supabase-community/supabase-swift` package to your app. For instructions, see the [Apple tutorial on adding package dependencies](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app). + +Create a helper file to initialize the Supabase client. +You need the API URL and the `anon` key that you copied [earlier](#get-the-api-keys). +These variables will be exposed on the application, and that's completely fine since you have +[Row Level Security](/docs/guides/auth#row-level-security) enabled on your database. + +```swift Supabase.swift +import Supabase + +let supabase = SupabaseClient( + supabaseURL: URL(string: "YOUR_SUPABASE_URL")!, + supabaseKey: "YOUR_SUPABASE_ANON_KEY" +) +``` + +### Set up a Login view + +Set up a SwiftUI view to manage logins and sign ups. +Users should be able to sign in using a magic link. + +```swift AuthView.swift +import SwiftUI +import Supabase + +struct AuthView: View { + @State var email = "" + @State var isLoading = false + @State var result: Result? + + var body: some View { + Form { + Section { + TextField("Email", text: $email) + .textContentType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + + Section { + Button("Sign in") { + signInButtonTapped() + } + + if isLoading { + ProgressView() + } + } + + if let result { + Section { + switch result { + case .success: + Text("Check your inbox.") + case .failure(let error): + Text(error.localizedDescription).foregroundStyle(.red) + } + } + } + } + .onOpenURL(perform: { url in + Task { + do { + try await supabase.auth.session(from: url) + } catch { + self.result = .failure(error) + } + } + }) + } + + func signInButtonTapped() { + Task { + isLoading = true + defer { isLoading = false } + + do { + try await supabase.auth.signInWithOTP( + email: email, + redirectTo: URL(string: "io.supabase.user-management://login-callback") + ) + result = .success(()) + } catch { + result = .failure(error) + } + } + } +} +``` + + + +The example uses a custom `redirectTo` URL. For this to work, add a custom redirect URL to Supabase and a custom URL scheme to your SwiftUI application. Follow the guide on [implementing deep link handling](/docs/guides/auth/native-mobile-deep-linking?platform=swift). + + + +### Account view + +After a user is signed in, you can allow them to edit their profile details and manage their account. + +Create a new view for that called `ProfileView.swift`. + +```swift ProfileView.swift +struct ProfileView: View { + @State var username = "" + @State var fullName = "" + @State var website = "" + + @State var isLoading = false + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Username", text: $username) + .textContentType(.username) + .textInputAutocapitalization(.never) + TextField("Full name", text: $fullName) + .textContentType(.name) + TextField("Website", text: $website) + .textContentType(.URL) + .textInputAutocapitalization(.never) + } + + Section { + Button("Update profile") { + updateProfileButtonTapped() + } + .bold() + + if isLoading { + ProgressView() + } + } + } + .navigationTitle("Profile") + .toolbar(content: { + ToolbarItem(placement: .topBarLeading){ + Button("Sign out", role: .destructive) { + Task { + try? await supabase.auth.signOut() + } + } + } + }) + } + .task { + await getInitialProfile() + } + } + + func getInitialProfile() async { + do { + let currentUser = try await supabase.auth.session.user + + let profile: Profile = try await supabase.database + .from("profiles") + .select() + .eq("id", value: currentUser.id) + .single() + .execute() + .value + + self.username = profile.username ?? "" + self.fullName = profile.fullName ?? "" + self.website = profile.website ?? "" + + } catch { + debugPrint(error) + } + } + + func updateProfileButtonTapped() { + Task { + isLoading = true + defer { isLoading = false } + do { + let currentUser = try await supabase.auth.session.user + + try await supabase.database + .from("profiles") + .update( + UpdateProfileParams( + username: username, + fullName: fullName, + website: website + ) + ) + .eq("id", value: currentUser.id) + .execute() + } catch { + debugPrint(error) + } + } + } +} +``` + +### Models + +In `ProfileView.swift`, you used 2 model types for deserializing the response and serializing the request to Supabase. Add those in a new `Models.swift` file. + +```swift Models.swift +struct Profile: Decodable { + let username: String? + let fullName: String? + let website: 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 + } +} +``` + +### Launch! + +Now that you've created all the views, add an entry point for the application. This will verify if the user has a valid session and route them to the authenticated or non-authenticated state. + +Add a new `AppView.swift` file. + +```swift AppView.swift +struct AppView: View { + @State var isAuthenticated = false + + var body: some View { + Group { + if isAuthenticated { + ProfileView() + } else { + AuthView() + } + } + .task { + for await state in await supabase.auth.onAuthStateChange() { + if [.initialSession, .signedIn, .signedOut].contains(state.event) { + isAuthenticated = state.session != nil + } + } + } + } +} +``` + +Update the entry point to the newly created `AppView`. Run in Xcode to launch your application in the simulator. + +## Bonus: Profile photos + +Every Supabase project is configured with [Storage](/docs/guides/storage) for managing large files like +photos and videos. + +### Add PhotosPicker + +Let's add support for the user to pick an image from the library and upload it. +Start by creating a new type to hold the picked avatar image: + +```swift AvatarImage.swift +import SwiftUI + +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 = UIImage(data: data) else { + return nil + } + + let image = Image(uiImage: uiImage) + self.init(image: image, data: data) + } +} + +enum TransferError: Error { + case importFailed +} +``` + +#### Add PhotosPicker to Profile page + +```swift ProfileView.swift +struct ProfileView: View { + @State var username = "" + @State var fullName = "" + @State var website = "" + + @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) + .textInputAutocapitalization(.never) + TextField("Full name", text: $fullName) + .textContentType(.name) + TextField("Website", text: $website) + .textContentType(.URL) + .textInputAutocapitalization(.never) + } + + Section { + Button("Update profile") { + updateProfileButtonTapped() + } + .bold() + + if isLoading { + ProgressView() + } + } + } + .navigationTitle("Profile") + .toolbar(content: { + ToolbarItem { + Button("Sign out", role: .destructive) { + Task { + try? await supabase.auth.signOut() + } + } + } + }) ++ .onChange(of: imageSelection) { _, newValue in ++ guard let newValue else { return } ++ loadTransferable(from: newValue) ++ } + } + .task { + await getInitialProfile() + } + } + + func getInitialProfile() async { + do { + let currentUser = try await supabase.auth.session.user + + let profile: Profile = try await supabase.database + .from("profiles") + .select() + .eq("id", value: currentUser.id) + .single() + .execute() + .value + + username = profile.username ?? "" + fullName = profile.fullName ?? "" + website = profile.website ?? "" + ++ if let avatarURL = profile.avatarURL, !avatarURL.isEmpty { ++ try await downloadImage(path: avatarURL) ++ } + + } catch { + debugPrint(error) + } + } + + func updateProfileButtonTapped() { + Task { + 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(updatedProfile) + .eq("id", value: currentUser.id) + .execute() + } catch { + debugPrint(error) + } + } + } + ++ 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 ++ } +} +``` + +Finally, update your Models. + +```swift Models.swift +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 + case avatarURL = "avatar_url" + } +} +``` + +You no longer need the `UpdateProfileParams` struct, as you can now reuse the `Profile` struct for both request and response calls. + +### Storage management + + + +At this stage you have a fully functional application! + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/public/img/supabase-swift-demo.png b/apps/docs/public/img/supabase-swift-demo.png new file mode 100644 index 0000000000..e66509183f Binary files /dev/null and b/apps/docs/public/img/supabase-swift-demo.png differ diff --git a/examples/user-management/swift-user-management/AppView.swift b/examples/user-management/swift-user-management/AppView.swift new file mode 100644 index 0000000000..1cfc17573b --- /dev/null +++ b/examples/user-management/swift-user-management/AppView.swift @@ -0,0 +1,35 @@ +// +// AppView.swift +// UserManagement +// +// Created by Guilherme Souza on 17/11/23. +// + +import SwiftUI + +struct AppView: View { + @State var isAuthenticated = false + + var body: some View { + Group { + if isAuthenticated { + ProfileView() + } else { + AuthView() + } + } + .task { + for await state in await supabase.auth.onAuthStateChange() { + if [.initialSession, .signedIn, .signedOut].contains(state.event) { + isAuthenticated = state.session != nil + } + } + } + } +} + +#if swift(>=5.9) + #Preview { + AppView() + } +#endif diff --git a/examples/user-management/swift-user-management/Assets.xcassets/AccentColor.colorset/Contents.json b/examples/user-management/swift-user-management/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..eb87897008 --- /dev/null +++ b/examples/user-management/swift-user-management/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/user-management/swift-user-management/Assets.xcassets/AppIcon.appiconset/Contents.json b/examples/user-management/swift-user-management/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..532cd729c6 --- /dev/null +++ b/examples/user-management/swift-user-management/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,63 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/user-management/swift-user-management/Assets.xcassets/Contents.json b/examples/user-management/swift-user-management/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/examples/user-management/swift-user-management/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/user-management/swift-user-management/AuthView.swift b/examples/user-management/swift-user-management/AuthView.swift new file mode 100644 index 0000000000..da30eb631a --- /dev/null +++ b/examples/user-management/swift-user-management/AuthView.swift @@ -0,0 +1,80 @@ +// +// AuthView.swift +// UserManagement +// +// Created by Guilherme Souza on 17/11/23. +// + +import Supabase +import SwiftUI + +struct AuthView: View { + @State var email = "" + @State var isLoading = false + @State var result: Result? + + var body: some View { + Form { + Section { + TextField("Email", text: $email) + .textContentType(.emailAddress) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + } + + Section { + Button("Sign in") { + signInButtonTapped() + } + + if isLoading { + ProgressView() + } + } + + if let result { + Section { + switch result { + case .success: Text("Check you inbox.") + case let .failure(error): Text(error.localizedDescription).foregroundStyle(.red) + } + } + } + } + .onMac { $0.padding() } + .onOpenURL(perform: { url in + Task { + do { + try await supabase.auth.session(from: url) + } catch { + result = .failure(error) + } + } + }) + } + + func signInButtonTapped() { + Task { + isLoading = true + defer { isLoading = false } + + do { + try await supabase.auth.signInWithOTP( + email: email, + redirectTo: URL(string: "io.supabase.user-management://login-callback") + ) + result = .success(()) + } catch { + result = .failure(error) + } + } + } +} + +#if swift(>=5.9) + #Preview { + AuthView() + } +#endif diff --git a/examples/user-management/swift-user-management/AvatarImage.swift b/examples/user-management/swift-user-management/AvatarImage.swift new file mode 100644 index 0000000000..191fb8aeca --- /dev/null +++ b/examples/user-management/swift-user-management/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/user-management/swift-user-management/Info.plist b/examples/user-management/swift-user-management/Info.plist new file mode 100644 index 0000000000..50c754a761 --- /dev/null +++ b/examples/user-management/swift-user-management/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + io.supabase.user-management + + + + + diff --git a/examples/user-management/swift-user-management/Models.swift b/examples/user-management/swift-user-management/Models.swift new file mode 100644 index 0000000000..f94f05ce80 --- /dev/null +++ b/examples/user-management/swift-user-management/Models.swift @@ -0,0 +1,22 @@ +// +// Models.swift +// UserManagement +// +// Created by Guilherme Souza on 17/11/23. +// + +import Foundation + +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 + case avatarURL = "avatar_url" + } +} diff --git a/examples/user-management/swift-user-management/Preview Content/Preview Assets.xcassets/Contents.json b/examples/user-management/swift-user-management/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/examples/user-management/swift-user-management/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/examples/user-management/swift-user-management/ProfileView.swift b/examples/user-management/swift-user-management/ProfileView.swift new file mode 100644 index 0000000000..404958f339 --- /dev/null +++ b/examples/user-management/swift-user-management/ProfileView.swift @@ -0,0 +1,183 @@ +// +// ProfileView.swift +// UserManagement +// +// Created by Guilherme Souza on 17/11/23. +// + +import PhotosUI +import Supabase +import SwiftUI + +struct ProfileView: View { + @State var username = "" + @State var fullName = "" + @State var website = "" + + @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) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + TextField("Full name", text: $fullName) + .textContentType(.name) + TextField("Website", text: $website) + .textContentType(.URL) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + } + + Section { + Button("Update profile") { + updateProfileButtonTapped() + } + .bold() + + if isLoading { + ProgressView() + } + } + } + .onMac { $0.padding() } + .navigationTitle("Profile") + .toolbar(content: { + ToolbarItem { + Button("Sign out", role: .destructive) { + Task { + try? await supabase.auth.signOut() + } + } + } + }) + .onChange(of: imageSelection) { _, newValue in + guard let newValue else { return } + loadTransferable(from: newValue) + } + } + .task { + await getInitialProfile() + } + } + + func getInitialProfile() async { + do { + let currentUser = try await supabase.auth.session.user + + let profile: Profile = try await supabase.database + .from("profiles") + .select() + .eq("id", value: currentUser.id) + .single() + .execute() + .value + + username = profile.username ?? "" + fullName = profile.fullName ?? "" + website = profile.website ?? "" + + if let avatarURL = profile.avatarURL, !avatarURL.isEmpty { + try await downloadImage(path: avatarURL) + } + + } catch { + debugPrint(error) + } + } + + func updateProfileButtonTapped() { + Task { + 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(updatedProfile) + .eq("id", value: currentUser.id) + .execute() + } catch { + debugPrint(error) + } + } + } + + 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) + #Preview { + ProfileView() + } +#endif diff --git a/examples/user-management/swift-user-management/README.md b/examples/user-management/swift-user-management/README.md new file mode 100644 index 0000000000..17e736b201 --- /dev/null +++ b/examples/user-management/swift-user-management/README.md @@ -0,0 +1,66 @@ +# Supabase Swift User Management + +This repo is a quick sample of how you can get started building apps using Swift and Supabase. You can find a step by step guide of how to build out this app in the [Quickstart: Swift guide](https://supabase.io/docs/guides/with-swift). + +This repo will demonstrate how to: +- Sign users in with Supabase Auth using [magic link](https://supabase.io/docs/reference/dart/auth-signin#sign-in-with-magic-link) +- Store and retrieve data with [Supabase database](https://supabase.io/docs/guides/database) +- Store image files in [Supabase storage](https://supabase.io/docs/guides/storage) + +![Supabase User Management example](supabase-swift-demo.png) + +## Getting Started + +Before running this app, you need to create a Supabase project and copy [your credentials](https://supabase.io/docs/guides/with-flutter#get-the-api-keys) to `Supabase.swift`. + +Run the application in a device or simulator using Xcode. + +## Database Schema + +```sql +-- Create a table for public "profiles" +create table profiles ( + id uuid references auth.users not null, + updated_at timestamp with time zone, + username text unique, + avatar_url text, + website text, + + primary key (id), + unique(username), + constraint username_length check (char_length(username) >= 3) +); + +alter table profiles enable row level security; + +create policy "Public profiles are viewable by everyone." + on profiles for select + using ( true ); + +create policy "Users can insert their own profile." + on profiles for insert + with check ( auth.uid() = id ); + +create policy "Users can update own profile." + on profiles for update + using ( auth.uid() = id ); + +-- Set up Realtime! +begin; + drop publication if exists supabase_realtime; + create publication supabase_realtime; +commit; +alter publication supabase_realtime add table profiles; + +-- Set up Storage! +insert into storage.buckets (id, name) +values ('avatars', 'avatars'); + +create policy "Avatar images are publicly accessible." + on storage.objects for select + using ( bucket_id = 'avatars' ); + +create policy "Anyone can upload an avatar." + on storage.objects for insert + with check ( bucket_id = 'avatars' ); +``` \ No newline at end of file diff --git a/examples/user-management/swift-user-management/Supabase.swift b/examples/user-management/swift-user-management/Supabase.swift new file mode 100644 index 0000000000..c76a5509f8 --- /dev/null +++ b/examples/user-management/swift-user-management/Supabase.swift @@ -0,0 +1,14 @@ +// +// Supabase.swift +// UserManagement +// +// Created by Guilherme Souza on 17/11/23. +// + +import Foundation +import Supabase + +let supabase = SupabaseClient( + supabaseURL: URL(string: "https://PROJECT_ID.supabase.co")!, + supabaseKey: "YOUR_SUPABASE_ANON_KEY" +) diff --git a/examples/user-management/swift-user-management/SwiftUIHelpers.swift b/examples/user-management/swift-user-management/SwiftUIHelpers.swift new file mode 100644 index 0000000000..bda5b20384 --- /dev/null +++ b/examples/user-management/swift-user-management/SwiftUIHelpers.swift @@ -0,0 +1,18 @@ +// +// SwiftUIHelpers.swift +// UserManagement +// +// Created by Guilherme Souza on 17/11/23. +// + +import SwiftUI + +extension View { + func onMac(_ block: (Self) -> some View) -> some View { + #if os(macOS) + return block(self) + #else + return self + #endif + } +} diff --git a/examples/user-management/swift-user-management/UserManagement.entitlements b/examples/user-management/swift-user-management/UserManagement.entitlements new file mode 100644 index 0000000000..625af03d99 --- /dev/null +++ b/examples/user-management/swift-user-management/UserManagement.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + + diff --git a/examples/user-management/swift-user-management/UserManagementApp.swift b/examples/user-management/swift-user-management/UserManagementApp.swift new file mode 100644 index 0000000000..dbbe1f33ea --- /dev/null +++ b/examples/user-management/swift-user-management/UserManagementApp.swift @@ -0,0 +1,17 @@ +// +// UserManagementApp.swift +// UserManagement +// +// Created by Guilherme Souza on 17/11/23. +// + +import SwiftUI + +@main +struct UserManagementApp: App { + var body: some Scene { + WindowGroup { + AppView() + } + } +} diff --git a/examples/user-management/swift-user-management/supabase-swift-demo.png b/examples/user-management/swift-user-management/supabase-swift-demo.png new file mode 100644 index 0000000000..e66509183f Binary files /dev/null and b/examples/user-management/swift-user-management/supabase-swift-demo.png differ