SaladUIとElixirDesktopでモバイルアプリを作成する

# はじめに
本記事はElixirでモバイルアプリを作成できるようにするライブラリElixirDesktopと
tailwindのUIコンポーネントであるShadcnUIをベースにしたLiveViewコンポーネント群のSaladUIを使用して実際にモバイルアプリを作成する方法を紹介する記事になります

今回はデスクトップモードで開発を行います

https://github.com/elixir-desktop/desktop

https://salad-storybook.fly.dev/welcome

https://ui.shadcn.com/

# プロジェクト作成
まずPhoenixPJを作成します 
今回はアプリ単体で動くようにDBにSqlite3を指定します

```
mix phx.new pocket_book --database sqlite3
```
desktop_setupとsalad_uiを追加

```elixir:mix.exs
defp deps do
  [
    ...
    {:bandit, "~> 1.2"}, 
    {:desktop_setup, github: "thehaigo/desktop_setup", only: :dev}, # 追加
    {:salad_ui, "~> 0.4.2"} # 追加
  ]
end
```

追加したら以下のコマンドでデスクトップアプリ化を行います

```
$ mix deps.get
$ mix desktop.install
```

完了したら以下のコマンドでアプリを起動します、画像のように表示されたら成功です

```
$ iex -S mix
```

![02_04_start_desktop_app.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/f4735923-a2d1-c9dd-56b4-0b5675b4a628.png)


# SaladUIセットアップ

