Turbo Native iOS Offline Mode using SQLITE - Part 1

This tutorial follows on from my last blog post on bring your turbo-ios app offline . There are various ways to present data offline for your app, but we will stick with Sqlite for this tutorial series. If you are interested in more, let me know.

The first thing we do is get all the boilerplate code set up. Our rails app is going to be a simple To-List app.

Things have changed a lot in Turbo Native iOS, and getting the app set up is so much faster thanks to the work of Joe Masilotti. Note: This code is for demo and brevity. If you are a copy/pasta kinda person, be sure to tweak the AppDatabase methods and look to use something like Combine with MVVM pattern, as it will make life much easier.

Source code is here.

Generating Rails app

The first thing we will do is generate our Rails app, which you can find here.

First, you want to create the app.

rails new turbo_native_offline  --database=postgresql
rake db:create

Create a simple ToDo scaffold.

rails g scaffold Todo title:string complete:boolean

Then migrate

rails db:migrate

Finally, we just need to do 3 more changes.

First, we create the following controller in

# app/controllers/turbo/ios/configurations_controller.rb
module Turbo
  module Ios
    class ConfigurationsController < ApplicationController
      def ios_v1
        render json: {
          settings: {},
          rules: [
            {
              patterns: [
                "/new$",
                "/edit$"
              ],
              properties: {
                context: "modal"
              }
            }
          ]
        }
      end
    end
  end
end

Our corresponding routes.rb file

Rails.application.routes.draw do
  namespace :turbo do
    namespace :ios do
      resources :configurations, only: [] do
        get :ios_v1, on: :collection
      end
    end
  end
  resources :todos
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

  # Defines the root path route ("/")
  root "todos#index"
end

Shoutout to Joe for inspiring this versioning in his workshop.

Finally, we will apply a default style called simplecss.

<!DOCTYPE html>
<html>
  <head>
    <title>Server</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <link rel="stylesheet" href="https://cdn.simplecss.org/simple.css", date-turbo-track='reload'>
    <%= javascript_importmap_tags %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

Now, you can run the server.

rails s 

Turbo Swift First Steps

Note that we are using the Turbo Navigator library with Turbo here, but that will be upstreamed to Turbo iOS soon.

First, we create our new iOS app. Open Xcode, Click “Create New Project” > “App”.

For InterFace, Select StoryBoard.

Call it TurboToDo if you lack imagination at the moment.

Next, we need to install our two libraries. Turbo and Turbo Navigator.

Click File > Add Package Dependencies and paste each library’s Github url.

Now, we will setup the app to use Turbo straight away. Replace SceneDelegate with the following:

import Turbo
import TurboNavigator
import UIKit

let baseURL = URL(string: "http://localhost:3000")!

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    private lazy var turboNavigator = TurboNavigator(delegate: self, pathConfiguration: pathConfiguration)
    private lazy var pathConfiguration = PathConfiguration(sources: [
        .server(baseURL.appendingPathComponent("/turbo/ios/configurations/ios_v1.json"))
    ])

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }

        self.window = UIWindow(windowScene: windowScene)
        self.window?.makeKeyAndVisible()

        self.window?.rootViewController = self.turboNavigator.rootViewController
        self.turboNavigator.route(baseURL)
    }
}

extension SceneDelegate: TurboNavigationDelegate {}

Assuming you are running the Rails server, you should be able to build the app and see that it works.

We have the foundation in place to build upon. Let’s start by adding a native screen.

Native Screen

Now that we have everything let’s get a native screen up and running.

In my previous tutorials, we had to do a lot of work to render a native screen. Thankfully, this is now much easier, thanks to TurboNavigator.

Let’s first create a dummy route that is only available on our Turbo Native app.

In todos/index:

<% if turbo_native_app? %>
  <%= link_to "Native todo", '/native_todos' %>
<% end %>

Note This is just for demo purposes. In a real app, you should use CSS, but we need to take shortcuts for brevity.

Before we go back to our iOS codebase, let’s add the following to the JSON output in app/controllers/turbo/ios/configurations_controller.rb

