ElixirDesktopで１からスタンドアローンなToDoスマホアプリを作ってみる

# はじめに
本記事はマルチプラットフォームアプリ開発を行うライブラリElixirDesktopで、
DBにSqlite3を使用してスタンドアローンで動作するスマホアプリを作る手順を紹介します

# 環境
M1 Mac 13.3
iOS 16.4
Phoenix 1.7.7

# Elixirのインストール

https://github.com/elixir-desktop/ios-example-app#how-to-build--run

基本的に上記のREADMEに沿って行います
今回は2023/07/12時最新版で試してみます
追記:erlang 26系はだめした
```
brew install carthage git openssl@1.1 npm
export DED_LDFLAGS_CONFTEST="-bundle"
export KERL_CONFIGURE_OPTIONS="--without-javac --with-ssl=$(brew --prefix openssl@1.1)"
asdf install erlang 25.0.4
asdf install elixir 1.15.2-otp-25
asdf global erlang 25.0.4
asdf global elixir 1.15.2-otp-25
```
インストール後はPhoenixをインストールします

```
mix archive.install hex phx_new
```

# プロジェクトの作成
本棚アプリEbookWormerを作りますが、今回は単純なCRUDのみ行います
DBはsqlite3を使うので `--database sqlite3`のオプションをつけます
また、PhoenixのプロジェクトとXcodeのプロジェクトの２つが必要なので、両方のプロジェクトをいれるディレクトリを作成します

```
mkdir todo_root
cd todo_root
mix phx.new todo_app --database sqlite3
cd todo_app
```

# ライブラリの追加
以下の3つのライブラリを追加します

```todo_app/mix.exs
  defp deps do
    [
      ...
      {:exqlite, github: "elixir-desktop/exqlite", override: true},
      {:desktop, "~> 1.5"},
      {:wx, "~> 1.1", hex: :bridge, targets: [:android, :ios]}
    ]
  end
```

追加したら`mix deps.get`を実行しましょう

# config修正

configファイルをElixirDesktopに合わせて変更します
※portの設定はバックグラウンドモードからの復帰時に必要
```todo_app/config/config.exs
config :todo_app, TodoAppWeb.Endpoint,
  # because of the iOS rebind - this is now a fixed port, but randomly selected
  http: [ip: {127, 0, 0, 1}, port: 10_000 + :rand.uniform(45_000)],
  render_errors: [
    formats: [html: TodoAppWeb.ErrorHTML, json: TodoAppWeb.ErrorJSON],
    layout: false
  ],
  pubsub_server: TodoApp.PubSub,
  live_view: [signing_salt: "XYeoBJB0"],
  secret_key_base: :crypto.strong_rand_bytes(32),
  server: true
```

`todo_app/config/runtime.exs`があるとエラーになるので`runtime_disable.exs`にリネームします

# Endpoint設定
EndpointのモジュールをPhoenixからDesktopに変更します
ブラウザではないのでセッション管理をcookieから etsに変更します

```todo_app/lib/todo_app_web/endpoint.ex
defmodule TodoAppWeb.Endpoint do
  use Desktop.Endpoint, otp_app: :todo_app

  @session_options [
    store: :ets,
    key: "_todo_app_key",
    table: :session
  ]

```

# アプリケーション起動時の設定
起動時のsuperviserの設定を行います

```todo_app/lib/todo_app.ex
defmodule TodoApp do
  use Application

  def config_dir() do
    Path.join([Desktop.OS.home(), ".config", "todo_app"])
  end

  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    # configフォルダを掘る
    File.mkdir_p!(config_dir())

    # DBの場所を指定
    Application.put_env(:todo_app, TodoApp.Repo,
      database: Path.join(config_dir(), "/database.sq3")
    )

    # session用のETSを起動
    :session = :ets.new(:session, [:named_table, :public, read_concurrency: true])

    children = [
      TodoApp.Repo,
      {Phoenix.PubSub, name: TodoApp.PubSub},
      TodoAppWeb.Endpoint
    ]

    opts = [strategy: :one_for_one, name: TodoApp.Supervisor]
    # メインのsuperviser起動
    {:ok, sup} = Supervisor.start_link(children, opts)

    # DBのマイグレーション実行
    TodoApp.Repo.initialize()

    # phoenixサーバーが起動中のポート番号を取得
    port = :ranch.get_port(TodoAppWeb.Endpoint.HTTP)
    # メインのsuperviserの配下にElixirDesktopのsuperviserを追加
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: TodoAppWindow,
          title: "TodoApp",
          size: {400, 800},
          url: "http://localhost:#{port}"
        ]
      })
  end

  def config_change(changed, _new, removed) do
    TodoAppWeb.Endpoint.config_change(changed, removed)
    :ok
  end
end
```

