ElixirDesktopで作るスマホアプリ Part 4 UIコンポーネント導入、Welcomeページとオンボーディングの実装

# はじめに

この記事はElixirアドベントカレンダー2024のシリーズ２、8日目の記事です

https://qiita.com/advent-calendar/2024/elixir

アプリを初回起動時に表示されるWelcomeページとオンボーディングを実装していきます

今回の作業ブランチを作成します

```
git checkout -b feature/onboarding
```

# UIライブラリの追加

Welcomeページを作る前にUIを実装するにあたって素のTailwindだと辛いのでライブラリを入れていこうと思います

## UIコンポーネントライブラリの選定

PhoenixではデフォルトでCSSフレームワークでTailwindCSSが採用されています
ですが、PhoenixのCoreComponentとTailwindでUIを実装するのは大変なので、
UIコンポーネントライブラリを使用して、そのコストを抑えることができます

UIコンポーネントの選定は気をつけることがあって、
ReactやVue,SveltなどJSのフレームワークの上で動かすものが多くヒットするのでTailwind単体で動いて、JavaScriptを使用していないもの、またはLiveViewに対応している物を選ぶ必要があります

JavaScriptを使用していないものを選ぶのは、LiveViewでUI制御を行うときに干渉してしまうため調整が面倒だからです

> Tailwind単体で動いて、JSを使用していないもの

こちらは２種類あります
1. tailwindのユーティリティクラスだけで作成した実装サンプル集
2. applyでcssのクラス的にまとめたコンポーネント集

大半は１です。実装の時にこのUIどう実装するのかがわかるので、参考にするのにも大変良いです

https://sailboatui.com/

https://www.hyperui.dev/

https://tailwindui.com/

https://tailblocks.cc/

https://flowrift.com

https://merakiui.com/

ですがこれだと、記述がどうしても多くなってしまいますので
いい感じにまとめてくれている２を心情的には使いたいのですが、該当するものはほとんどなく
いい感じに揃っているのはこちらです

https://daisyui.com/

今回はこれを使用します

> LiveViewに対応している物を選ぶ必要があります

こちらは今年いくつか出てきたので気になった方は試してみてください

https://petal.build

https://mishka.tools/chelekom

https://github.com/bluzky/salad_ui

https://github.com/briankariuki/tremorx


daisyuiをインストールします
JSのライブラリをインストールするときは assetsに移動する必要があるので注意が必要です

```
cd assets
npm i daisyui
cd ..
```

インストールしたら、pluginsに requireを追加します

```diff_javascript:assets/tailwind.config.js
module.exports = {
  content: ["./js/**/*.js", "../lib/*_web.ex", "../lib/*_web/**/*.*ex"],
  theme: {...},
  plugins: [
    require("@tailwindcss/forms"),
+   require("daisyui"), 
    ...
  ],
  
```

UIコンポーネントの導入が終わったのでコミットします

```
git add .
git commit -m 'add daisyui'
```

# Welcomeページ

次にWelcomeページを作成します

こちらは未ログイン時にアプリを起動した際に表示される画面で
ログインと新規登録の導線を表示させます

Welcomeページに表示するいい感じの写真を`priv/static/images`に入れます

![welcome.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/04e60cb5-4298-ae38-22ec-bfa7e8ba1ac7.png)


phx.newで作られた初期ページ`home.html.heex`を以下のように変更します

画像のパスは`~p""`シジルで`/images/ファイル名`とすることで展開してくれます

このコンポーネントを使用して画面を構築します

https://daisyui.com/components/hero/#hero-with-overlay-image


```elixir:lib/trarecord_web/controllers/page_html/home.html.heex
<div class="hero h-screen " style={"background-image: url(#{~p"/images/welcome.png"});"}>
  <div class="hero-overlay bg-opacity-0"></div>
  <div class="hero-content text-center text-neutral-content">
    <div class="w-full">
      <div class="-mt-64">
        <h1 class="text-5xl font-bold mb-24">Welcome to Trarecord</h1>
      </div>
      <div class="fixed bottom-12 right-8 flex flex-col gap-y-8">
        <.link navigate={~p"/users/log_in"} class="btn font-bold normal-case w-32">
          Sign In
        </.link>
        <.link navigate={~p"/users/register"} class="btn font-bold normal-case w-32">
          Sign Up
        </.link>
      </div>
    </div>
  </div>
</div>
```

起動時に未ログインの場合はログインページにリダイレクトしていましたが、Welcomeページをそのまま表示するように変更します

