docs: add getting started guide for Swift (#19044)
* docs: getting started guide for Swift * Apply suggestions from code review Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * Add new tutorial to menu * Add correct image for tutorial * style: format with-swift tutorial * docs: add example to the repository --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> Co-authored-by: Thor 雷神 Schaeff <5748289+thorwebdev@users.noreply.github.com>
This commit is contained in:
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -307,6 +307,44 @@ In certain auth scenarios you will need to handle linking back into your applica
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="swift" label="Swift">
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
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
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- other tags -->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>io.supabase.user-management</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
```
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
|
||||
|
||||
527
apps/docs/pages/guides/getting-started/tutorials/with-swift.mdx
Normal file
527
apps/docs/pages/guides/getting-started/tutorials/with-swift.mdx
Normal file
@@ -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.',
|
||||
}
|
||||
|
||||
<QuickstartIntro />
|
||||
|
||||

|
||||
|
||||
<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>
|
||||
|
||||
<ProjectSetup />
|
||||
|
||||
## 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<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
|
||||
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
|
||||
|
||||
<StorageManagement />
|
||||
|
||||
At this stage you have a fully functional application!
|
||||
|
||||
export const Page = ({ children }) => <Layout meta={meta} children={children} />
|
||||
|
||||
export default Page
|
||||
BIN
apps/docs/public/img/supabase-swift-demo.png
Normal file
BIN
apps/docs/public/img/supabase-swift-demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
35
examples/user-management/swift-user-management/AppView.swift
Normal file
35
examples/user-management/swift-user-management/AppView.swift
Normal file
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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<Void, Error>?
|
||||
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
17
examples/user-management/swift-user-management/Info.plist
Normal file
17
examples/user-management/swift-user-management/Info.plist
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Editor</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>io.supabase.user-management</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
22
examples/user-management/swift-user-management/Models.swift
Normal file
22
examples/user-management/swift-user-management/Models.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
183
examples/user-management/swift-user-management/ProfileView.swift
Normal file
183
examples/user-management/swift-user-management/ProfileView.swift
Normal file
@@ -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
|
||||
66
examples/user-management/swift-user-management/README.md
Normal file
66
examples/user-management/swift-user-management/README.md
Normal file
@@ -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)
|
||||
|
||||

|
||||
|
||||
## 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' );
|
||||
```
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-only</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Reference in New Issue
Block a user