TDD in iOS with Ruby Motion: Part II - Working with Data

Brian Sam-Bodden
  • April 2013
  • Ruby
  • IOS

In the first installment of this series we covered the foundations of iOS development with RubyMotion by building the beginnings of a To Do application in a Test-Driven fashion. We learned the basics of Views and Controllers by building a simple application in which we got as far as displaying data in a list backed by an array.

In this installment we will dive into the "M" behind MVC, and along the way we'll gain an understanding of the different ways to persist data in iOS with RubyMotion.

Where we've been so far...

Figure BSB-1 shows the state of the ToDo application as we left it at the end of Part I.

ToDo App

Figure 1. ToDo App

We built the first installment in a test-driven fashion, and Listing BSB-1 shows the final state of the test suite.

Listing BSB-1: ToDo App Test Suite

Application 'Todo'
  - has one window
          
ToDos View
  - should exist
  - displays the given ToDos
  - displays the correct label for a give ToDo
          
4 specifications (4 requirements), 0 failures, 0 errors

Data in Mobile Applications

In this era of social communication and interaction, isolated applications are mostly non-existent. Most mobile applications (especially those with collaboration features) keep the authoritative source of their data in an external service and keep a local user data cache that needs to be synchronized from time to time.

Data in iOS

Data persistence in iOS presents us with a paradox of choice. There are many mechanisms by which to persist data locally in an iOS application, including but not limited to:

  • NSUserDefaults: Is a key-value local storage, capable of storing both objects and primitive data types. With NSUserDefaults data is stored in what is known as the iOS "defaults system". The defaults system is not meant for storing sensitive data, heavy objects or large amounts of data.

  • NSCache: As the name implies, it is a cache that stores key-value pairs. NSCache automatically evicts objects in order to free up space in memory as needed. It can hold larger amounts of data than NSUserDefaults, but it should be only used as a cache.

  • Archives: Archives are a means to serialize an object graph into an ``architecture independent stream of bytes''. Archives are available in sequential and keyed archives backed by NSArchiver and NSKeyArchiver respectively.

  • Core Data: The 'official' persistence framework that can serializes an object graph, provide object life-cycle management, relationships, lazy loading, validation, change tracking, undo support, schema migrations and queries. Although Core Data can be backed by an embedded relational database, it is itself not a relational database. It provides four backup store types: XML (only on desktop), SQLite, binary (file) and in-memory.

RubyMotion, RubyGems and Bundler

One of the aspects that makes Ruby such a productive environment is the richness of its open source ecosystem. RubyGems provides access to thousands of prepackaged Ruby libraries for you to use in your applications, but since RubyMotion is a dialect of Ruby that is statically compiled, most regular Ruby gems won't work right out of the gate. Fortunately, iOS typically provides an appropriate counter-part to a pure Ruby gem that has been wrapped in ``RubyMotion goodness'' for your enjoyment. Gems for use in RubyMotion need to have been specifically created with RubyMotion's constraints in mind.

In traditional Ruby applications, developers make gems available to their applications by installing them on their systems using the gem command or by using Bundler to create an application-specific 'bundle' of gems. Then, in their application's Ruby code, they use the require method to have access to those libraries' classes and modules.

In RubyMotion, the require method is only allowed inside the project's Rakefile. Gems required in the Rakefile are compiled into the target executable in alphabetical order.