```diff_elixir:lib/trarecord_web/controllers/page_controller.ex:L19
defmodule TrarecordWeb.PageController do
  use TrarecordWeb, :controller
  alias Trarecord.Accounts.User

- def home(conn, _params) do
-   # The home page is often custom made,
-   # so skip the default app layout.
-   render(conn, :home, layout: false)
- end

  def index(conn, _params) do
    redirect_to(conn, conn.assigns.current_user)
  end

  defp redirect_to(conn, %User{}) do
    redirect(conn, to: ~p"/users/settings")
  end

  defp redirect_to(conn, nil) do
-   redirect(conn, to: ~p"/users/log_in")  
+   render(conn, :home, layout: false)
  end
end
```

これで起動時にWelcome画面が表示されそこから、ログイン、新規登録の導線ができました

Welcomeページにlogin,registerリンクが出て邪魔なので削除します

```diff_elixir:lib/trarecord_web/components/layouts/root.html.heex
<!DOCTYPE html>
<html lang="en" class="[scrollbar-gutter:stable]">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="csrf-token" content={get_csrf_token()} />
    <.live_title suffix=" · Phoenix Framework">
      <%= assigns[:page_title] || "Trarecord" %>
    </.live_title>
    <link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
    <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
    </script>
  </head>
  <body class="bg-white">
-   <ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
-     <%= if @current_user do %>
-       <li class="text-[0.8125rem] leading-6 text-zinc-900">
-         <%= @current_user.email %>
-       </li>
-       <li>
-         <.link
-           href={~p"/users/settings"}
-           class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
-         >
-           Settings
-         </.link>
-       </li>
-       <li>
-         <.link
-           href={~p"/users/log_out"}
-           method="delete"
-           class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
-         >
-           Log out
-         </.link>
-       </li>
-     <% else %>
-       <li>
-         <.link
-           href={~p"/users/register"}
-           class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
-         >
-           Register
-         </.link>
-        </li>
-       <li>
-         <.link
-           href={~p"/users/log_in"}
-           class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
-         >
-           Log in
-         </.link>
-       </li>
-     <% end %>
-   </ul>
    <%= @inner_content %>
  </body>
</html>
```

これでWelcomeページが出来たのでコミットします

```
git add .
git commit -m 'impl welcome page'
```

# オンボーディング

オンボーディングとはアプリやWebサービスを登録した後にどのようなアプリで、どう使うのかをユーザーに伝える機能です

スライドショーだったり、実際に使用するボタンのみ有効にしてユーザーの操作をガイドするなどいくつか種類があります

今回はスライドショーで実装していきます

## 画像の準備
スライドショーで使用する画像ですが、以下からダウンロードします

https://www.freepik.com/author/stories

https://www.freepik.com/free-vector/flying-around-world-with-airplane-concept-illustration_8426450.htm#fromView=author&page=1&position=1&uuid=e2b0ffe6-2e3c-41ea-8419-3e3657ad419d

https://www.freepik.com/free-vector/resume-folder-concept-illustration_5358953.htm#fromView=author&page=1&position=6&uuid=31a01ff6-d2cb-4798-bc51-89a0517d7ad9

https://www.freepik.com/free-vector/post-concept-illustration_5928515.htm#fromView=author&page=1&position=2&uuid=1450568b-5aac-4d60-a3ad-bef059625d94

この方の画像は無料ですが、クレジット表記をする必要があるので、ダウンロード時に表記するクレジットのhtmlをコピーして控えておきましょう

![スクリーンショット 2024-12-06 1.09.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/919908c0-c836-6c7f-9236-5521b4e86667.png)

## オンボーディングページの作成

素材が揃ったのでオンボーディングページを作成します

スライドショーなのでページ推移はせずに nextボタンを押して次の画像とテキストを表示し、最後の画像ならfinishを押してオンボーディングを完了させます

表示する画像とテキスト、リンクのリストを返す`intro/0`関数を実装します

データ構造は次の様にしています

- page -> ページ番号
- title -> タイトル
- text -> 本文
- link -> クレジットのリンク
- image -> 画像パス
- bg -> 背景色

画像の切り替えイベントはページ番号が3だったらfinishで完了となりそれ以外は次の画像を表示するようにしています

