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:
-
How to deploy smart contracts in a Flow blockchain network:
testnet
. -
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
).
- Install the Flow CLI
- Generate the account keys. Remember to safely store the private key and don't share it with anyone.
- Create a Testnet flow account here with your public key.
- Create a folder for your contracts project, open it in the terminal, and do
flow init
. This will create aflow.json
file with a default configuration. - 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.
- 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. - 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"]
}
}
}
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
You'll see something like this:
Deploying 1 contracts for accounts: my-testnet-account
NotepadManagerV1 -> 0x9bde7238c9c39e97 (09d8818a9d41ae2fc92f9dd6d7355772e7d97e28fb9e20b2f66f473420a16b31)
Which can be read as:
NotepadManagerV1 -> the address containing it (the transaction ID)
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:
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:
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:
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
}
}
}
}
LoginView.swift
Simple screen with a big login button that invokes the authenticate()
method on the LoginVM
ViewModel.
LoginVM.swift
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)
}
}
MyNotesView.swift
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()
}
}
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:
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)
}
}
}
}
}
NoteEditorView.swift
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())
}
}
Note.swift
Just the Note model:
import Flow
import Foundation
struct Note {
let id: UInt64
let title: String
let body: String
}
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)
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
Nice tutorial. Thanks very much.
Here I have a question, how to keep the connection to the wallet, even if reopen the App completely.
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 :)