はじめに
本記事はElixirDesktopを使用してGPS Loggingを行うAndroidアプリを作成した記事になります
- プロジェクトをつくる
- Androidでビルドする
- saasからTailwind,DaisyUIに差し替える
- DaisyUIでHeaderとBottomTabを実装する
- GPS ロギング画面を実装する
- AndroidのWebViewで位置情報を取得できるようにする
- JS HookでLiveView側に反映させる
- GPSログをDBに保存する
ElixirDesktopとは
WxWidgetとPhoenixを組み合わせてwebサーバーを起動することなくスタンドアローンでネイティブアプリを起動・実装できるライブラリです
サンプルアプリはDBにはSqlite3を使っています
また、自分はまだ成功していませんが、iOSでも同一のコードでネィティブアプリを起動することができます
iOS: https://github.com/elixir-desktop/ios-example-app
Android: https://github.com/elixir-desktop/android-example-app
下準備
※アプリバージョンが上がっていて、README通りに進めれば問題なくできるようになりました 2023/04/21
install Android Studio + NDK
ADV Managerでデバイスを1つ作成
asdfだとうまく行かないので brew installします
また kerlというErlangのバージョン管理ライブラリも追加します
kerlでElixirDesktop用にカスタムされたotp24のErlangをインストールします
brew install elixir kerl
mkdir -p ~/projects/
kerl build git https://github.com/diodechain/otp.git diode/beta 24.beta
kerl install 24.beta ~/projects/24.beta
プロジェクト作成
では早速作っていこうと思いますが、まだmix phx.new
に該当するコマンドは無いようなので
サンプルのREADMEに沿ってやってみましょう
How to build & run
Install the beta OTP build *(see known issues)
Install Android Studio + NDK.
Go to "Files -> New -> Project from Version Control" and enter this URL: https://github.com/elixir-desktop/android-example-app/
Update the run_mix to activate the correct Erlang/Elixir version during build.
Connect your Phone to Android Studio
Start the App
githubからプロジェクトを作成するを選択し
AndroidサンプルのリポジトリURLを入れてディレクトリ名をandroid-qiitaにしてcloneを実行します
プロジェクトが作成されるとgradleの同期が走りますのでしばらく待ちましょう
左下に小さく進捗が表示されています
Androidアプリとしてビルドする
完了したら下準備で作成したバーチャルデバイスでRunを押してビルドを実行します
残念ながら失敗しますので順次解決してきましょう
Error1 Javaのバージョンがあっていない
An exception occurred applying plugin request [id: 'com.android.application']
> Failed to apply plugin 'com.android.internal.application'.
> Android Gradle plugin requires Java 11 to run. You are currently using Java 1.8.
You can try some of the following options:
- changing the IDE settings.
- changing the JAVA_HOME environment variable.
- changing `org.gradle.java.home` in `gradle.properties`.
こちらのエラーが出た場合はこちらを参考に解消しましょう
Error2 run_mixが成功しない
次はこんなエラーが出るかと思います
Execution failed for task ':app:buildNum'.
> Process 'command '../run_mix'' finished with non-zero exit value 1
手動でやってみる
コンソールで開いて以下の箇所まで移動し run_mix
を実行します
cd ~/StudioProjects/android-qiita/app
../run_mix
Elixirが起動しない
asdfを使っている方は下のようなエラーが出るかと思われます
{"init terminating in do_boot",{undef,[{elixir,start_cli,[],[]},{init,start_em,1,[]},{init,do_boot,3,[]}]}}
init terminating in do_boot ({undef,[{elixir,start_cli,[],[]},{init,start_em,1,[]},{init,do_boot,3,[]}]})
これは run_mixでkerlでerlangを切り替えたのですが、asdfに入っているElixirが対応していない場合に出ます。 brew install elixir
でインストールしたElixirだと大丈夫なので、asdfを無効化にしましょう
direnvを使用します
brew install direnv
touch .envrc
プロジェクト内にファイルを作成したら以下の内容を追加します userは適宜自分のユーザー名に変えてください
PATH_rm /Users/user/.asdf/shims
PATH_rm /Users/user/.asdf/bin
完了したら以下のコマンドを実行します
direnv allow
再度実行
../run_mix を実行すると以下のようなエラーになるのでphoenix部分のフォルダに移動移動してdeps.getを実行します
Unchecked dependencies for environment prod:
cd elixir-app
mix deps.get
cd ..
wxが無い!
elixir-desktopライブラリに含まれているはずですが無いので最新版を追加します
defp deps do
[
{:ecto_sqlite3, "~> 0.7"},
# {:desktop, path: "../desktop"},
{:desktop, github: "elixir-desktop/desktop", tag: "v1.4.0"},
{:wx, "~> 1.0.10", hex: :bridge, targets: [:android, :ios]}, # 追加
...
]
end
cd elixir-app
mix deps.get
cd ..
../run_mix
SASSのファイルがダウンロードできない!
スタイリングで使っているsassのバイナリがリンク切れでDLできないそうなのでバージョン等を上げて対応します
** (RuntimeError) could not download dart_sass for architecture: arm-apple-darwin21.2.0
defmodule Todo.MixProject do
use Mix.Project
...
# Run "mix help deps" to learn about dependencies.
defp deps do
[
# Assets
{:esbuild, "~> 0.2", runtime: Mix.env() == :dev},
{:dart_sass, "~> 0.5", runtime: Mix.env() == :dev}, # 0.5に上げる
# Credo
{:credo, "~> 1.5", only: [:dev, :test], runtime: false}
]
end
end
config :dart_sass,
version: "1.49.11", # バージョン上げる
default: [
args: ~w(css/app.scss ../priv/static/assets/app.css),
cd: Path.expand("../assets", __DIR__)
]
cd elixir-app
mix deps.get
cd ..
../run_mix
これでelixir側のビルドは完了しました
ビルド失敗するのでrun_mixを何もしない別のものに変える
まだAndroid側のビルドは失敗するので Android側で実行するシェルスクリプトを空にしましょう
cp ../run_mix .
#!/bin/bash
set -e
ビルド時に実行するファイルを変更します
lugins {
id 'com.android.application'
id 'kotlin-android'
}
android {
compileSdkVersion 30
buildToolsVersion "30.0.3"
task buildNum(type: Exec, description: 'Update Elixir App') {
// さっきの空シェルスクリプトを実行するようにする
commandLine './run_mix', 'package.mobile'
}
...
}
gradleファイルを変更してrunボタンが無効化されている場合は、右上のsync project with gradle Filesボタンを押します
完了したら 緑のrunボタンを押します
起動できた!
色々やりましたがこちらの記事の方が手順が少ないのでこちらのほうがいいかもしれません
SASSからTailwind, DaisyUIに差し替える
せっかく起動するようにしましたが tailwindとdaisyuiでスタイリングするのでsass消していきます
最初にscssをcssにリネームします
scssのファイル読み込みを消して、scss形式のコメントアウトを解除してコメントインします
@use "../node_modules/nprogress/nprogress.css";
@import "./todo.scss"; /* 消す */
/* LiveView specific classes for your customizations */
.invalid-feedback {
color: #a94442;
display: block;
margin: -1rem 0 2rem; /* コメントイン */
}
...
こちらを参考にtailwindを設定していきます
defp aliases do
[
"assets.deploy": [
"phx.digest.clean --all",
"esbuild default --minify",
- "sass default --no-source-map --style=compressed",
"tailwind default --minify", # 追加
"phx.digest"
]
]
end
defp deps do
[
...
# Phoenix
{:phoenix, "~> 1.6"},
{:phoenix_live_view, "~> 0.17.4"}, # 0.17に上げる
...
# Assets
{:esbuild, "~> 0.2", runtime: Mix.env() == :dev},
- {:dart_sass, "~> 0.5", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.1", runtime: Mix.env() == :dev}, # 追加
# Credo
{:credo, "~> 1.5", only: [:dev, :test], runtime: false}
]
end
- config :dart_sass,
- version: "1.49.11",
- default: [
- args: ~w(css/app.scss ../priv/static/assets/app.css),
- cd: Path.expand("../assets", __DIR__)
- ]
# 以下追加
config :tailwind, version: "3.1.6", default: [
args: ~w(
--config=tailwind.config.js
--input=css/app.css
--output=../priv/static/assets/app.css
),
cd: Path.expand("../assets", __DIR__)
]
config :todo_app, TodoWeb.Endpoint,
debug_errors: true,
code_reloader: true,
check_origin: false,
watchers: [
esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
- sass:
- {DartSass, :install_and_run,
- [:default, ~w(--embed-source-map --source-map-urls=absolute --watch)]}
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} # 追加
],
...
cd elixir-app
mix deps.get
mix tailwind.install
これでtailwind.config.jsも作られるので、次のdaisyuiをインストールしていきます
cd assets
npm install daisyui
cd ..
let plugin = require('tailwindcss/plugin')
module.exports = {
content: [
'./js/**/*.js',
'../lib/*_web.ex',
'../lib/*_web/**/*.*ex'
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
require("daisyui"), // 追加
...
]
}
Androidだといちいちビルドが必要なので
UIの開発はホットリロードしてくれるデスクトップで行っていきます
iex -S mix
DaisyUIでHeaderとBottomTabを実装する
tailwindとdaisyuiが入ったのでスマホっぽいUIを組んでいきましょう
共通コンポーネントとして作るので
live/componentsフォルダを作ります
Header
daisyuiにあるのでそれを使います
defmodule TodoWeb.Components.Header do
@moduledoc """
Application Header
"""
use TodoWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
end
end
描画部分 assignにtitleを受け取り、真ん中に表示します
<div class="navbar bg-primary text-primary-content">
<div class="navbar-start">
</div>
<div class="navbar-center">
<a class="btn btn-ghost normal-case text-4xl"><%= @title %></a>
</div>
<div class="navbar-end">
</div>
</div>
BottomTab
こちらも daisyuiにあるので使います
defmodule TodoWeb.Components.BottomTab do
@moduledoc """
Bottom Tab Navigation
"""
use TodoWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
end
end
まだ他のページがないので全てトップへのリンクにしておきます
titleが一致した場合はactive状態にして線を表示するようにします
<div class="btm-nav">
<button class={if @title == "Home", do: "active", else: ""}>
<a href="/">
<span class="btm-nav-label">Home</span>
</a>
</button>
<button class={if @title == "GPS", do: "active", else: ""}>
<a href="/">
<span class="btm-nav-label">GPS</span>
</a>
</button>
<button class={if @title == "Log", do: "active", else: ""}>
<a href="/">
<span class="btm-nav-label">Log</span>
</a>
</button>
</div>
todo_liveでの読み込み
コンポーネントができたので実際に使ってみましょう
コンポーネントで使うのでtitleをアサインします
defmodule TodoWeb.TodoLive do
@moduledoc """
Main live view of our TodoApp. Just allows adding, removing and checking off
todo items
"""
use TodoWeb, :live_view
alias TodoWeb.Components.{Header, BottomTab}
@impl true
def mount(_args, _session, socket) do
todos = TodoApp.Todo.all_todos()
TodoApp.Todo.subscribe()
{
:ok,
socket
|> assign(:title, "Home")
|> assign(todos: todos)
}
end
...
end
サンプルのコードはleexなのでheexにリネームします
コンポーネントを上下に配置して、コンテンツはHome画面なので良さげなHeroUnitを貼り付けます
<.live_component module={Header} id="header" title={@title} />
<div class="hero min-h-screen bg-base-200">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">Hello there</h1>
<p class="py-6">Provident cupiditate voluptatem et in. Quaerat fugiat ut assumenda excepturi exercitationem quasi. In deleniti eaque aut repudiandae et a id nisi.</p>
<button class="btn btn-primary">Get Started</button>
</div>
</div>
</div>
<.live_component module={BottomTab} id="bottom_tab" title={@title} />
いい感じになりました!
GPS ロギング画面を実装する
GPSロギング画面を作りましょう
defmodule TodoWeb.GpsLive do
use TodoWeb, :live_view
alias TodoWeb.Components.{Header, BottomTab}
@impl true
def mount(_args, _session, socket) do
{
:ok,
socket
|> assign(:title, "GPS")
|> assign(:lat, 0)
|> assign(:lng, 0)
}
end
end
Statという変動する数値を表示するのに良さそうなコンポーネントを使用します
<div id="gps" class="w-full h-screen bg-base-200">
<.live_component module={Header} id="header" title={@title} />
<div class="hero bg-base-200 mt-20">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">GPS Logger</h1>
<div class="mt-20">
<button class="btn btn-primary">Start Logging</button>
<button class="btn btn-secondary">Stop Logging</button>
</div>
</div>
</div>
</div>
<div class="stats stats-vertical shadow mt-20">
<div class="stat w-screen">
<div class="stat-title">Latitude</div>
<div class="stat-value text-primary"><%= @lat %></div>
</div>
<div class="stat w-screen">
<div class="stat-title">Longtitude</div>
<div class="stat-value text-secondary"><%= @lng %></div>
</div>
</div>
<.live_component module={BottomTab} id="bottom_tab" title={@title} />
</div>
画面を作ったらルーティングとBottomTabに追加します
defmodule TodoWeb.Router do
use TodoWeb, :router
...
scope "/", TodoWeb do
pipe_through :browser
live "/", TodoLive
live "/gps", GpsLive #追加
end
end
2つめのリンクをGPSに変更します
<div class="btm-nav">
<button class={if @title == "Home", do: "active", else: ""}>
<a href="/">
<span class="btm-nav-label">Home</span>
</a>
</button>
<button class={if @title == "GPS", do: "active", else: ""}>
<%= live_redirect to: Routes.live_path(@socket, TodoWeb.GpsLive) do %>
<span class="btm-nav-label">GPS</span>
<% end %>
</button>
<button class={if @title == "Log", do: "active", else: ""}>
<a href="/">
<span class="btm-nav-label">Log</span>
</a>
</button>
</div>
GPSロギング部分の画面ができました
AndroidのWebViewで位置情報を取得できるようにする
このままだとAndroidのWebViewでは位置情報が取得できないので
Android側の設定を行います
220行目あたりにWebView周りの設定があるので seGeolocationEnabled(true)で位置情報取得を有効化します
権限要求を追加します
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.elixirdesktop.example"
android:installLocation="internalOnly">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
...
class Bridge(private val applicationContext : Context, private var webview : WebView) {
...
fun setWebView(_webview: WebView) {
webview = _webview
val settings = webview.settings
settings.setGeolocationEnabled(true) // 追加
settings.javaScriptEnabled = true
settings.layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL
settings.useWideViewPort = true
// enable Web Storage: localStorage, sessionStorage
settings.domStorageEnabled = true
if (lastURL.isNotBlank()) {
webview.post { webview.loadUrl(lastURL) }
}
}
...
}
kotlin,Javaは詳しくないので以下からコードをコピペ、AndroidStudioだと貼り付け時にJavaコードをKotlinに変えてくれるみたいでそのままで良かったです
すごいな・・・
package io.elixirdesktop.example
// 以下を追加
import android.Manifest
import android.content.pm.PackageManager
import android.webkit.GeolocationPermissions
import android.webkit.WebChromeClient
import androidx.core.app.ActivityCompat
class MainActivity : Activity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkPermission() // 位置情報の使用承諾画面を出す
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.browser.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String) {
if (binding.browser.visibility != View.VISIBLE) {
binding.browser.visibility = View.VISIBLE
binding.splash.visibility = View.GONE
}
}
}
// パーミッション周りの記述
binding.browser.webChromeClient = object : WebChromeClient() {
override fun onGeolocationPermissionsShowPrompt(
origin: String?,
callback: GeolocationPermissions.Callback?
) {
//tell the webview that permission has granted
callback!!.invoke(origin, true, true)
}
}
if (bridge != null) {
// This happens on re-creation of the activity e.g. after rotating the screen
bridge!!.setWebView(binding.browser)
} else {
// This happens only on the first time when starting the app
bridge = Bridge(applicationContext, binding.browser)
}
}
// パーミッション許可のモーダル部分の内容
private fun checkPermission() {
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
&& ActivityCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_COARSE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
// パーミッションの許可を取得する
ActivityCompat.requestPermissions(
this,
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
),
1000
)
}
}
...
}
同じ方法でWebViewの設定をすればジャイロセンサーやカメラなネイティブな部分も使えるかと思います
JS HookでLiveView側に反映させる
WebViewで位置情報を取得できるようになったので、JSの GeolocationAPIで取得していきます
PhoenixLiveViewには相互にデータと関数の送信を行える JS Hookというものがあるのでそちらを使用します
マウント時に
startボタンを押したらロギングを開始し、更新があったらPhoenix側のupdateを実行する
stopボタンを押したら停止する
イベントを追加します
let Hooks = {};
Hooks.Gps = {
mounted() {
this.handleEvent("start_logging", () => {
watchID = navigator.geolocation.watchPosition((position) => {
this.pushEvent("update", { lat: position.coords.latitude, lng: position.coords.longitude });
});
window.watchID = watchID
})
this.handleEvent("stop_logging", () => {
navigator.geolocation.clearWatch(window.watchID);
})
}
}
export default Hooks
hookを作成したらapp.jsで読み込みます
import Hooks from "./hooks"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks,
params: { _csrf_token: csrfToken }
})
hooksを作成したら id属性を付けたタグで読み込み
StartとStopボタンにクリックイベントを追加します
<div id="gps" class="w-full h-screen bg-base-200" phx-hook="Gps">
<.live_component module={Header} id="header" title={@title} />
<div class="hero bg-base-200 mt-20">
<div class="hero-content text-center">
<div class="max-w-md">
<h1 class="text-5xl font-bold">GPS Logger</h1>
<div class="mt-20">
<button phx-click="start" class="btn btn-primary">Start Logging</button>
<button phx-click="stop" class="btn btn-secondary">Stop Logging</button>
</div>
</div>
</div>
</div>
...
</div>
クリックイベント時とGPSの値が送信された時の処理を追加します
JS側のイベントを発火させる際には push_eventを実行し、何も引数がなくてもMapを第3引数にいれます
JS側から発火されるupdateイベントもhandle_eventで実装します
defmodule TodoWeb.GpsLive do
use TodoWeb, :live_view
alias TodoWeb.Components.{Header, BottomTab}
@impl true
def mount(_args, _session, socket) do
{
:ok,
socket
|> assign(:title, "GPS")
|> assign(:lat, 0)
|> assign(:lng, 0)
}
end
# 以下追加
@impl true
def handle_event("start", _, socket) do
{:noreply, push_event(socket, "start_logging", %{})}
end
@impl true
def handle_event("stop", _, socket) do
{:noreply, push_event(socket, "stop_logging", %{})}
end
@impl true
def handle_event("update", %{"lat" => lat, "lng" => lng} = params, socket) do
{
:noreply,
socket
|> assign(:lat, lat)
|> assign(:lng, lng)
}
end
end
GPSログをDBに保存する
ログを取得できるようになったので、その値をDBに保存したいと思います
phx.gen.liveで行いたいですが、現在のファイル構成だと齟齬が出るので修正していきます
VSCode等のプロジェクト内コード検索で
TodoWebとなっている箇所をTodoAppWebに置き換えます
todo_webとなっている箇所を todo_app_webに置き換えます
todo_web.exをtodo_app_web.exにリネーム
lib/todo_web を lib/todo_app_webにリネーム
準備が整ったら以下を実行します
mix phx.gen.live Loggers Position positions lat:float lng:float
routesが吐き出されるのでrouter.exに貼り付けます
defmodule TodoAppWeb.Router do
use TodoAppWeb, :router
...
scope "/", TodoAppWeb do
pipe_through(:browser)
live("/", TodoLive)
live("/gps", GpsLive)
# 以下追加
live("/positions", PostionLive.Index, :index)
live("/positions/new", PostionLive.Index, :new)
live("/positions/:id/edit", PostionLive.Index, :edit)
live("/positions/:id", PostionLive.Show, :show)
live("/positions/:id/show/edit", PostionLive.Show, :edit)
end
end
defmodule TodoAppWeb.GpsLive do
use TodoAppWeb, :live_view
alias TodoAppWeb.Components.{Header, BottomTab}
alias TodoApp.Loggers #追加
@impl true
def handle_event("update", %{"lat" => lat, "lng" => lng} = params, socket) do
# create_positionを挟み込む
case Loggers.create_postion(params) do
{:ok, _postion} ->
{
:noreply,
socket
|> assign(:lat, lat)
|> assign(:lng, lng)
}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
end
index.html.heexの上下にHeaderとBottomTabを付けます
<.live_component module={Header} id="header" title={@title} />
<h1>Listing Positions</h1>
...
<.live_component module={BottomTab} id="bottom_tab" title={@title} />
titleをアサインします
defmodule TodoAppWeb.PostionLive.Index do
use TodoAppWeb, :live_view
alias TodoApp.Loggers
alias TodoApp.Loggers.Postion
alias TodoAppWeb.Components.{Header, BottomTab}
@impl true
def mount(_params, _session, socket) do
{
:ok,
socket
|> assign(:title, "Log")
|> assign(:positions, list_positions())
}
end
...
タブにリンクを追加します
<div class="btm-nav">
...
<button class={if @title == "Log", do: "active", else: ""}>
<%= live_redirect to: Routes.postion_index_path(@socket, :index) do %>
<span class="btm-nav-label">Log</span>
<% end %>
</button>
</div>
AndroidアプリだとAndroid側のsqliteを叩く必要があり、
マイグレーション周りがまだ整備されていない感じなので今回はSQLベタ書きにします
defmodule TodoApp.Repo do
use Ecto.Repo, otp_app: :todo_app, adapter: Ecto.Adapters.SQLite3
def initialize() do
Ecto.Adapters.SQL.query!(__MODULE__, """
CREATE TABLE IF NOT EXISTS positions (
id INTEGER PRIMARY KEY,
lat REAL,
lng REAL
)
""")
end
end
timestampを削除
defmodule TodoApp.Loggers.Postion do
use Ecto.Schema
import Ecto.Changeset
schema "positions" do
field :lat, :float
field :lng, :float
timestamps() # 削除
end
..
end
elixir-app内でビルドしたコードだとandroidだと動かないので一度cleanしてからビルドし直します
mix deps.clean --all
mix deps.get
cd ..
../run_mix
エミュレーターがうまく動かないのでfakeGPSの値を取得するようにします
https://developer.android.com/studio/debug/dev-options?hl=ja
developer optionsはビルド番号を7回タップして有効化してください
developer optionsでplaystoreからインストールしたfake gpsを設定してください
demo
最後に
というわけでElixirDesktopでGPSロガーを作ってみました
いかがでしょうか、まだExpo(react native)やFlutterほど簡単に開発できるほどこなれていませんが
整備されてきてこの部分が解消されたら、tailwind、phx.gen.live等を使って爆速でアプリを作れるようになる
しかも Elixirでそんな楽しそうな未来が見えてワクワクしますね
本記事は以上になりますありがとうございました
参考サイト
https://github.com/elixir-desktop/desktop
https://github.com/elixir-desktop/ios-example-app
https://github.com/elixir-desktop/android-example-app
https://qiita.com/rkunihiro/items/433ec719396154f170f2
https://qiita.com/takahirom/items/5e8d7b69e873edb3dcaf
https://typememo.jp/tech/chromium-build-macos-should-be-disable-asdf/
https://tailwindcss.com/docs/guides/phoenix
https://daisyui.com/components/
https://qiita.com/the_haigo/items/22ca888ab2b19b558828
https://qiita.com/torifukukaiou/items/5458458e2ec1bcee5152
https://sakura-bird1.hatenablog.com/entry/20130610/1370864805
https://www.gesource.jp/weblog/?p=7303
https://qiita.com/gksdyd88/items/feeaccdef401ce5644c7
https://hexdocs.pm/phoenix_live_view/0.17.0/js-interop.html#client-hooks
https://developer.mozilla.org/ja/docs/Web/API/Geolocation_API/Using_the_Geolocation_API#examples