```lib/trarecord_web/live/onboarding_live/index.ex
defmodule TrarecordWeb.OnboardingLive.Index do
  use TrarecordWeb, :live_view

  def render(assigns) do
    ~H"""
    <div
      class="flex justify-center w-screen h-screen bg-local bg-contain bg-center bg-no-repeat"
      style={"background-image: url(#{@page_data.image}); background-color: #{@page_data.bg}"}
    >
      <div class="grid grid-rows-6 mt-12 text-center">
        <h1 class="text-2xl"><%= raw(@page_data.title) %></h1>
        <p class="text-base -mt-12 row-span-4"><%= raw(@page_data.text) %></p>
        <%= if @page == 3 do %>
          <p class="text-xl cursor-default" phx-click="finish">finish</p>
        <% else %>
          <p class="text-xl cursor-default" phx-click="next">next</p>
        <% end %>
      </div>
    </div>
    <p id="freepik" class="absolute bottom-2 right-4 text-sm">
      <a href={@page_data.link} class="underline text-blue-600">
        Image by storyset on Freepik
      </a>
    </p>
    """
  end

  def mount(_params, _session, socket) do
    socket
    |> assign(:page, 1)
    |> assign(:page_data, Enum.find(intro(), &(&1.page == 1)))
    |> then(&{:ok, &1})
  end

  def handle_event("next", _params, socket) do
    page = socket.assigns.page + 1

    socket
    |> assign(:page, page)
    |> assign(:page_data, Enum.find(intro(), &(&1.page == page)))
    |> then(&{:noreply, &1})
  end

  def handle_event("finish", _params, socket) do
    {:noreply, push_navigate(socket, to: ~p"/")}
  end

  def intro() do
    [
      %{
        page: 1,
        title: "Let's gather where we want to go.",
        text: "You can register <br />various spots using Google search.",
        link:
          "https://www.freepik.com/free-vector/flying-around-world-with-airplane-concept-illustration_8426450.htm#fromView=author&page=1&position=12&uuid=dbcac267-0605-4076-ae43-111dfb07a118",
        image: "/images/flying-around-world.svg",
        bg: "#59b2ab"
      },
      %{
        page: 2,
        title: "Let's organize them in folders.",
        text: "If you have more spots, divide them into <br />folders for easier management.",
        link:
          "https://www.freepik.com/free-vector/resume-folder-concept-illustration_5358953.htm#fromView=author&page=1&position=6&uuid=31a01ff6-d2cb-4798-bc51-89a0517d7ad9",
        image: "/images/folder.svg",
        bg: "#febe29"
      },
      %{
        page: 3,
        title: "Record your travel.",
        text:
          "You can record your memories of actually visiting the spot<br />with GPS logging and Checkin.",
        link:
          "https://www.freepik.com/free-vector/post-concept-illustration_5928515.htm#fromView=author&page=1&position=2&uuid=1450568b-5aac-4d60-a3ad-bef059625d94",
        image: "/images/post.svg",
        bg: "#22bcb5"
      }
    ]
  end
end
```

## ルーティングとリダイレクト処理

liveviewのページは作ったのでルーティングを追加します
登録後に表示するので、ログイン後のスコープに追加します

```diff_elixir:lib/trarecord_web/router.ex:L64
  scope "/", TrarecordWeb do
    pipe_through [:browser, :require_authenticated_user]

    live_session :require_authenticated_user,
      on_mount: [{TrarecordWeb.UserAuth, :ensure_authenticated}] do
      live "/users/settings", UserSettingsLive, :edit
      live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
+     live "/onboarding", OnboardingLive.Index, :index
    end
  end
```

user_registration_liveのフォームをsubmitするとここを通るので、`put_session/3`関数でオンボーディングにリダイレクトするように変更します

```diff_elixir:lib/trarecord_web/controllers/user_session_controller.ex:L7
  def create(conn, %{"_action" => "registered"} = params) do
-   create(conn, params, "Account created successfully!")
+   conn
+   |> put_session(:user_return_to, ~p"/onboarding")
+   |> create(params, "Account created successfully!")
  end
```

オンボーディング時にヘッダーが邪魔なので以下のように変更します

```heex:lib/trarecord_web/components/layouts/app.html.heex
<main>
  <div class="flex flex-col">
    <.flash_group flash={@flash} />
    <%= @inner_content %>
  </div>
</main>
```

オンボーディングの実装が完了したのでコミットします

```
git add .
git commit -m 'impl onboarding'
```

# 動作確認

実装が完了したので動作確認を行います

![bb04af429b108e899e92bc2be0324111.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/07adef69-9ca2-9a3f-dacc-a757b6577201.gif)