For our ToDo App we'll be using a few libraries that will allow us to follow 'The Ruby Way' of development. We'll use:

  • MotionModel (https://github.com/sxross/MotionModel): Simple Model and Validation Mixins for RubyMotion

  • Formotion (https://github.com/clayallsopp/formotion): A simple DSL to create iOS Forms.

  • Guard Motion (https://github.com/mordaroso/guard-motion): Guard::Motion automatically runs your RubyMotion specs when file changes are detected.

  • rb-fsevent (https://github.com/thibaudgg/rb-fsevent): Very simple & usable Mac OSX FSEvents API.

Add Gemfile

We'll start by adding a Gemfile to the root of the Todo project with the contents found in Listing BSB-2.

Listing BSB-2: Gemfile

source 'http://rubygems.org'
          
gem 'motion_model'
gem 'formotion'
          
group :development do
  gem 'guard-motion'
  gem 'rb-fsevent', :require => false
end

From the command line, run the bundle install command. You might need to install Bundler (gem install bundler). With the gems now available in the system, we'll need to require them in our Rakefile so that RubyMotion can compile them into the target executable (Listing BSB-3).

Listing BSB-3: Requires in Rakefile

# -*- coding: utf-8 -*-
$:.unshift("/Library/RubyMotion/lib")
require 'motion/project'
require 'motion_model'
require 'guard/motion'
require 'formotion'
require 'bundler'
Bundler.require

Motion::Project::App.setup do |app|
  # Use `rake config' to see complete project settings.
  app.name = 'Todo'
end

Guard Motion

We'll further automate our TDD loop by using Guard Motion, a RubyMotion specific extension to guard (https://github.com/guard/guard). Guard will watch for changes to our project and automatically run the specs. Let's start by adding a guard definition (Guardfile) by running the guard init motion command, as shown in Listing BSB-4.

Listing BSB-4: Initialize a RubyMotion Guardfile

+ guard init motion
18:00:36 - INFO - Writing new Guardfile to ../Todo/Guardfile
18:00:36 - INFO - motion guard added to Guardfile, feel free to edit it

With the Guardfile in place we can use the guard command to watch the project directory for changes. The system will run the specs once on initialization, as shown in Listing BSB-5.

Listing BSB-5: Guardfile kicking off the specs

/> guard
18:06:27 - INFO - Guard uses TerminalTitle to send notifications.
18:06:27 - INFO - Guard::Motion is running
18:06:27 - INFO - Running all specs
      Build ./build/iPhoneSimulator-6.1-Development
   Simulate ./build/iPhoneSimulator-6.1-Development/Todo_spec.app
Application 'Todo'
  - has one window
          
ToDos View
  - should exist
  - displays the given ToDos
  - displays the correct label for a give ToDo
          
4 specifications (4 requirements), 0 failures, 0 errors
          
18:06:30 - INFO - Guard is now watching at '../Todo'

Motion Model

Now that we have our TDD loop automated, let's dive into crafting the models for our ToDo application. We'll approach our model development using the MotionModel library.

MotionModel provides a Ruby PORO (Plain Old Ruby Object) with light and simple persistence and validation abilities, similar to what ActiveRecord does in the Ruby on Rails world. MotionModel fits those cases where Core Data is too heavy, but you are still intending to work with your data, its types, and its relations. MotionModel's default persistence strategy relies on NSCoder to serialize data. It then uses NSKeyedArchiver to create a NSData representation.

Developing the Todo Model

We'll start to TDD our models at the lowest level with a simple spec to test that the Todo model exists, as shown in Listing BSB-6.

Listing BSB-6: Todo model existence test

describe "Todo Model" do
  it "exists" do
    Object.const_defined?('Todo')
  end
end

The moment we save the file, we should see the build kick off, which should fail as shown in Listing BSB-7.

Listing BSB-7: At the beginning of the Red-Green-Refactor Loop

18:24:28 - INFO - Running: spec/todo_spec.rb
     Build ./build/iPhoneSimulator-6.1-Development
   Compile spec/todo_spec.rb
      Link ./build/iPhoneSimulator-6.1-Development/Todo_spec.app/Todo
    Create ./build/iPhoneSimulator-6.1-Development/Todo_spec.dSYM
  Simulate ./build/iPhoneSimulator-6.1-Development/Todo_spec.app
Todo Model
  - exists [FAILED]
          
Bacon::Error: false.true?() failed
  spec.rb:649:in `satisfy:': Todo Model - exists
  spec.rb:663:in `method_missing:'
  spec.rb:279:in `block in run_spec_block'
  spec.rb:403:in `execute_block'
  spec.rb:279:in `run_spec_block'
  spec.rb:294:in `run'
          
1 specifications (1 requirements), 1 failures, 0 errors
rake aborted!

Let's start by creating an empty Todo model under app/models in the file todo.rb, as shown in Listing BSB-8.

Listing BSB-8: Todo model skeleton

class Todo
          
end

We can now enhance our tests to define what we expect from our Todo model. We'll start by specifying that the Todo model should know its name, description, due_date, and whether it is done or not, as shown Listing BSB-9.

Listing BSB-9: Todo model spec

before do
  @todo = Todo.new 
end
          
it "exists" do
  Object.const_defined?('Todo').should.be.true 
end
          
it "has a name, description, a due date and whether is done or not" do
  @todo.should.respond_to :name
  @todo.should.respond_to :description
  @todo.should.respond_to :due_date
  @todo.should.respond_to :done
end

To pass the specification, we'll mix in the main MotionModel functionality contained in MotionModel::Model, use the default persistence adapter provided by the module MotionModel::ArrayModelAdapter, and define our four columns as shown Listing BSB-10.

Listing BSB-10: Basic Todo model

class Todo
  include MotionModel::Model
  include MotionModel::ArrayModelAdapter
          
  columns :name        => :string,
          :details     => :string,
          :due_date    => {:type => :date, 
                           :formotion => {:picker_type => :date_time}},
          :done        => {:type => :boolean, :default => false, 
                           :formotion => {:type => :switch}}
end

Now, let's specify some validity constraints. We'll start with the constraint that a Todo should have a name to be valid, as shown Listing BSB-11.

Listing BSB-11: Validity Specification

it "is invalid without a name" do
  @todo.name = nil
  @todo.should.not.be.valid
end

To validate a model, we need to mix in the MotionModel::Validatable module and use the validates method, as shown Listing BSB-12.

Listing BSB-12: Adding Validation to the Todo model.

class Todo
  include MotionModel::Model
  include MotionModel::ArrayModelAdapter
  include MotionModel::Validatable
          
  columns :name        => :string,
          :details     => :string,
          :due_date    => {:type => :date, 
                           :formotion => {:picker_type => :date_time}},
          :done        => {:type => :boolean, :default => false, 
                           :formotion => {:type => :switch}}
          
  validates :name, :presence => true
end

Let's round out the Todo model development with a couple of specifications: ``A Todo is not done by default'' and ``A Todo knows if its overdue.'' The final specification is shown in Listing BSB-13.

Listing BSB-13: Full Todo Model Spec

  describe "Todo Model" do
  before do
    @now = NSDate.new
    @todo = Todo.new :name => "Buy Milk",
                     :description => "We need some Milk",
                     :due_date => @now
  end
          
  it "exists" do
    Object.const_defined?('Todo').should.be.true 
  end
          
  it "has a name, description, a due date and whether is done or not" do
    @todo.should.respond_to :name
    @todo.should.respond_to :description
    @todo.should.respond_to :due_date
    @todo.should.respond_to :done
  end
          
  it "is invalid without a name" do
    @todo.name = nil
    @todo.should.not.be.valid
  end
          
  it "is not done by default" do
    @todo.done.should.not.be.true
  end
          
  it "knows if its overdue" do
    @todo.should.be.overdue
  end
end 

Our final version of the Todo model is shown in Listing BSB-14.

Listing BSB-14: The Todo Model

class Todo
  include MotionModel::Model
  include MotionModel::ArrayModelAdapter
  include MotionModel::Validatable
          
  columns :name        => :string,
          :details     => :string,
          :due_date    => {:type => :date, 
                           :formotion => {:picker_type => :date_time}},
          :done        => {:type => :boolean, :default => false, 
                           :formotion => {:type => :switch}}
          
  validates :name, :presence => true
          
  def overdue?
    NSDate.new > self.due_date && !done
  end
end

Integrating the Todo Model

The next step is to refactor the TodosController controller to make use of the new model. The controller, as it stands, is shown in Listing BSB-15.

Listing BSB-15: Array-backed TodosController

class TodosController < UIViewController
  attr_writer :data
          
  def viewDidLoad
    super
    self.title = "My ToDos"
    @table = UITableView.alloc.initWithFrame(self.view.bounds)
    @table.autoresizingMask = UIViewAutoresizingFlexibleHeight
    self.view.addSubview(@table)
          
    @table.dataSource = self
          
    @data = %w(Milk Orange\ Juice Apples Bananas Brocolli Carrots Beef 
               Chicken Enchiladas Hot\ Dogs Butter Bread Pasta 
               Rice).map do |thing| 
                 "Buy #{thing}" 
               end
  end
          
  def tableView(tableView, numberOfRowsInSection: section)
    @data.size
  end
          
  def tableView(tableView, cellForRowAtIndexPath: indexPath)
    cell = UITableViewCell.alloc.initWithStyle(UITableViewCellStyleDefault, 
                                               reuseIdentifier:nil)
    cell.textLabel.text = @data[indexPath.row]
    cell
  end
end

Our refactoring will involve replacing the @data string array with a collection of Todo model instances. Notice the call to Todo.all inside the refactored viewDidLoad method, which retrieves all existing Todos from storage. Also, in the tableView:numberOfRowsInSection method, we return the size of the @todos array and in tableView:cellForRowAtIndexPath we access the name property of the model to be displayed on the table. The refactored controller is shown in Listing BSB-16.

Listing BSB-16: Model-backed TodosController

class TodosController < UIViewController
          
  def viewDidLoad
    super
    self.title = "My ToDos"
    @table = UITableView.alloc.initWithFrame(self.view.bounds)
    @table.autoresizingMask = UIViewAutoresizingFlexibleHeight
    self.view.addSubview(@table)
          
    @table.dataSource = self
    @table.delegate = self
          
    @todos = Todo.all
  end
          
  def tableView(tableView, numberOfRowsInSection: section)
    @todos.size
  end
          
  def tableView(tableView, cellForRowAtIndexPath: indexPath)
    cell = UITableViewCell.alloc.initWithStyle(UITableViewCellStyleDefault, 
                                               reuseIdentifier:nil)
    cell.textLabel.text = @todos[indexPath.row].name
    cell
  end
end

If we run the tests, we see that the existing tests for the controller are now broken, as shown in Listing BSB-17.

Listing BSB-17: Broken TodosController Specs

ToDos View
  - should exist
  - displays the given ToDos [FAILED]
  - displays the correct label for a give ToDo [ERROR: NoMethodError]
          
Bacon::Error: not [].empty?() failed
  spec.rb:649:in `satisfy:': ToDos View - displays the given ToDos
  spec.rb:663:in `method_missing:'
  spec.rb:279:in `block in run_spec_block'
  spec.rb:403:in `execute_block'
  spec.rb:279:in `run_spec_block'
  spec.rb:294:in `run'
          
NoMethodError: undefined method `textLabel' for nil:NilClass
  spec.rb:279:in `block in run_spec_block': ToDos View - displays the correct label for a give ToDo
  spec.rb:403:in `execute_block'
  spec.rb:279:in `run_spec_block'
  spec.rb:294:in `run'
          
9 specifications (11 requirements), 1 failures, 1 errors  

Let's refactor the specs to also make use of the Todo model (Listing BSB-18), and let's also rename the file todos_controller_spec.rb to reflect its purpose better.

Listing BSB-18: Refactored TodosController Specs

describe "Todos Controller" do
  tests TodosController
          
  before do
    Todo.delete_all
    @todo = Todo.create(:name => 'Buy Milk',
                        :description => 'Get some 1% to rid yourself of the muffin top',
                        :due_date => '2013-03-31')
    @table = controller.instance_variable_get("@table")
  end
          
  it 'should exist' do
    @table.should.not == nil
  end
          
  it 'displays the given ToDos' do
    @table.visibleCells.should.not.be.empty
  end
          
  it 'displays the correct label for a give ToDo' do
    first_cell = @table.visibleCells.first
    first_cell.textLabel.text.should == 'Buy Milk'
  end
end 

Now, with all of our tests passing, let's try launching the application to see where we stand. You'll quickly notice that our table of Todos is empty. Let's add a bit of code to our AppDelegate to seed the database on application startup (Listing BSB-19).

Listing BSB-19: Database "Seeding"

class AppDelegate
  def application(application, didFinishLaunchingWithOptions:launchOptions)
    seed unless RUBYMOTION_ENV == 'test'
          
    @window = UIWindow.alloc
      .initWithFrame(UIScreen.mainScreen.bounds)
          
    @todos_controller = TodosController.alloc
      .initWithNibName(nil, bundle:nil)
          
    @window.rootViewController = UINavigationController.alloc
      .initWithRootViewController(@todos_controller)
          
    @window.makeKeyAndVisible
          
    true
  end
          
  def seed
    now = NSDate.new
    things = %w(Milk Orange\ Juice Apples Bananas Brocolli Carrots Beef 
                Chicken Enchiladas Hot\ Dogs Butter Bread Pasta Rice)
    things.each do |thing|
      Todo.create :name => "Buy #{thing}",
                  :description => "We need some #{thing}",
                  :due_date => now
    end
  end
end

Todo Detail View

Now we can move on to developing the detail view for our Todos. The idea is that users will be able to select an entry from the Todos list and see a form displaying the Todo's details. That functionality will be the responsibility of the (yet to be created) TodoController (not to be confused with the existing TodosController). Let's start with the spec shown in Listing BSB-20.

Listing BSB-20: Todo Detail View Spec

describe "Todo Controller" do
  tests TodoController
          
  def controller
    unless @controller
      @now = NSDate.new
      @todo = Todo.create :name => "Buy Milk",
                          :details => "We need some Milk",
                          :due_date => @now
      @controller = TodoController.new(@todo)
    end
    @controller
  end
          
  it "displays a Todo's details" do
    @name_row.value.should.equal "Buy Milk"
    @details_row.value.should.equal "We need some Milk"
    @due_date_row.object.date_value.hour.should.equal @now.hour
    @due_date_row.object.date_value.min.should.equal @now.min
    @done_row.value.should.equal false
  end
end 

The spec in Listing BSB-20 reveals a lot of implementation details that with a more mature testing framework you wouldn't need to know in advance. First, we are overriding the controller method of the spec to return a properly initialized TodoController instance (thanks to Clay Allsopp for the tip). Then we are assuming that we have handles to each one of the rows (which we'll create with Formotion) in order to check the values displayed in said rows.

Let's start by enhancing the Todo model with the Formotion capalities by mixing in MotionModel::Formotion as shown in Listing BSB-21.

Listing BSB-21: Formotion enhanced Todo Model

  class Todo
  include MotionModel::Model
  include MotionModel::ArrayModelAdapter
  include MotionModel::Validatable
  include MotionModel::Formotion
          
  columns :name        => :string,
          :details     => :string,
          :due_date    => {:type => :date, 
                           :formotion => {:picker_type => :date_time}},
          :done        => {:type => :boolean, :default => false, 
                           :formotion => {:type => :switch}}
          
  validates :name, :presence => true
          
  def overdue?
    NSDate.new > self.due_date && !done
  end
end

Listing BSB-22 shows the result of running the specs. As you can see, we are ready to flesh out the TodoController.

Listing BSB-22: Failed specs for TodoController

  Application 'Todo'
  - has one window
          
Todo Controller
  - displays a Todo's details [ERROR: NoMethodError]
          
Todo Model
  - exists
  - has a name, description, a due date and whether is done or not
  - is invalid without a name
  - is not done by default
  - knows if its overdue
          
Todos Controller
  - should exist
  - displays the given ToDos
  - displays the correct label for a give ToDo
          
NoMethodError: undefined method `value' for nil:NilClass
  spec.rb:279:in `block in run_spec_block': Todo Controller - displays a Todo's details
  spec.rb:403:in `execute_block'
  spec.rb:279:in `run_spec_block'
  spec.rb:294:in `run'
          
10 specifications (12 requirements), 0 failures, 1 errors

Our first attempt at creating a skeleton for the TodoController is shown in Listing BSB-23. Formotion's central concept is that of a ``form,'' which is a collection of sections with rows. In the constructor of the TodoController, we are passing a Todo model instance to initialize the form. Luckily for us, MotionModel provides the #to_formotion method on a model that can be used to initialize a Formotion form.

Listing BSB-23: TodoController first cut

class TodoController < Formotion::FormController
  attr_accessor :todo
  attr_accessor :form
          
  def initialize(todo)
    self.form = Formotion::Form.new(todo.to_formotion('Edit your ToDo'))
    self.initWithForm(self.form)
    self.todo = todo
  end
end

Integrating the TodoController and TodosController

Integration of our two existing controllers will entail responding to a tap on the Todos table and displaying the TodoController view. To do so, we will modify the TodosController to handle the tableView#didSelectRowAtIndexPath event, and in it we will create a new instance of our TodoController using the selected Todo model and ``push'' it into the view using the #pushViewController method of the controller's navigationController as shown in Listing BSB-24.

Listing BSB-24: Handling Row Selection

def tableView(tableView, didSelectRowAtIndexPath:indexPath)
  tableView.deselectRowAtIndexPath(indexPath, animated:true)
  todo = @todos[indexPath.row]
  todo_controller = TodoController.new(todo)
  self.navigationController.pushViewController(todo_controller, 
                                               animated: true)
end

To pass our tests, we need to get ahold of the ``rows'' in our form. Let's modify todo_controller_spec.rb and add a before block to grab ahold of the @form instance variable and extract the four visual rows of our form, as shown in Listing BSB-25.

Listing BSB-25: Passing the "displays a Todo's details" spec

before do
  @form = @controller.instance_variable_get("@form")
  @name_row = @form.sections[0].rows[0]
  @details_row = @form.sections[0].rows[1]
  @due_date_row = @form.sections[0].rows[2]
  @done_row = @form.sections[0].rows[3]
end

If we launch the application using the rake command and select a given todo, we should see the TodoController in action, as shown in Figure BSB-2.

Showing a Todo's Details

Figure 2. Showing a Todo's Details

Editing and Saving a Todo

So far, we have a storage-backed list of Todos that we can display on a table, and we can navigate to view individual Todo details. Now, let's add the ability to save any modifications made to a Todo. We'll start with the simple Spec shown in Listing BSB-26.

Listing BSB-26: It "saves changes made to a todo" Spec

  it "saves changes made to a todo" do
  @name_row.object.row.text_field.text = "Buy 1% Milk"
  controller.save
          
  saved_todo = Todo.find(@todo.id)
          
  saved_todo.name.should.equal "Buy 1% Milk"
end 

If you run the Spec, you'll notice that it fails when trying to call the non-existent #save method of the controller. MotionModel provides the reciprocal of the #to_formotion method in the #from_formotion! method, which we will use in the implementation of the #save method. In the #save method, we will grab the data from the form by calling the #render method and then update the value in the instance variable @todo (using #from_formotion!). Finally, we will save the modified model and, if we are not in test mode, navigate back to the Todos list (Listing BSB-27).

Listing BSB-27: Implementing the #save method

def save
  data = @form.render
  @todo.from_formotion!(data)
  @todo.save
          
  app = UIApplication.sharedApplication
  delegate = app.delegate
  controller = delegate.instance_variable_get("@todos_controller")
  view = controller.instance_variable_get("@table")
  view.setNeedsDisplay
          
  unless RUBYMOTION_ENV == 'test'
  self.navigationController.popToRootViewControllerAnimated(true) 
end

Exposing the save functionality to the users

We have tests showing that we can edit and save a Todo, but if we launch the application, you'll notice that there is no way for the user to ``save'' an edited Todo. Let's add a "Save" button to the right of our navigation bar, as shown in Listing BSB-28.

Listing BSB-28: Adding a "save" button

def viewDidLoad
  super
  saveButton = UIBarButtonItem.alloc.initWithTitle("Save", 
               style: UIBarButtonItemStyleBordered, 
               target:self, action:'save')
  self.navigationItem.rightBarButtonItem = saveButton
end

If we launch and test the application, we can see that the changed values are persisted when we tap the ``save'' button (Figure BSB-3), but they are not reflected on the Todos list.

Save Button

Figure 3. Save Button

Model Motion supports notifications that are issued on object save, update, and delete. We can use the NSNotificationCenter default instance to register an observer, which will invoke the #todoChanged method, as shown in Listing BSB-29.

Listing BSB-29: Wiring a change 'observer'

NSNotificationCenter.defaultCenter.addObserver(
  self, 
  selector: 'todoChanged:',
  name: 'MotionModelDataDidChangeNotification',
  object: nil
) unless RUBYMOTION_ENV == 'test'

In the implementation of the #todoChanged method (Listing BSB-30), we get a notification object that we can interrogate for the 'action' triggered in the model object. In our particular case, we care about the 'update' action, which when triggered we will trigger a row reload in order to update the label shown on the table for a Todo.

Listing BSB-30: Handling model changes

  def todoChanged(notification)
  case notification.userInfo[:action]
    when 'add'
    when 'update'
      todo = notification.object
      row = todo.id - 1
      path = NSIndexPath.indexPathForRow(row, inSection:0)
      @table.reloadRowsAtIndexPaths([path], 
        withRowAnimation:UITableViewRowAnimationAutomatic)
    when 'delete'  
  end
end 

Conclusion

In this installment we learned a bit about data persistence in iOS and how RubyMotion libraries can bring the simplicity of Rails-like DSLs into the world of iOS. The next steps for our RubyMotion ToDo App are to complete the Todo CRUD functionality and then to add the features that will make it a production-ready application.

Share