mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 14:49:10 +03:00
Partials are currently defined via MDX includes. This PR switches to pre-compile-time partials, which have a new syntax: ``` <$Partial path="path/to/file.mdx" /> ``` ## Rationale This produces two improvements: 1. Partial substitution can occur in pipelines that don't use MDX compilation. For example, we can now do partial substitution before building the search index, so partial content will also be indexed. 2. After the App Router migration, the MDXProviders should've been deprecated, but were kept around for the sole reason of making partials work, and leading to us shipping unnecessary client-side code. We get a minor decrease in overall client bundle size (5.74 MB to 5.6 MB) by getting rid of the Providers. ## Breaking changes Besides the change to partial syntax, the arguments are also less powerful than before because we are doing string substitution and don't have the full power of JS. Defining string variables is still possible (documented in the Contributing guide), and since that's all we actually do in practice, this shouldn't be too cumbersome. There is always the escape hatch of making a custom component for more complex content reuse cases.
534 lines
13 KiB
Plaintext
534 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.
|
|
|
|
```swift Supabase.swift
|
|
import Foundation
|
|
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<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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
<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`.
|
|
|
|
```swift 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 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
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
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:
|
|
|
|
```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
|
|
}
|
|
```
|
|
|
|
{/* supa-mdx-lint-disable-next-line Rule001HeadingCase */}
|
|
|
|
#### Add `PhotosPicker` to profile page
|
|
|
|
```swift 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
|
|
+ }
|
|
}
|
|
```
|
|
|
|
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.
|
|
|
|
At this stage you have a fully functional application!
|