新規登録後オンボーディングが始まり、完了後はログイン後画面にリダイレクトされるのを確認できました

# テストの追加・修正

オンボーディング周りで既存の改修を行ったので、こけたテストの修正とオンボーディングのテストを追加していきます

## page_controller

未ログインで起動時はWelcomeページを開くようにしたので変更

```diff_elixir:test/trarecord_web/controllers/page_controller_test.exs
defmodule TrarecordWeb.PageControllerTest do
  use TrarecordWeb.ConnCase

  test "GET /", %{conn: conn} do
    conn = get(conn, ~p"/")
-   assert html_response(conn, 302) =~ "/users/log_in"
+   assert html_response(conn, 200) =~ "Welcome"
  end
end
```

## user_session_controller_test

登録後は onboardingにリダイレクトするようにしたので変更

```diff_elixir:test/trarecord_web/controllers/user_session_controller_test.exs:L53
    test "login following registration", %{conn: conn, user: user} do
      conn =
        conn
        |> post(~p"/users/log_in", %{
          "_action" => "registered",
          "user" => %{
            "email" => user.email,
            "password" => valid_user_password()
          }
        })

-     assert redirected_to(conn) == ~p"/"
+     assert redirected_to(conn) == ~p"/onboarding"     
      assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Account created successfully"
    end
```

## user_login_live_test

ヘッダーを消したので新規登録のリンクは Sing upだけになったので変更

```diff_elixir:test/trarecord_web/live/user_login_live_test.exs:L7
    test "renders log in page", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/users/log_in")

      assert html =~ "Log in"
-     assert html =~ "Register"
+     assert html =~ "Sign up"
      assert html =~ "Forgot your password?"
    end
```

## user_registration_live_test

新規登録後はonboardingにリダイレクトするようにしたので変更

```diff_elixir:test/trarecord_web/live/user_registration_live_test.exs
    test "creates account and logs the user in", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/users/register")

      email = unique_user_email()
      form = form(lv, "#registration_form", user: params_for(:user_form_data, email: email))
      render_submit(form)
      conn = follow_trigger_action(form, conn)

-     assert redirected_to(conn) == ~p"/"
+     assert redirected_to(conn) == ~p"/onboarding"

      # Now do a logged in request and assert on the menu
      conn = get(conn, "/")
      response = html_response(conn, 302)
      assert response =~ "/users/settings"
    end
```

## onboarding_live_test

ページを開いた時のテストと、オンボーディング完了と完了後のリダイレクトのテストを作成

```test/trarecord_web/live/onboarding_live_test.exs
defmodule TrarecordWeb.OnboardingLiveTest do
  use TrarecordWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  describe "start onbarding" do
    setup %{conn: conn} do
      %{conn: log_in_user(conn, insert(:user))}
    end

    test "render onboarding page", %{conn: conn} do
      {:ok, _lv, html} = live(conn, ~p"/onboarding")

      assert html =~ "Let&#39;s gather where we want to go."
      assert html =~ "next"
    end

    test "finish onboarding and redirect /", %{conn: conn} do
      {:ok, lv, _html} = live(conn, ~p"/onboarding")

      assert lv
             |> element("p", "next")
             |> render_click() =~ "Let&#39;s organize them in folders."

      assert lv
             |> element("p", "next")
             |> render_click() =~ "Record your travel."

      assert lv
             |> element("p", "finish")
             |> render_click()
             |> follow_redirect(conn, "/")
    end
  end
end
```

```
git add .
git commit -m 'fix test'
git push origin feature/onbarding
```

# CI check and Merge

CIも無事通ったのでマージして完了です

![スクリーンショット 2024-12-06 3.22.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/e2a6325d-5714-5133-505b-c567ed460f6a.png)

# 最後に

UIコンポーネントライブラリの追加、Welcompeページとオンボーディングの実装が完了しました
これでアプリらしさが結構出てきたかと思います

次は指定したリンクをブラウザで開く、ネイティブ機能との連携を実装していきます

本記事は以上になりますありがとうございました

# 参考ページ
https://sailboatui.com/
https://www.hyperui.dev/
https://tailwindui.com/
https://tailblocks.cc/
https://flowrift.com
https://merakiui.com/
https://daisyui.com/
https://petal.build
https://mishka.tools/chelekom
https://github.com/bluzky/salad_ui
https://github.com/briankariuki/tremorx
https://www.freepik.com/author/stories
https://myajo.net/tips/9500/