{
  patterns: [
   "/native_todos$"
              ],
   properties: {
    "view-controller": "todos"
    }
...

Note the ViewController property. This bit of code makes it similar to how we render native screens in Android.

Back to our iOS codebase. Let’s create the corresponding View Controller called ToViewController.

import Foundation
import UIKit
import TurboNavigator
import SwiftUI

class ToDoViewController: UIHostingController<TodoIndex>, PathConfigurationIdentifiable {
    static var pathConfigurationIdentifier: String { "todos" }

    init() {
        super.init(rootView: TodoIndex())
    }
    
    @MainActor required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

Note the static var pathConfigurationIdentifier: String { "todos" }.

We now need to create the SwiftUI view called ToDoIndex.

import SwiftUI

struct TodoIndex: View {
    var body: some View {
        Text("Hello World")
    }
}

#Preview {
    TodoIndex()
}

Finally, let’s wire up TurboNavigator to react to that route and render a native screen.

// SceneDelagate
extension SceneDelegate: TurboNavigationDelegate {
    func handle(proposal: VisitProposal) -> ProposalResult {
        if proposal.viewController == ToDoViewController.pathConfigurationIdentifier {
            return .acceptCustom(ToDoViewController())
        } else {
            return .accept
        }

    }
}

Rerun the app, and clicking on that link will now render a native screen.

It’s incredible how far we have come compared to what I was doing before.

Less code is better code.

Setting Up GRDB

As discussed in the previous post, we will use the GRDB, a swift library for Sqlite, for our local storage solution.

Unlike Rails, we have to setup a lot of conversions ourself, the following code is only going to get you up and running but if you are going to use this in a production app, you will have to make it more robust.

First things first, we have to add GRDB and GRDBQuery to our app.

Next, we setup a AppDatabase file which I nest under Models.

Luckily, this is a well-documented library so that we can browse through the many demo applications as well as the documentation.

Create a file under Models called AppDatabase.

import Foundation
import GRDB
import os.log

struct AppDatabase {
    init(_ dbWriter: any DatabaseWriter) throws {
        self.dbWriter = dbWriter
        try migrator.migrate(dbWriter)
    }

    private let dbWriter: any DatabaseWriter
}


extension AppDatabase {
    private static let sqlLogger = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "SQL")

    public static func makeConfiguration(_ base: Configuration = Configuration()) -> Configuration {
        var config = base



        if ProcessInfo.processInfo.environment["SQL_TRACE"] != nil {
            config.prepareDatabase { db in
                db.trace {
            
                    os_log("%{public}@", log: sqlLogger, type: .debug, String(describing: $0))
                }
            }
        }

#if DEBUG
        config.publicStatementArguments = true
#endif

        return config
    }
}

// MARK: - Database Migrations

extension AppDatabase {
    private var migrator: DatabaseMigrator {
        var migrator = DatabaseMigrator()

#if DEBUG
        migrator.eraseDatabaseOnSchemaChange = true
#endif

        migrator.registerMigration("createToDo") { db in
            try db.create(table: "todo") { t in
                t.autoIncrementedPrimaryKey("id")
                t.column("title", .text).notNull()
                t.column("complete", .boolean)
                t.column("createdAt", .datetime)
                t.column("updatedAt", .datetime)
            }
        }

        return migrator
    }
}


extension AppDatabase {
    /// A validation error that prevents some players from being saved into
    /// the database.
    enum ValidationError: LocalizedError {
        case missingName

        var errorDescription: String? {
            switch self {
            case .missingName:
                return "Please provide a title."
            }
        }
    }

}

extension AppDatabase {
    var reader: DatabaseReader {
        dbWriter
    }
}

Next, we need a singleton connection to the database. Create a file called Persistance.

import Foundation
import GRDB

extension AppDatabase {
    static let shared = makeShared()

    private static func makeShared() -> AppDatabase {
        do {
            let fileManager = FileManager.default
            let appSupportURL = try fileManager.url(
                for: .applicationSupportDirectory, in .userDomainMask,
                appropriateFor: nil, create: true)
            let directoryURL = appSupportURL.appendingPathComponent("Database", isDirectory: true)

            if CommandLine.arguments.contains("-reset") {
                try? fileManager.removeItem(at: directoryURL)
            }

            try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)

            let databaseURL = directoryURL.appendingPathComponent("db.sqlite")
            NSLog("Database stored at \(databaseURL.path)")
            let dbPool = try DatabasePool(
                path: databaseURL.path,
                // Use default AppDatabase configuration
                configuration: AppDatabase.makeConfiguration())

            let appDatabase = try AppDatabase(dbPool)


            return appDatabase
        } catch {
            fatalError("Unresolved error \(error)")
        }
    }

    static func empty() -> AppDatabase {
        let dbQueue = try! DatabaseQueue(configuration: AppDatabase.makeConfiguration())
        return try! AppDatabase(dbQueue)
    }

    static func random() -> AppDatabase {
        let appDatabase = empty()
        return appDatabase
    }
}

