DEV Community

Cover image for How to build a native iOS dApp that uses the Flow blockchain as the backend
Juan Sagasti for The Agile Monkeys

Posted on • Updated on

How to build a native iOS dApp that uses the Flow blockchain as the backend

Introduction

This is the second and final part of a web3 tutorial about the Flow blockchain. Understanding the first part is essential and a pre-requisite because I explain there how to create and test Smart Contracts, the one we developed to create notes, its API, and how to write transactions and scripts to mutate and read data in the blockchain. Please take your time and make sure you understand it before continuing :)

First part: A notepad in the Flow blockchain

So, in this final part, you will learn:

  1. How to deploy smart contracts in a Flow blockchain network: testnet.
  2. How to build an iOS dApp that communicates with that contract in Flow's testnet. For user authentication, the app will use the Blocto Wallet, a cross-chain crypto wallet compatible with Flow. I assume you have a basic understanding of iOS programming. We will be using SwiftUI + Combine.

Deploying the Smart Contract

Note that the NotepadManager contract we will be using in the iOS dApp is already deployed in a flow account of my own. Remember that you can use any public smart contract in the blockchain, so you don't need to deploy the contract again in every account that wants to use it, as we learned in the previous article. This step is only for learning purposes in case you are interested in how to deploy contracts. You can skip this section and go directly to the dApp implementation part.

