* docs: indicate publishable key instead of anon in many examples * replace your-anon-key to string indicating publishable or anon * fix your_... * apply suggestion from @ChrisChinchilla Co-authored-by: Chris Chinchilla <chris@chrischinchilla.com> * Update keys in code examples * Prettier fix * Update apps/docs/content/guides/functions/schedule-functions.mdx --------- Co-authored-by: Chris Chinchilla <chris@chrischinchilla.com>
566 lines
13 KiB
Plaintext
566 lines
13 KiB
Plaintext
---
|
|
title: 'Build a User Management App with Swift and SwiftUI'
|
|
description: 'Learn how to use Supabase in your SwiftUI App.'
|
|
---
|
|
|
|
<$Partial path="quickstart_intro.mdx" />
|
|
|
|

|
|
|
|
<Admonition type="note">
|
|
|
|
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).
|
|
|
|
</Admonition>
|
|
|
|
<$Partial path="project_setup.mdx" />
|
|
|
|
## 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/supabase-swift) dependency.
|
|
|
|
Add the `https://github.com/supabase/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.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=Supabase.swift
|
|
import Foundation
|
|
import Supabase
|
|
|
|
let supabase = SupabaseClient(
|
|
supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,
|
|
supabaseKey: "YOUR_SUPABASE_PUBLISHABLE_KEY"
|
|
)
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### 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.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=AuthView.swift
|
|
import SwiftUI
|
|
import Supabase
|
|
|
|
struct AuthView: View {
|
|
@State var email = ""
|
|
@State var isLoading = false
|
|
@State var result: Result<Void, Error>?
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
<Admonition type="note">
|
|
|
|
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).
|
|
|
|
</Admonition>
|
|
|
|
### 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`.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=ProfileView.swift
|
|
import SwiftUI
|
|
|
|
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
|
|
.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
|
|
.from("profiles")
|
|
.update(
|
|
UpdateProfileParams(
|
|
username: username,
|
|
fullName: fullName,
|
|
website: website
|
|
)
|
|
)
|
|
.eq("id", value: currentUser.id)
|
|
.execute()
|
|
} catch {
|
|
debugPrint(error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### 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.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=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
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
### 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.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=AppView.swift
|
|
import SwiftUI
|
|
|
|
struct AppView: View {
|
|
@State var isAuthenticated = false
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isAuthenticated {
|
|
ProfileView()
|
|
} else {
|
|
AuthView()
|
|
}
|
|
}
|
|
.task {
|
|
for await state in supabase.auth.authStateChanges {
|
|
if [.initialSession, .signedIn, .signedOut].contains(state.event) {
|
|
isAuthenticated = state.session != nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
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.
|
|
|
|
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
|
|
|
### 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:
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=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
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
|
|
|
#### Add `PhotosPicker` to profile page
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=ProfileView.swift
|
|
import PhotosUI
|
|
import Storage
|
|
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)
|
|
.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
|
|
.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
|
|
.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(
|
|
filePath,
|
|
data: data,
|
|
options: FileOptions(contentType: "image/jpeg")
|
|
)
|
|
|
|
return filePath
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
Finally, update your Models.
|
|
|
|
<$CodeTabs>
|
|
|
|
```swift name=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"
|
|
}
|
|
}
|
|
```
|
|
|
|
</$CodeTabs>
|
|
|
|
You no longer need the `UpdateProfileParams` struct, as you can now reuse the `Profile` struct for both request and response calls.
|
|
|
|
At this stage you have a fully functional application!
|