マイグレーション関連はまだなにもないですが関数だけ追加しておきます

```todo_app/lib/todo_app/repo.ex
defmodule TodoApp.Repo do
  ...
  def initialize() do
  end
end
```

アプリケーション設定ファイルを差し替えます

```diff_elixir:todo_app/mix.exs
  def application do
    [
-      mod: {TodoApp.Application, []},
+      mod: {TodoApp, []},
      extra_applications: [:logger, :runtime_tools]
    ]
  end

```

# iOSでの起動
設定が完了しましたので一度ここで
phoenixのフォルダからプロジェクトのフォルダに移動し、iOSのサンプルアプリをcloneします

```
cd ..
git clone git@github.com:elixir-desktop/ios-example-app.git
```

cloneしたらswfiftのライブラリを追加します
```
cd ios-example-app && carthage update --use-xcframeworks
```

次にphoenixのビルドファイルを編集します

```diff_shell:ios-example-app/run_mix
#!/bin/bash
set -e

# Setting up the PATH environment
[ -s /opt/homebrew/bin/brew ] && eval $(/opt/homebrew/bin/brew shellenv)
[ -s /usr/local/bin/brew ] && eval $(/usr/local/bin/brew shellenv)

# This loads nvm
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"  

# This loads asdf
if [ -s "$HOMEBREW_PREFIX/opt/asdf/libexec/asdf.sh" ]; then 
  \. "$HOMEBREW_PREFIX/opt/asdf/libexec/asdf.sh" 
elif [ -s "$HOME/.asdf/asdf.sh" ]; then
  \. "$HOME/.asdf/asdf.sh"
fi

BASE=`pwd`
export MIX_ENV=prod
export MIX_TARGET=ios

mix local.hex --force --if-missing
mix local.rebar --force --if-missing

- if [ ! -d "elixir-app" ]; then
-  git clone https://github.com/elixir-desktop/desktop-- example-app.git elixir-app
- fi

- # using the right runtime versions
- if [ ! -f "elixir/.tool-versions" ]; then
-   cp .tool-versions elixir-app/
- fi

- cd elixir-app
+ cd ../todo_app

if [ ! -d "deps/desktop" ]; then
  mix deps.get
fi

- if [ ! -d "assets/node_modules" ]; then
-   cd assets && npm i && cd ..
- fi
+ # 今回はjsライブラリを使わないのでコメントアウト
+ # if [ ! -d "assets/node_modules" ]; then
+ #  cd assets && npm i && cd ..
+ # fi


if [ -f "$BASE/todoapp/app.zip" ]; then
  rm "$BASE/todoapp/app.zip"
fi

mix assets.deploy && \
  mix release --overwrite && \
  cd _build/ios_prod/rel/todo_app && \
  zip -9r "$BASE/todoapp/app.zip" lib/ releases/ --exclude "*.so"
```
書き換えが終わったらxcodeを開きます

```
open todoapp.xcodeproj
```

プロジェクト設定からライブラリのliberlangを`Embed & Sign`に変更します

![スクリーンショット 2023-05-10 16.08.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/e1ae3733-b848-8222-099b-f0af1bfd1406.png)

完了したらxcodeのstartボタンを押します

ビルドが失敗した場合は `./run_mix` でビルドスクリプト単体で実行してエラーメッセージを見ましょう

ビルドが成功すれば、初回は少し時間がかかりますが、次のような画面がでてきます