We are going to deploy the NotepadManager contract to the testnet, which is nearly identical to the mainnet, but we can operate without paying real gas. This is ideal for learning and testing our projects before deploying them to the production network (mainnet).

  1. Install the Flow CLI
  2. Generate the account keys. Remember to safely store the private key and don't share it with anyone.
  3. Create a Testnet flow account here with your public key.
  4. Create a folder for your contracts project, open it in the terminal, and do flow init. This will create a flow.json file with a default configuration.
  5. Open your project folder in an editor. I'm using Visual Studio Code, which has an excellent Cadence plugin you can install from the Extensions Marketplace.
  6. Add a new file to your project called NotepadManager.cdc. Paste the contract code we developed in the previous article. You can find it in the Playground, too.
  7. Edit the flow.json file so it looks like this:
{
    "emulators": {},
    "contracts": {
        "NotepadManagerV1": "NotepadManagerV1.cdc"
    },
    "networks": {
        "emulator": "127.0.0.1:3569",
        "mainnet": "access.mainnet.nodes.onflow.org:9000",
        "testnet": "access.devnet.nodes.onflow.org:9000"
    },
    "accounts": {
        "my-testnet-account": {
            "address": "YOUR ACCOUNT ADDRESS (without the '0x' prefix)",
            "key": "YOUR PRIVATE KEY"
        }
    },
    "deployments": {
        "testnet": {
            "my-testnet-account": ["NotepadManagerV1"]
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that we emptied the "emulators" field because we won't be using the local emulator but a real network. We also added the "contracts" and "deployments" fields to point to our contract and account.

Remember to fill the "address" and "key" fields, as specified. You obtained your account address in step #3 and the private key in step #2. Be careful with that key, and don't commit it to a public repository.

Now that everything is set up, it's time to deploy the contract! In the terminal, in your project folder, do:

flow project deploy
Enter fullscreen mode Exit fullscreen mode

You'll see something like this:

  Deploying 1 contracts for accounts: my-testnet-account
  NotepadManagerV1 -> 0x9bde7238c9c39e97 (09d8818a9d41ae2fc92f9dd6d7355772e7d97e28fb9e20b2f66f473420a16b31) 
Enter fullscreen mode Exit fullscreen mode

Which can be read as:

NotepadManagerV1 -> the address containing it (the transaction ID)
Enter fullscreen mode Exit fullscreen mode

You can see the transaction status or the account details through blockchain explorers like Tesnet Flowscan or Flow View Source. For example, if I introduce my account address 0x9bde7238c9c39e97 in Flowscan:

Flowscan

You can see the account balance, transaction history, and contracts deployed to the account. If you click the NotepadManagerV1 link, you can see the contract's code! This is powerful for public audits of the contracts in the blockchain.

Implementation

Our iOS dApp will have a pretty straightforward architecture:
dApp Architecture

All the elements in that picture are .swift files. You can start by creating an Xcode project (iOS 15.5 for the time being) and all those files. Files with the ...View suffix are SwiftUI views.

For communicating with the Flow blockchain, we will be using the iOS SDK that Outblock is building. A big update containing breaking changes is planned to be released soon, so we are going to lock now the SDK version to a particular commit (latest as of today) to be sure our code keeps working overtime. To do that, just add the dependency of the Swift Package Manager tied to the b4a9cfaaa0003f3f250490e6ccdf8b75065199a3 commit:
Package Dependency

Now we can start adding code to our Swift files!

FlowNotesApp.swift

This is the entry point of our app. It's in charge of initializing the fcl-swift framework with the needed endpoints to communicate with the Flow blockchain and Blocto Wallet and showing the LoginView or the MyNotesView depending on the user's authorization state.

import Combine
import FCL
import SwiftUI

enum UI {
    static let flowGreen = Color(red: 0, green: 1, blue: 130/255)
}

@main
struct FlowNotesApp: App {
    @State private var currentUser: User?

    init() {
        // FCL configuration with the Tesnet Nodes URLs needed 
        // to stablish the connection with the blockchain and 
        // the Blocto Wallet:
        fcl.config(appName: "FlowNotes",
                   appIcon: "https://placekitten.com/g/200/200",
                   location: "https://foo.com",
                   walletNode: "https://fcl-discovery.onflow.org/testnet/authn",
                   accessNode: "https://access-testnet.onflow.org",
                   env: "testnet",
                   scope: "email",
                   authn: "https://flow-wallet-testnet.blocto.app/api/flow/authn")

        let flowGreenColor = UIColor(cgColor: UI.flowGreen.cgColor ?? UIColor.green.cgColor) // Color to UIColor conversion
        UINavigationBar.appearance().largeTitleTextAttributes = [.foregroundColor: flowGreenColor]
        UINavigationBar.appearance().titleTextAttributes = [.foregroundColor: flowGreenColor]
    }

    var body: some Scene {
        WindowGroup {
            VStack {
                if currentUser != nil {
                    MyNotesView(vm: MyNotesVM())
                } else {
                    LoginView()
                }
            }
            .preferredColorScheme(.dark)
            .onReceive(fcl.$currentUser) { user in
                currentUser = user
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

LoginView.swift

LoginView

Simple screen with a big login button that invokes the authenticate() method on the LoginVM ViewModel.

LoginVM.swift

Blocto Wallet authentication

The authenticate() method will invoke the FCL's authentication method, which will present us with the Blocto Wallet webView for the user to log in. After successful authentication, the FCL's currentUser property will be populated, triggering an update in our FlowNotesApp class, which is listening to that property. The MyNotesView will be shown then.

import Combine
import FCL
import Foundation

final class LoginVM: ObservableObject {

    private var cancellables = Set<AnyCancellable>()

    func authenticate() {
        fcl
            .authenticate()
            .receive(on: DispatchQueue.main)
            .sink { result in
                print(result)
            } receiveValue: { response in
                print(response)
            }
            .store(in: &cancellables)
    }
}
Enter fullscreen mode Exit fullscreen mode

MyNotesView.swift

MyNotesView

This view is in charge of showing the list of notes retrieved by MyNotesVM. The users can also:

  • Delete notes with the swipe-to-left gesture.
  • Present the NoteEditorView from the "+" button to create new notes.
  • Show the action menu from the "..." button. Here, users can manually refresh the list, delete the entire Notepad or sign out.

You will note that the transactions are pretty slow compared to a regular backend. This is because mutations of the blockchain need to be accepted and mined, which could take several seconds to happen. For simplicity, we present a full-screen loading screen while the VM's state is loading, until the transaction is sealed and a script can safely retrieve the final result. Read-only operations (scripts) like the notes fetch are blazing fast. If the transaction fails with an error, we present it to the user with an AlertView.

As you see, the transactions and script code used here are nearly identical to the ones in the first article (I omitted the comments in these to avoid repetition):

import SwiftUI

struct MyNotesView: View {

    @StateObject private var vm: MyNotesVM

    @State private var isLoading = false
    @State private var isShowingNoteEditor = false
    @State private var isShowingErrorAlert = false

    init(vm: MyNotesVM) {
        _vm = .init(wrappedValue: vm)
    }

    private var signOutButton: some View {
        Button {
            vm.signOut()
        } label: {
            Label("Sign Out", systemImage: "person.crop.circle.badge.xmark")
        }
    }

    private var refreshNotepadButton: some View {
        Button {
            vm.queryNotes()
        } label: {
            Label("Refresh notepad", systemImage: "arrow.clockwise")
        }
    }

    private var deleteNotepadButton: some View {
        Button {
            vm.deleteNotepad()
        } label: {
            Label("Delete notepad", systemImage: "trash")
        }
        .opacity(vm.notes != nil ? 1 : 0)
        .disabled(vm.notes == nil)
    }

    private var moreOptionsButton: some View {
        Menu {
            refreshNotepadButton
            Divider()
            deleteNotepadButton
            Divider()
            signOutButton
        } label: {
            Image(systemName: "ellipsis")
        }
    }

    private var addNoteButton: some View {
        Button {
            isShowingNoteEditor = true
        } label: {
            Image(systemName: "plus")
        }
    }

    var body: some View {
        ZStack {
            NavigationView {
                Group {
                    if let notes = vm.notes {
                        List {
                            ForEach(notes, id: \.id) { note in
                                NoteRow(note: note)
                                    .listRowBackground(Color.black)
                            }
                            .onDelete { index in
                                vm.deleteNote(atIndex: index.first)
                            }
                        }

                    } else if vm.state == .loading {
                        EmptyView()
                    } else {
                        Text("Your notepad is empty ๐Ÿค“\nAdd a new note from\nthe'๏ผ‹'button")
                            .multilineTextAlignment(.center)
                            .font(.headline)
                            .foregroundColor(.gray)
                    }
                }
                .navigationBarItems(leading: moreOptionsButton, trailing: addNoteButton)
                .navigationTitle("Flow Notes")
            }
            .tint(UI.flowGreen)

            ZStack {
                Color.black
                    .edgesIgnoringSafeArea(.all)
                    .opacity(isLoading ? 0.7 : 0)

                ProgressView {
                    Text("On it! ๐Ÿš€ \nPlease wait...")
                        .multilineTextAlignment(.center)
                        .foregroundColor(.white)
                }
                .progressViewStyle(CircularProgressViewStyle(tint: .white))
                .opacity(isLoading ? 1 : 0)
            }
        }
        .onChange(of: vm.state) { newValue in
            isLoading = newValue == .loading
            isShowingErrorAlert = newValue.isFailed
        }
        .alert(isPresented: $isShowingErrorAlert, content: {
            Alert(title: Text("Error!"),
                  message: Text(vm.state.errorMessage ?? "Unknown"),
                  dismissButton: .default(Text("OK"), action: {
                vm.currentErrorDidDismiss()
            }))
        })
        .sheet(isPresented: $isShowingNoteEditor) {
            isShowingNoteEditor = false
        } content: {
            NoteEditorView().environmentObject(vm)
        }
    }
}

struct NoteRow: View {
    let note: Note

    var body: some View {
        HStack {
            VStack(alignment: .leading, spacing: 5) {
                Text(note.title)
                    .foregroundColor(.black)
                    .font(.body.weight(.bold))

                Text(note.body)
                    .foregroundColor(.black)
                    .font(.caption)
            }

            Spacer()
        }
        .padding()
        .background(UI.flowGreen)
        .cornerRadius(10)
    }
}

private struct PreviewWrapper: View {

    var vm = MyNotesVM()

    init() {
        vm.notes = [Note(id: 0, title: "This is a great note", body: "This is the body of a great note."),
                    Note(id: 1, title: "Another note", body: "The best body that was ever written.")]
    }

    var body: some View {
        MyNotesView(vm: vm).preferredColorScheme(.dark)
    }
}

struct MyNotesView_Previews: PreviewProvider {
    static var previews: some View {
        PreviewWrapper()
    }
}
Enter fullscreen mode Exit fullscreen mode

MyNotesVM

This is the VM that defines the set of operations available to mutate the notepad through transactions or fetch the notes through a script.

Every time you send a transaction, the SDK will present a transaction confirmation screen (webView) from the Blocto Wallet, so you can review the transaction's code and approve it:

Transaction Confirmation Screen


import Combine
import FCL
import Flow
import Foundation

enum LoadingState: Equatable {
    case idle
    case loading
    case didFail(error: String)

    var isFailed: Bool {
        guard case .didFail = self else { return false }
        return true
    }

    var errorMessage: String? {
        guard case let .didFail(error) = self else { return nil }
        return error
    }
}

final class MyNotesVM: ObservableObject {

    @Published private(set) var state = LoadingState.idle
    @Published var notes: [Note]?

    private let notepadManagerAddress = "0x9bde7238c9c39e97"
    private var cancellables = Set<AnyCancellable>()

    init() {
        defer {
            queryNotes()
        }
    }

    // MARK: - Public API

    func createNote(title: String, body: String) {
        guard fcl.currentUser?.addr != nil else { return }

        state = .loading

        fcl.mutate {
            cadence {
                // Transaction that checks if the Notepad 
                // exists (and creates it if needed) before 
                // creating and adding the note to it:
                 """
                 import NotepadManagerV1 from \(notepadManagerAddress)

                 transaction {
                     prepare(acct: AuthAccount) {
                         var notepad = acct.borrow<&NotepadManagerV1.Notepad>(from: /storage/NotepadV1)

                         if notepad == nil { 
                             // Create it and make it public
                             acct.save(<- NotepadManagerV1.createNotepad(), to: /storage/NotepadV1)
                             acct.link<&NotepadManagerV1.Notepad>(/public/PublicNotepadV1, target: /storage/NotepadV1)
                         }

                         var theNotepad = acct.borrow<&NotepadManagerV1.Notepad>(from: /storage/NotepadV1)
                         theNotepad?.addNote(title: "\(title)", body: "\(body)")
                     }
                 }
                 """
            }

            gasLimit {
                1000
            }
        }
        .receive(on: DispatchQueue.main)
        .sink { completion in
            if case let .failure(error) = completion {
                self.state = .didFail(error: error.localizedDescription)
            }
        } receiveValue: { [weak self] transactionId in
            guard let self = self else { return }

            self.waitForSealedTransaction(transactionId) {
                self.queryNotes()
            }
        }
        .store(in: &cancellables)
    }

    func deleteNote(atIndex index: Int?) {
        guard fcl.currentUser?.addr != nil, let index = index, let idToDelete = notes?[index].id else { return }

        state = .loading

        fcl.mutate {
            cadence {
                // Transaction that tries to delete the note 
                // if the Notepad exists:
                 """
                 import NotepadManagerV1 from \(notepadManagerAddress)

                 transaction {
                     prepare(acct: AuthAccount) {
                            let notepad = acct.borrow<&NotepadManagerV1.Notepad>(from: /storage/NotepadV1)
                            notepad?.deleteNote(noteID: \(idToDelete))
                     }
                 }
                 """
            }

            gasLimit {
                1000
            }
        }
        .receive(on: DispatchQueue.main)
        .sink { completion in
            if case let .failure(error) = completion {
                self.state = .didFail(error: error.localizedDescription)
            }
        } receiveValue: { [weak self] transactionId in
            guard let self = self else { return }

            self.waitForSealedTransaction(transactionId) {
                self.queryNotes()
            }
        }
        .store(in: &cancellables)
    }

    func deleteNotepad() {
        guard fcl.currentUser?.addr != nil else { return }

        state = .loading

        fcl.mutate {
            cadence {
                // Transaction that deletes the Notepad:
                 """
                 import NotepadManagerV1 from \(notepadManagerAddress)

                 transaction {
                     prepare(acct: AuthAccount) {
                         var notepad <- acct.load<@NotepadManagerV1.Notepad>(from: /storage/NotepadV1)!
                         NotepadManagerV1.deleteNotepad(notepad: <- notepad)
                     }
                 }
                 """
            }

            gasLimit {
                1000
            }
        }
        .receive(on: DispatchQueue.main)
        .sink { completion in
            if case let .failure(error) = completion {
                self.state = .didFail(error: error.localizedDescription)
            }
        } receiveValue: { [weak self] transactionId in
            guard let self = self else { return }

            self.waitForSealedTransaction(transactionId) {
                self.notes = nil
                self.state = .idle
            }
        }
        .store(in: &cancellables)
    }

    func queryNotes() {
        guard let currentUserAddress = fcl.currentUser?.addr else { return }

        state = .loading

        fcl.query {
            cadence {
                // Script that tries to get all notes from the 
                // current Notepad. It returns nil in case the 
                // Notepad doesn't exist yet publicly:
                """
                import NotepadManagerV1 from \(notepadManagerAddress)

                pub fun main(): [NotepadManagerV1.NoteDTO]? {
                    let notepadAccount = getAccount(0x\(currentUserAddress))

                    let notepadCapability = notepadAccount.getCapability<&NotepadManagerV1.Notepad>(/public/PublicNotepadV1)
                    let notepadReference = notepadCapability.borrow()

                    return notepadReference == nil ? nil : notepadReference?.allNotes()
                }
                """
            }
        }
        .receive(on: DispatchQueue.main)
        .sink { [weak self] completion in
            guard let self = self else { return }

            if case let .failure(error) = completion {
                self.state = .didFail(error: error.localizedDescription)
            }
        } receiveValue: { [weak self] result in
            print(result)

            guard let self = self else { return }
            guard let valuesArray = result.fields?.value.toOptional()?.value.toArray() else {
                self.notes = nil
                self.state = .idle
                return
            }

            let notes: [Note] = valuesArray.compactMap {
                guard let noteData = $0.value.toStruct()?.fields else { return nil }
                // It's not the best decoding code, but enough 
                // for what we are illustrating. The SDK 
                // maintainer is working on a better data 
                // decoder for future versions.
                let id = noteData[0].value.value.toUInt64()
                let title = noteData[1].value.value.toString()
                let body = noteData[2].value.value.toString()

                guard let id = id, let title = title, let body = body else { return nil }
                return Note(id: id, title: title, body: body)
            }

            self.notes = notes
            self.state = .idle
        }.store(in: &cancellables)
    }

    func currentErrorDidDismiss() {
        state = .idle
    }

    func signOut() {
        fcl.currentUser = nil
    }

    // MARK: - Private

    private func waitForSealedTransaction(_ transactionId: String, onSuccess: @escaping () -> Void) {
        // We are using a background thread because we don't 
        // want the .wait() to block the main event loop:
        DispatchQueue.global(qos: .userInitiated).async {
            do {
                // We want to wait for the transaction to be 
                // sealed (last transaction state) because we 
                // need the final result to be available in 
                // the blockchain. It's slower, but we don't 
                // want intermediate transaction states where 
                // you can still get empty errors or data when 
                // querying the blockchain with a Script.
                let result = try Flow.ID(hex: transactionId).onceSealed().wait()

                // And we update the state again in the main thread:
                DispatchQueue.main.async {
                    print(result)

                    if !result.errorMessage.isEmpty {
                        self.state = .didFail(error: result.errorMessage)
                    } else {
                        onSuccess()
                    }
                }
            } catch {
                DispatchQueue.main.async {
                    self.state = .didFail(error: error.localizedDescription)
                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

NoteEditorView.swift

NoteEditorView

The NoteEditorView is just a form to compose and send a new note.

import SwiftUI

struct NoteEditorView: View {

    @EnvironmentObject private var vm: MyNotesVM
    @Environment(\.dismiss) private var dismiss

    @State private var noteTitle = ""
    @State private var noteBody = ""

    private var canSaveNote: Bool { !noteTitle.isEmpty && !noteBody.isEmpty}

    private var saveButton: some View {
        Button("Save") {
            vm.createNote(title: noteTitle, body: noteBody)
            dismiss()
        }
        .opacity(canSaveNote ? 1 : 0.5)
        .disabled(!canSaveNote)
    }

    var body: some View {
        NavigationView {
            VStack {
                VStack {
                    TextField("Title", text: $noteTitle)
                        .foregroundColor(.black)
                        .font(.body.weight(.bold))
                        .accentColor(.black)
                        .padding(.vertical, 7)

                    TextField("Body", text: $noteBody)
                        .foregroundColor(.black)
                        .accentColor(.black)
                        .padding(.vertical, 7)
                }
                .padding(.horizontal)
                .background(UI.flowGreen.opacity(0.5))
                .cornerRadius(10)

                Spacer()
            }
            .padding()
            .navigationTitle("New note")
            .navigationBarTitleDisplayMode(.inline)
            .navigationBarItems(trailing: saveButton)
        }
        .tint(UI.flowGreen)
    }
}

struct NoteEditor_Previews: PreviewProvider {
    static var previews: some View {
        NoteEditorView()
            .preferredColorScheme(.dark)
            .environmentObject(MyNotesVM())
    }
}
Enter fullscreen mode Exit fullscreen mode

Note.swift

Just the Note model:

import Flow
import Foundation

struct Note {
    let id: UInt64
    let title: String
    let body: String
}
Enter fullscreen mode Exit fullscreen mode

And that's it! I hope you find this helpful and that it inspires you to find more cool use cases. If you want to see a full demo video in action and check this project's source code, please refer to the FlowNotes repository.

There's an edit transaction use case in the NotepadManager contract that I haven't used in the app. I leave it to you as an exercise if you are motivated enough :)

Thank you for reading!

Top comments (3)

Collapse
 
andrew54068 profile image
andrew54068 • Edited

Nice tutorial.
FYI, if anyone wants to integrate with blocto both web and native app seamlessly, there are some repos from Blocto team:
github.com/portto/fcl-swift
github.com/portto/flow-swift-sdk

Android:
github.com/portto/fcl-android

Collapse
 
ondev profile image
ondev

Nice tutorial. Thanks very much.

Here I have a question, how to keep the connection to the wallet, even if reopen the App completely.

Collapse
 
jfsagasti profile image
Juan Sagasti

Hi! That's a great question I, too, asked myself, but I decided to keep it simple for the tutorial.

I've asked the SDK maintainer about this, and he told me that this might not be currently possible with the current structure. Also, the Blocto login has a session id, and each session id has an expiration time for security reasons (which I don't know). You could try to persist that session info on disk somehow (I haven't dug enough to see if this is even possible) but consider that possible expiration. Anyway, Blocto remembers you in subsequent authentications, and you only need to accept the confirmation screen as you do with transactions. He also told me that they are working in another Flow-compatible wallet called Lilico that uses a different auth pattern, and what you are asking will be easy to achieve :)

If you feel like investigating this great point, let us know your findings! Much appreciated :)