[公式サイト](https://github.com/bluzky/salad_ui/tree/main)に沿って SaladUIのセットアップを行います

![スクリーンショット 2024-09-04 12.57.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/c32c1b2b-69f6-4223-3c85-cbcf36e5f02b.png)

[本家のページ](https://ui.shadcn.com/themes)のテーマを選択し、customを推して色を選択します
そのあとcopy codeをクリックするとクリップボードに色データが入っていますのでapp.cssに追加します

今回は紫を選択しました

```assets/css/app.css
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
/* This file is for your main application CSS */

# ここから追加
@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 224 71.4% 4.1%;
    --card: 0 0% 100%;
    --card-foreground: 224 71.4% 4.1%;
    --popover: 0 0% 100%;
    --popover-foreground: 224 71.4% 4.1%;
    --primary: 262.1 83.3% 57.8%;
    --primary-foreground: 210 20% 98%;
    --secondary: 220 14.3% 95.9%;
    --secondary-foreground: 220.9 39.3% 11%;
    --muted: 220 14.3% 95.9%;
    --muted-foreground: 220 8.9% 46.1%;
    --accent: 220 14.3% 95.9%;
    --accent-foreground: 220.9 39.3% 11%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 210 20% 98%;
    --border: 220 13% 91%;
    --input: 220 13% 91%;
    --ring: 262.1 83.3% 57.8%;
    --radius: 0.5rem;
    --chart-1: 12 76% 61%;
    --chart-2: 173 58% 39%;
    --chart-3: 197 37% 24%;
    --chart-4: 43 74% 66%;
    --chart-5: 27 87% 67%;
  }

  .dark {
    --background: 224 71.4% 4.1%;
    --foreground: 210 20% 98%;
    --card: 224 71.4% 4.1%;
    --card-foreground: 210 20% 98%;
    --popover: 224 71.4% 4.1%;
    --popover-foreground: 210 20% 98%;
    --primary: 263.4 70% 50.4%;
    --primary-foreground: 210 20% 98%;
    --secondary: 215 27.9% 16.9%;
    --secondary-foreground: 210 20% 98%;
    --muted: 215 27.9% 16.9%;
    --muted-foreground: 217.9 10.6% 64.9%;
    --accent: 215 27.9% 16.9%;
    --accent-foreground: 210 20% 98%;
    --destructive: 0 62.8% 30.6%;
    --destructive-foreground: 210 20% 98%;
    --border: 215 27.9% 16.9%;
    --input: 215 27.9% 16.9%;
    --ring: 263.4 70% 50.4%;
    --chart-1: 220 70% 50%;
    --chart-2: 160 60% 45%;
    --chart-3: 30 80% 55%;
    --chart-4: 280 65% 60%;
    --chart-5: 340 75% 55%;
  }
}
# ここまで追加
```

次に色情報のjsonを作成します

```assets/tailwind.colors.json
{
  "accent": {
    "DEFAULT": "hsl(var(--accent))",
    "foreground": "hsl(var(--accent-foreground))"
  },
  "background": "hsl(var(--background))",
  "border": "hsl(var(--border))",
  "card": {
    "DEFAULT": "hsl(var(--card))",
    "foreground": "hsl(var(--card-foreground))"
  },
  "destructive": {
    "DEFAULT": "hsl(var(--destructive))",
    "foreground": "hsl(var(--destructive-foreground))"
  },
  "foreground": "hsl(var(--foreground))",
  "input": "hsl(var(--input))",
  "muted": {
    "DEFAULT": "hsl(var(--muted))",
    "foreground": "hsl(var(--muted-foreground))"
  },
  "popover": {
    "DEFAULT": "hsl(var(--popover))",
    "foreground": "hsl(var(--popover-foreground))"
  },
  "primary": {
    "DEFAULT": "hsl(var(--primary))",
    "foreground": "hsl(var(--primary-foreground))"
  },
  "ring": "hsl(var(--ring))",
  "secondary": {
    "DEFAULT": "hsl(var(--secondary))",
    "foreground": "hsl(var(--secondary-foreground))"
  }
}

```

色情報のファイルを作成したら、tailwind.confg.jsを以下のように変更します

```diff_javascript:assets/tailwind.config.js
module.exports = {
  content: [
    "./js/**/*.js",
+   "../deps/salad_ui/lib/**/*.ex",
    "../lib/pocket_book_web.ex",
    "../lib/pocket_book_web/**/*.*ex",
  ],
    extend: {
-     colors: {
-       brand: "#FD4F00",
-     }
+     colors: require("./tailwind.colors.json")
    },
  },
  plugins: [
    require("@tailwindcss/forms"),
+   require("@tailwindcss/typography"),
+   require("tailwindcss-animate"),
    ...
  ],
};

```

変更したらアニメーション用のライブラリを追加します


```bash
$ cd assets
$ yarn add -D tailwindcss-animate
```

最後に色情報のファイルのパスを設定して完了です

```config/config.exs
config :tails, colors_file: Path.join(File.cwd!(), "assets/tailwind.colors.json")
```

# CoreComponentのコンポーネントをSaladUIと差し替え

CoreComponentとSaladUIでコンポーネント名が衝突するものがあるので、
`except`オプションで読み込みから除外して、差し替えるSaladUIのコンポーネントを読み込みます
これで、アプリ全体を通してSaladUIの`.button`を使うようになります

```diff_elixir:lib/pocket_book_web.ex
  defp html_helpers do
    quote do
      # HTML escaping functionality
      import Phoenix.HTML
      # Core UI components and translation
-     import PocketBookWeb.CoreComponents
+     import PocketBookWeb.CoreComponents, except: [button: 1, label: 1]
+     import SaladUI.Button
+     import SaladUI.Label
    end
  end

```

# CRUD機能の実装

次に以下のコマンドでCRUD画面を作成します

```bash
$ mix phx.gen.live Pages Page pages title:string body:text
```

## Migrationファイルのコピー
ElixirDesktopのアプリの場合、mix ecto.migrationで端末内等のSqliteファイルにマイグレーションを実行できないため、アプリ起動時にマイグレーションを実行するようになっています

アプリ起動時のマイグレーションを行えるように以下の処理を行います

- `priv/repo/migrations`フォルダを`pocket_book/`にコピーします
- マイグレーションファイルを`[timestamp]_create_pages.exs` から `[timestamp]_create_pages.ex`に変更します
- マイグレーションファイルのモジュール名を以下のように変更

```diff_elixir:lib/pocket_book/migrations/20240904041336_create_pages.ex
- defmodule PocketBook.Repo.Migrations.CreatePages do
+ defmodule PocketBook.Migrations.CreatePages do
  use Ecto.Migration

  def change do
    create table(:pages) do
      add :title, :string
      add :body, :text

      timestamps(type: :utc_datetime)
    end
  end
end
```
- migrationスクリプトが`repo.ex`にあるので以下のように追加する
```diff_elixir:lib/pocket_book/repo.ex
defmodule PocketBook.Repo do
  def migration() do
+  Ecto.Migrator.up(PocketBook.Repo, 20240904041336, PocketBook.Migrations.CreatePages)  
  end
end
```

これで起動時にマイグレーションがまだの場合実行してくれます

## 起動時の初期ページの設定
通常のままだとトップページしか開けないため初期ページを以下のように変更します

```diff_elixir:lib/pocket_book.ex
defmodule PocketBook do
  use Application
  
  @app Mix.Project.config()[:app]
  def start(:normal, []) do
    ...
    {:ok, _} =
      Supervisor.start_child(sup, {
        Desktop.Window,
        [
          app: @app,
          id: PocketBookWindow,
          title: "pocket_book",
          size: {400, 800},
-         url: "http://localhost:#{port}"          
+         url: "http://localhost:#{port}/pages"
        ]
      })
  end
end
```

## デザイン修正
縦方向のパディングが多いので減らします

```diff_eex:lib/pocket_book_web/components/layouts/app.html.heex
- <main class="px-4 py-40 sm:px-6 lg:px-8">
+ <main class="px-4 py-4 sm:px-6 lg:px-8">
  <div class="mx-auto max-w-2xl">
    <.flash_group flash={@flash} />
    <%= @inner_content %>
  </div>
</main>
```

# SaladUIでCardレイアウト化
SaladUIの Cardコンポーネントでデフォルトのテーブル表示をカードレイアウトに変更します

CardとBadgeを読み込みます
```diff_elixir:lib/pocket_book_web/live/page_live/index.ex
defmodule PocketBookWeb.PageLive.Index do
  use PocketBookWeb, :live_view

  alias PocketBook.Pages
  alias PocketBook.Pages.Page

+ import SaladUI.Card
+ import SaladUI.Badge

  ...
end
```

`<.table></.table>`を削除して以下のように追加します

```eex:lib/pocket_book_web/live/page_live/index.html.heex
<.header>
  Listing Pages
  <:actions>
    <.link patch={~p"/pages/new"}>
      <.button>New Page</.button>
    </.link>
  </:actions>
</.header>

<div
  id="pages"
  class="mt-4 flex flex-wrap gap-1"
  phx-update={match?(%Phoenix.LiveView.LiveStream{}, @streams.pages) && "stream"}
>
  <%= for {id, page} <- @streams.pages do %>
    <.link navigate={~p"/pages/#{page}"}>
      <.card id={id} class="w-[42vw] ml-2 mb-2">
        <.card_header>
          <.card_title><%= page.title %></.card_title>
        </.card_header>
        <.card_content class="truncate">
          <%= page.body %>
        </.card_content>
        <.card_footer class="flex justify-end gap-x-1 -mr-4">
          <.link patch={~p"/pages/#{page}/edit"}>
            <.badge variant="outline">Edit</.badge>
          </.link>
          <.link patch={~p"/pages/#{page}/delete"}>
            <.badge variant="destructive">Delete</.badge>
          </.link>
        </.card_footer>
      </.card>
    </.link>
  <% end %>
</div>
...
```

![スクリーンショット 2024-09-04 15.15.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/6d7a327f-0a84-7da9-4a16-1faf68ea1f4b.png)



## 削除確認モーダル

confimやalertダイアログはElixirDesktop等のWebViewのアプリで表示できないので、
Dialogコンポーネントを使用して実装します

最初にルートを追加
```diff_elixir:lib/pocket_book_web/router.ex
  scope "/", PocketBookWeb do
    pipe_through :browser

    get "/", PageController, :home

    live "/pages", PageLive.Index, :index
    live "/pages/new", PageLive.Index, :new
    live "/pages/:id/edit", PageLive.Index, :edit
+   live "/pages/:id/delete", PageLive.Index, :delete


    live "/pages/:id", PageLive.Show, :show
    live "/pages/:id/show/edit", PageLive.Show, :edit
  end
```

次にDialogを読み込んで、パラメータのパターンマッチの追加と削除イベントを修正します

```diff_elixir:lib/pocket_book_web/live/page_live/index.ex
defmodule PocketBookWeb.PageLive.Index do
  use PocketBookWeb, :live_view

  alias PocketBook.Pages
  alias PocketBook.Pages.Page

  import SaladUI.Card
+ import SaladUI.Dialog 

  ...

  defp apply_action(socket, :index, _params) do
    socket
    |> assign(:page_title, "Listing Pages")
    |> assign(:page, nil)
  end

+  defp apply_action(socket, :delete, %{"id" => id}) do
+   socket
+   |> assign(:page_title, "Delete Page")
+   |> assign(:page, Pages.get_page!(id))
+  end


  @impl true
  def handle_info({PocketBookWeb.PageLive.FormComponent, {:saved, page}}, socket) do
    {:noreply, stream_insert(socket, :pages, page)}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    page = Pages.get_page!(id)
    {:ok, _} = Pages.delete_page(page)

-   {:noreply, stream_delete(socket, :pages, page)}
+    {
+      :noreply,
+      socket
+      # flashを表示
+      |> put_flash(:info, "Page deleted successfully")
+      # モーダルを閉じるように変更
+      |> push_navigate(to: ~p"/pages")
+      |> stream_delete(:pages, page)
+    }
  end
end
```

ついでにフォームのmodalをdialogに差し替えて、その下に削除確認ダイアログを追加します

```diff_eex:lib/pocket_book_web/live/page_live/index.html.heex
- <.modal
+ <.dialog 
  :if={@live_action in [:new, :edit]}
  id="page-modal"
  show
+ class="w-[90vw]"
  on_cancel={JS.patch(~p"/pages")}
>
  <.live_component
    module={PocketBookWeb.PageLive.FormComponent}
    id={@page.id || :new}
    title={@page_title}
    action={@live_action}
    page={@page}
    patch={~p"/pages"}
  />
- </.modal>
+ </.dialog>

<.dialog
  :if={@live_action in [:delete]}
  id="delete-modal"
  class="w-[90vw]"
  show
  on_cancel={JS.patch(~p"/pages")}
>
  <.dialog_header>
    <.dialog_title>Delete Page</.dialog_title>
  </.dialog_header>
  <div class="text-center">
    Are you sure?
  </div>
  <.dialog_footer>
    <div class="flex flex-col gap-y-4 p-2">
      <.button variant="destructive" phx-click="delete" phx-value-id={@page.id}>delete</.button>
      <.button variant="outline" phx-click={JS.patch(~p"/pages")}>cancel</.button>
    </div>
  </.dialog_footer>
</.dialog>
```

デフォルトのモーダルではなく、ダイアログが表示されるようになりました

![スクリーンショット 2024-09-04 14.51.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/f20207ba-106e-6d02-23eb-5eec7e0baa74.png)

削除確認ダイアログも問題なく表示できています

![スクリーンショット 2024-09-04 14.51.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/7946dc69-e1e8-9d20-ac5d-19ecc3821d44.png)

# 最後に

SaladUIを使うことでモバイルらしいUIを簡単に構築できるようになりました
他にも色々コンポーネントがあるので是非試してみてください、本記事は以上になりますありがとうございました

# 参考ページ

https://qiita.com/the_haigo/items/f419bda951bb60145556
https://qiita.com/RyoWakabayashi/items/c3dc2ec8bb67adb6dc3e
https://github.com/elixir-desktop/desktop
https://salad-storybook.fly.dev/welcome
https://ui.shadcn.com/