![スクリーンショット 2023-07-12 18.29.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/15116715-7aa5-aac0-a6e5-bb22be3755b0.png)


# CRUD作成

いつものコマンドでCRUD画面を作ります

```
mix phx.gen.live Tasks Task tasks name:string status:boolean
```

特に認証はしないのでそのまま貼り付けます

```todo_app/lib/todo_app_web/router.ex
  scope "/", TodoAppWeb do
    pipe_through :browser

    get "/", PageController, :home
    # 以下追加
    live "/tasks", TaskLive.Index, :index
    live "/tasks/new", TaskLive.Index, :new
    live "/tasks/:id/edit", TaskLive.Index, :edit

    live "/tasks/:id", TaskLive.Show, :show
    live "/tasks/:id/show/edit", TaskLive.Show, :edit
  end

```

# Migration周り

ここからが本記事の本番です
ElixirDesktopでは端末ごとに`mix ecto.migrate`なんてことはできないので起動時にmigrationをするようにします

`todo_app/priv/repo/migrations`フォルダを
`todo_app/lib/todo_app/`にコピーします
コピー後は `xxxx_create_tasks.exs`を`xxxx_create_tasks.ex`にリネームします

名前衝突を防ぐためにモジュール名をリネームします
```diff_elixir:todo_app/lib/todo_app/migrations/xxx_create_tasks.ex
- defmodule TodoApp.Repo.Migrations.CreateTasks do
+ defmodule TodoApp.Migrations.CreateTasks do
  ...
end
```


repo.exのinitialize関数に実行するマイグレーションのモジュール名を追加します
`Ecto.Migrator.up`はマイグレーションを実行する関数で
第1引数はRepoモジュール
第2引数はマイグレーション番号、今回はファイル名のタイムスタンプ
第3引数は先ほどコピーしたマイグレーションファイルのモジュール名
をそれぞれ指定します
```todo_app/lib/todo_app/repo.ex
defmodule TodoApp.Repo do
  ...
  def initialize() do
    Ecto.Migrator.up(TodoApp.Repo, 20_230_712_093_326, TodoApp.Migrations.CreateTasks)
  end
end
```

# 起動時に開くページを指定

現在だと起動時にトップページが開かれるので棚一覧が開くようにします

```ebookworm/lib/todo_app.ex
defmodule TodoApp do
  ...

  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    ...
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: TodoAppWindow,
          title: "TodoApp",
          size: {400, 800},
          # tasksを末尾に追加
          url: "http://localhost:#{port}/tasks"
        ]
      })
  end
end
```

無事CRUD画面が表示されました

![4e070cd6037ae75035196a684e7cf15d.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/f3e07f6a-7fc2-9687-8402-5a0c1ea0c8ff.gif)

# 微調整
一応の完成ではありますが、細かいところがスマホアプリのWebViewと合わないので微調整を行います

## core_component カスタム
.table コンポーネントが横に広くてスクロールが必要になるのが嫌なので、変更する場合は
core_components.exに全てあるので、対象のコンポーネントを検索してclassを値を変更することで調整ができます

```diff_elixir:todo_app/lib/todo_app_web/components/core_components.ex:L470
  def table(assigns) do
    assigns =
      with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
        assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
      end

    ~H"""
    <div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
-      <table class="mt-11 w-[40rem] sm:w-full">
+      <table class="mt-11 w-full"> 
    # 以下省略
    """
  end
```

# confirm modalを追加
WebViewだと削除時に出てくる confirmやalertのダイアログは出ないので独自に実装が必要です
削除リンクをモーダルを開くナビゲーションにして
index.html.heexの末尾にconfirm modalを表示するようにします