Phew, boilerplate done, let’s start adding and reading ToDo’s from our database.

Our ToDo CRUD actions

With everything ready, we can start creating the CRUD interface. We need to create a model that conforms to the protocols provided by GRDB. Once we create a model, we can modify our ToDoIndexView to have the CRUD actions of Create, Read, Update and Destroy.

First, create a file called Todo.swift

import Foundation
import GRDB

struct Todo: Identifiable, Codable, TableRecord, PersistableRecord, FetchableRecord {
    var id: Int64?
    var title: String
    var complete: Bool
    var createdAt: Date
    var updatedAt: Date
}

extension Todo: MutablePersistableRecord {
    mutating func didInsert(_ inserted: InsertionSuccess) {
        id = inserted.rowID
    }
}

Create and Read

Let’s modify our AppDataBase file to have both a create and read method.

extension AppDatabase {
    
    func createRandomToDo() throws {
        let todo = Todo(id: nil, title: UUID().uuidString, complete: false, createdAt: Date(), updatedAt: Date())

        try dbWriter.write { db in
            do {
                try todo.save(db)
            } catch {
                print(error.localizedDescription)
            }
        }
    }

    func getToDos() throws -> [Todo] {
        var todos = [Todo]()
        try dbWriter.write { db in
            do {
                todos = try Todo.fetchAll(db)
            } catch {
                print(error.localizedDescription)
            }
        }
        return todos
    }

// more to come
}

Now, back to our ToDoIndexView, let’s add the following:

struct TodoIndex: View {
    var appDatabase: AppDatabase = AppDatabase.shared
    @State private var todos: [Todo] = []

    var body: some View {
        List {
            ForEach(todos, id: \.id) { todo in
                Text("ID: \(todo.id ?? 0) \(todo.title)")
            }
        }
        .onAppear {
            getToDos()
        }
        .toolbar(id: "Actions") {
            ToolbarItem(id: "Add", placement: .primaryAction) {
                Button {
                    createRandomToDo()
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
    }

    func createRandomToDo() {
        do {
            try appDatabase.createRandomToDo()
        } catch {
            print(error.localizedDescription)
        }
        getToDos()
    }


    func getToDos() {
        do {
            todos = try appDatabase.getToDos()
        } catch {
            print(error.localizedDescription)
        }
    }
}

#Preview {
    TodoIndex()
}

You should now be able to build and run the code. If you press the “plus” icon, a new ToDo is added, which has a random UUID.

You’ll note that we call getToDos() after each create action.

Note: This is for brevity. In a production app, you’ll want to use combine or some sort of database observer to update the views.

Delete

We need to add the following to our AppDatabase file for our delete action.

   func deleteToDo(_ todo: Todo)  throws {
        try  dbWriter.write { db in
            _ = try Todo.deleteAll(db, ids: [todo.id])
        }
    }

In our view, we add onDelete to the ForEach loop followed by a delete method.

   List {
            ForEach(todos, id: \.id) { todo in
                Text("ID: \(todo.id ?? 0) \(todo.title)")
            }
            .onDelete(perform: deleteToDo)
        }

Our delete method is as follows:

    func deleteToDo(at offsets: IndexSet) {
        for offset in offsets {
            let todo = todos[offset]
            do {
                try appDatabase.deleteToDo(todo)
            } catch {
                print(error.localizedDescription)
            }
        }
        getToDos()
    }

Rebuild the app, and you’ll see that you can swipe to delete.

If you look at the console in Xcode, it should give you the file path for Sqlite, you can open that using a tool like TablePlus.

Create ToDo

We will now create an actual Todo instead of a random one.

First things first, we create a new SwiftUI View.

File > New File > SwiftUI

Call it NewToDoView

import SwiftUI

struct NewTodoView: View {
    var appDatabase: AppDatabase = AppDatabase.shared
    @Binding var isPresented: Bool
    @State private var title = ""
    var body: some View {
        Form {
            Section("Create ToDo") {
                TextField("Title", text: $title)
            }

            Section {
                Button {
                    Task {
                        let todo = Todo(title: title, complete: false, createdAt: Date(), updatedAt: Date())
                        do {
                            try appDatabase.createToDo(todo)
                        } catch {
                            print(error.localizedDescription)
                        }
                        isPresented = false
                    }
                } label: {
                    HStack {
                        Spacer()
                        Text("Save")
                        Spacer()
                    }
                }


            }
        }
    }
}

#Preview {
    NewTodoView(isPresented: .constant(false))
}

You must add a @State wrapper property to TodoIndexView.

struct TodoIndex: View {
    @State private var showingNewSheet = false
    .....

Then, display the view you created whenever the user presses the “plus” button.