```diff_elixir:todo_app/lib/todo_app_web/live/task_live/index.html.heex

...
<.table
  id="tasks"
  rows={@streams.tasks}
  row_click={fn {_id, task} -> JS.navigate(~p"/tasks/#{task}") end}
>
  <:col :let={{_id, task}} label="Name"><%= task.name %></:col>
  <:col :let={{_id, task}} label="Status"><%= task.status %></:col>
  <:action :let={{_id, task}}>
    <div class="sr-only">
      <.link navigate={~p"/tasks/#{task}"}>Show</.link>
    </div>
    <.link patch={~p"/tasks/#{task}/edit"}>Edit</.link>
  </:action>
-  <:action :let={{id, task}}>
+  <:action :let={{_id, task}}>
    <.link
-      phx-click={JS.push("delete", value: %{id: task.id}) |> hide("##{id}")}
-      data-confirm="Are you sure?"
+      patch={~p"/tasks/#{task}/delete"}
    >
      Delete
    </.link>
  </:action>
</.table>

...
<.modal
  :if={@live_action in [:delete]}
  id="task-delete-modal"
  show
  on_cancel={JS.navigate(~p"/tasks")}
>
  <p class="m-8">Are you sure?</p>
  <.button phx-click={JS.push("delete", value: %{id: @task.id})}>Delete</.button>
</.modal>
```

```todo_app/lib/todo_app_web/live/task_live/index.ex
defmodule TodoAppWeb.TaskLive.Index do
  use TodoAppWeb, :live_view

  alias TodoApp.Tasks
  alias TodoApp.Tasks.Task

  ...
  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Tasks")
    |> assign(:task, nil)
  end
  # 追加
  defp apply_action(socket, :delete, %{"id" => id}) do
    socket
    |> assign(:page_title, "Delete Task")
    |> assign(:task, Tasks.get_task!(id))
  end

  @impl true
  def handle_info({TodoAppWeb.TaskLive.FormComponent, {:saved, task}}, socket) do
    {:noreply, stream_insert(socket, :tasks, task)}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    task = Tasks.get_task!(id)
    {:ok, _} = Tasks.delete_task(task)

    {
      :noreply,
      socket
      # flashを追加
      |> put_flash(:info, "Task deleted successfully")
      # モーダルを閉じるように変更
      |> push_navigate(to: ~p"/tasks")
      |> stream_delete(:tasks, task)
    }
  end
end
```

routeにdelete actionを追加します

```todo_app/lib/todo_app_web/router.ex
  scope "/", TodoAppWeb do
    pipe_through(:browser)

    get("/", PageController, :home)
    live "/tasks", TaskLive.Index, :index
    live "/tasks/new", TaskLive.Index, :new
    live "/tasks/:id/edit", TaskLive.Index, :edit
    live("/tasks/:id/delete", TaskLive.Index, :delete) # 追加

    live "/tasks/:id", TaskLive.Show, :show
    live "/tasks/:id/show/edit", TaskLive.Show, :edit
  end
```

# 開発時の小技
iOSシミュレーターで起動するとprod環境のためコードの変更は再度ビルドする必要があるため非常に効率が悪いです
なので`iex -S mix`でWxWidgetで起動して、そのPhoenixサーバーにアクセスするようにします

```todo_app/lib/todo_app.ex
defmodule TodoApp do
  ...
  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: TodoAppWindow,
          title: "TodoApp",
          size: {400, 800},
          # portから4000に変更
          url: "http://localhost:#{4000}/tasks"
        ]
      })
  end
end
```

Androidの場合はURLは以下にすれば表示されます
`url: "http://10.0.2.2:#{4000}/tasks"`


このときDBはiOSシミュレーターのsqliteではなく、todo_app直下にあるsqliteを参照するので注意が必要です


# DEMO

![ed84b7a7a81a7c511357bf43f2eb73ff.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/2a3d5bf2-3982-ac72-b7a1-56d9f9993c1e.gif)

追記：iPhoneSE の実機での動作も確認できました

# 最後に
いかがでしたでしょうか
ElixirDesktopと最新版のPhoenix+Sqlite3で外部と通信を行わないスタンドアローンなアプリを簡単に作ることができました。API等を使用して外部と通信の行わない場合はオフラインで問題なく動きます

クラウドサービスを使用することなく簡単に作ることができるのでぜひなにか作ってみて実際に審査に出してみてください
本記事は以上になりますありがとうございました