        .toolbar(id: "Actions") {
            ToolbarItem(id: "Add", placement: .primaryAction) {
                Button {
                    showingNewSheet.toggle()
                    } label: {
                        Label("Add", systemImage: "plus")
                    }
                }
            }
        .sheet(isPresented: $showingNewSheet, onDismiss: getToDos, content: {
            NewTodoView(isPresented: $showingNewSheet)
        })

Finally, we just add our create a method to the appDatabase.

    func createToDo(_ todo: Todo) throws {
        try dbWriter.write { db in
            do {
                try todo.save(db)
            } catch {
                print(error.localizedDescription)
            }
        }
    }

Update ToDo

To complete our CRUD actions, we just need to be able to update our ToDo

First, we edit our TodoIndexViewby adding two new @State variables.

   @State private var showingEditSheet = false
   @State private var selectedTodo: Todo?

Next, we want to trigger a edit action when we swipe on a todo.

ForEach(todos, id: \.id) { todo in
                Text("ID: \(todo.id ?? 0) \(todo.title)")
                    .swipeActions {
                        Button {
                            selectedTodo = todo
                            showingEditSheet.toggle()
                        } label: {
                            Label("Edit", systemImage: "pencil")
                        }
....

Finally, we display our edit sheet.

     .sheet(isPresented: $showingEditSheet, onDismiss: getToDos, content: {
            if let todo = selectedTodo {
                EditTodoView(isPresented: $showingEditSheet, todo: todo)
            }
        })

Then we create our edit sheet.

import SwiftUI

struct EditTodoView: View {
    var appDatabase: AppDatabase = AppDatabase.shared
    @Binding var isPresented: Bool
    @State var todo: Todo

    var body: some View {
        Form {
            Section("Create ToDo") {
                TextField("Title", text: $todo.title)
            }

            Section {
                Button {
                    Task {
                        do {
                            try appDatabase.updateToDo(&todo)
                        } catch {
                            print(error.localizedDescription)
                        }
                        isPresented = false
                    }
                } label: {
                    HStack {
                        Spacer()
                        Text("Save")
                        Spacer()
                    }
                }


            }
        }
    }
}

#Preview {
    EditTodoView(isPresented: .constant(false), todo: Todo(title: "", complete: false, createdAt: Date(), updatedAt: Date()))
}

Finally, we add our update method to our Appdatabase

func updateToDo(_ todo: inout Todo) throws {
    todo.updatedAt = Date()
        try dbWriter.write { db in
            do {
                try todo.save(db)
            } catch {
                print(error.localizedDescription)
            }
        }
}

If you rerun the app, you can edit todo items now.

Reading ToDos Offline,

Now that we know we can create ToDos that use the local Database, we should render the ToDo list in case we go offline.

In SceneDelegate,

extension SceneDelegate: TurboNavigationDelegate {
    func handle(proposal: VisitProposal) -> ProposalResult {
    ....

    func visitableDidFailRequest(_ visitable: Visitable, error: Error, retry: @escaping RetryBlock) {
        if (error as NSError).code == -1004 || (error as NSError).code == -1009 {
            turboNavigator.navigationController.pushViewController(ToDoViewController(), animated: true)
        }else if let errorPresenter = visitable as? ErrorPresenter {
            errorPresenter.presentError(error) {
                retry()
            }
        }
    }
}

The error code comes from here. This will push the ToDoViewController. You could also set the root view to the ToDoViewController.

self.window?.rootViewController = ToDoViewController()

However, users may have to force quit your app when they return online. There are many options, and to create the best experience when someone goes offline or online will depend on your specific circumstances.

We also have lots of edge cases.

Up Next

In this article, we built some offline CRUD functionality. I hope you can now see how powerful Turbo Native iOS can be when you hand-off critical parts to the local native app. In the following article, we’ll discuss possible data-syncing strategies. Of course, each app is different, but for this example, we will assume that all local todos get published to the web.

Shout Out

Big shout to Joe Masilotti and the folks at 37 Signals for the great work on the libraries and to Gwendal Roué.

Learn to Build Android, iOS and Rails Apps using Hotwire

© 2024 William Kennedy, Inc. All rights reserved.