🦍

LINE内で完結し、位置情報取得とネイティブアプリ風のUXを提供するWebアプリを個人開発した話

2024/12/12に公開3

私がリリースした筋トレ共有アプリ「ジムトモ」

本記事は LINEDC Advent Calendar 2024の12日目の記事です。

嘘がつけない筋トレ共有アプリ、ジムトモをリリースしました!
ジムトモは週に何回ジムに通うのか目標を設定し、友達と進捗を共有するアプリです。

位置情報を活用し、通っているジムの半径100m以内に居るときのみ記録できる仕様にしています。
なので、友達にサボっていると思われたくないから嘘の記録を書く。そんなことは出来ません。

他にも、絶対に継続したい人向けの機能ですが、目標未達成時の罰金を設定することも出来ます。

この仕組みによって、離れていても友達と一緒にジム通いを継続させるアプリです。

週の目標結果 みんなの記録 グループに招待

https://www.gymtomo.com?from=zenn

アプリを開発したきっかけ

私は以前から、筋トレ布教活動に取り組んでおり、その過程で分かった驚愕の真実があります。

それは、一人で黙々とジムに通い続けるのは難しいということです。

重いプログラミングよりも重いダンベルが好きな私は、なぜジムに通えない人類が存在するのか理解できませんでしたが、それが普通であると最近理解しました。

「職場の同僚や友達と一緒ならジムに行けるが、一人ではついサボってしまう。。。」
そんな方が多いのではないでしょうか?

そこで、1人でジム通いを継続できる人を増やすために、筋トレ記録共有アプリを作ろうと思いました。

技術スタック

技術の勉強ではなく、誰かの役に立つサービスを作るのを主目的としている個人開発では、極力めんどくさいことを避けて素早く作っていくことが大切だと考えてます。
そうしないと完成までもっていけず、途中で頓挫してしまうからです。

なので、私は自分が慣れている技術や手軽で簡単な技術を選定することを重視しています。
勿論、新しいことを学ぶのは楽しいので、触ったことがない技術も取り入れてしまいますが、過剰になりすぎないように自分を抑えています。

Next.js15(App Router)

私は1つのフレームワークでフルスタックに個人開発したいと考えてます。理由は言うまでもなく、手軽だからです。

初めて触ったフレームワークということもあり、個人開発ではLaravelをずっと使ってきました。業務でLaravel×MPAは避けたいですが、個人開発だとコントローラーでデータ取得して、テンプレートエンジンへサクッと流し込む手軽さが丁度良いです。

ですが、Next.jsのApp Routerを触ってみるとMPAのメンタルモデルに近く、非常に好感触だったので、今回はキャッチアップも兼ねてNext.jsで作ってみました。

LINE LIFF

https://developers.line.biz/ja/docs/liff/overview

LIFFはLINEのトークルーム内で機能するWebアプリです。LINEのユーザーIDなどをLINEプラットフォームから取得できるので、ログインレスなサービスを作成できます。

Auth.js (NextAuth.js)

LIFFログインでIDトークンを発行し、そのsubを用いてAuth.jsへ詰め替えてます。
詰め替えた理由は、LINE LIFFのライブラリがServer Componentに対応していなかったからです。
Server ComponentやServer Actionを活用したかったので、Auth.jsのCredentialsProviderを活用して独自のIDトークンを発行しました。
(需要があるなら、詳細は別記事に書きたいと思います)

Vercel

選定理由はデプロイするのが楽だからです。
AWSのラッパーと揶揄され、ぼったくりと言われることもありますが、何も考えずにデプロイ出来るので素晴らしいと思います。
完成させるのに必死なので、GCPやAWSにデプロイする余力はないです。

Supabase

下記の3つ使ってます。

  • Database
  • Storage
  • Edge Functions

初回ログイン時にユーザーのLINEのプロフィール画像をStorageへ保存しています。
Next.jsで同期処理として実行してしまうとAPIレスポンスが遅延し、ログインのUXが悪化するため、Edge Functionを呼び出して非同期に実行しています。

LINEから取得した画像URLを使わずにStorageへ保存しているのは、LINEから取得した画像はLINEのCDNから配信されているので、画像URLに変更が入ったりして、表示されなくなるのを避けるためです。

Prisma

supabase clientは使わずにprismaを使っています。
prismaを用いてデータ取得は基本的にサーバーコンポーネントで行っています。
データ取得のために都度APIを作成する必要がなく快適でした。

tailwind.css

業務でも使っていて慣れているので採用しました。

shadcn/ui

tailwindと相性がよく、カスタマイズが容易と聞いたので使ってみました。

Sentry

本番環境でデバッグして改善するのが容易になるので、面倒くさがらずに初期から入れておいた方が良いです。

こだわりポイント

こだわったことは主に3つです。

  • 嘘の記録をできないようにする
  • 超簡単にジムに行った記録ができる
  • LINEだけで完結する

嘘がつけない仕組みが必要

ジムトモには週の目標を設定し、その達成or未達成をLINEで通知する機能があります。

そこで、週2回ジムに行くことを友達と約束したとします。
でも、さっそく今週はまだ1回しか行けていない。明日は行くつもりだが、今週は未達成になってしまう。
そんな時、「明日行くのだから、今日行ったことにして先に記録をつけておこう…」となってしまいませんか?
そんな人間の弱い心を防ぐためには、嘘がつけない仕組みが必要だと思いました。

そのため、通っているジムの半径100メートル以内に居る時のみ記録できる仕組みにしました。

超簡単に記録ができる

ジムトモでは、ラインからワンクリックで記録ができます。

簡単さにこだわったのは、私自身が超絶めんどくさがり屋だがらです。
私のような人間はそもそも記録しなくなると思ったので、ワンクリックで記録できることを重視しました。

ワンクリックで記録&位置情報取得の技術

ジムトモでは、ラインからワンクリックで半径100m以内のジムにチェックインできます。

この機能の仕組みをざっくり説明すると、下記の流れになります。

  1. 事前に通っているジムを登録してもらう。この際に、そのジムの緯度・経度を保存する。
  2. チェックインのボタンを押した際に、現在地の緯度と経度を取得する。
  3. ヒュベニの公式を用いて、現在地とジムの緯度と経度から2点間の距離を求める。
  4. 2点間の距離が100m以内ならチェックインする。

通っているジムを登録する


Googleの「Place Autocomplete」と「Place Details」の2つのAPIを使っています。

ジムの検索でAutocompleteを活用しており、optionでtypeを指定することが可能なので、gymで絞り込んでいます。
そして、表示された一覧から、登録したいジムをクリックした際にDetailsを叩いて、そのジムの緯度・経度を取得しています。

ちなみに、Autocompleteのライブラリは色々ありますが、私はreact-google-mapsを使いました。比較的新しいライブラリですが、Googleの公式サイトで紹介されていた&ドキュメントが分かりやすかったので使ってみました。

現在地の緯度と経度を取得する

WebブラウザにはGeolocation APIが実装されているので、これを利用しています。

そのまま使うとかなり複雑になりそうなので、react-geolocatedというライブラリを利用しました。

緯度と経度から2点間の距離

Googleの「Distance Matrix API」で2点間の距離を求められると知っていましたが、これは使いませんでした。

チェックインの際は速度が大切なので、外部APIへリクエストしたくないと考えていました。
そして、緯度と経度が既に分かっているなら、外部APIへリクエストせずとも2点間の距離を求める公式が存在するだろうと考え、その方針で進めました。

予想通り、2点間の距離を求める公式は複数ありました。そして、以下の理由からヒュベニの公式を使うことにしました。

  • 他と比べて公式が簡単
  • 長距離の場合は誤差が出るらしいが、今回の要件は2点間が100m前後の際に正確に計測出来れば良いので問題なし

位置情報取得の権限で悩んだこと

LINEへの許可 ブラウザ上での許可

ジムトモはLIFFブラウザ上で動いています。

LIFFブラウザから位置情報を取得するには、2つの権限を許可してもらう必要がありました。

  1. LINEアプリに対して位置情報取得を許可
  2. ブラウザ上で位置情報を取得する際に、ダイアログに上で許可する。

サービスのコンセプト上、位置情報許可のUX体験を磨くことはかなり重要です。
ですので、権限エラーの際にどっちが原因なのかによって、ユーザーへの案内文言を切り分けようとしました。
しかし、プログラムで判別することは出来なかったので悩みました。

Permissions APIを用いて判別する案を途中で思いつき希望に満ち溢れましたが、WebView Androidが未実装ということを知り、 奈落の底に突き落とされました。
(LIFFブラウザは、iOSではWKWebView、AndroidではAndroid WebViewを利用している)

因果関係を特定できないなら、相関関係に基づいて切り分ける

因果関係を特定できないなら、相関関係に基づいてユーザーに表示する文言を切り分ける作戦で対処しました。

具体的には権限エラーの場合、LINEアプリに対して未許可である可能性が圧倒的に高いと判断し、決め打ちで文言を表示することにしました。

  • LINEアプリに対して未許可の場合は、ブラウザ上のダイアログはそもそも表示されない。
  • LINEアプリに許可後、ブラウザ上で拒否した場合、リロードすると再度ダイアログが表示される。

よって、ブラウザ上で拒否したことによる権限エラーの頻度は少ない&ユーザー自身で容易に復帰可能だと考え、上記の判断をしました。

これで一件落着かと思えば、もう1つ問題がありました。

正確な位置情報の許可

端末の設定からLINEアプリに対して位置情報を許可する際に、「正確な位置情報」というオプションが存在します。
これがオフの場合、実際の現在地点から2~3km程離れた地点が取得されてしまいます。
ジムトモでは半径100m以内の時のみ、チェックインできるのでこれは大問題です。

これに対して、チェックインボタンを押した際に、ジムとの距離が1000m以上であるなら、「正確な位置情報」がオフである可能性が高いと判断し、決め打ちで案内文言を表示する仕様にすることで対処しました。(ジムから1000m離れているのにチェックインボタンを押すことは普通はないはず)

なぜLINEか?

LINEを選んだ理由は2つです。

1つはLINEだとアプリインストールが不要で手軽だからです。
個人で利用するアプリならまだしも、知り合い複数名で利用するアプリなので、全員にインストールさせる必要があるネイティブアプリだと初期導入のハードルが高すぎると思いました。

2つはLINEでジムに行ったことを、親しい友だちへ共有する体験を擬似的に再現したかったからです。
私の身の回りの友達やネット記事でも、友だち数人でLINEグループ作り、そのグループで報告し合うことでモチベーションを維持している事例を観測していました。
なのでそのプロセスの一部をより便利に改善することで、現状LINEでジム通いを報告し合っている人に使ってもらえるのでは?と考えました。

ネイティブアプリにUXを寄せる際の苦労

画面遷移の履歴

Webとネイティブアプリではページ遷移の履歴が大きくことなります。

Webではページ遷移の履歴は直線的に積み上がっていきますが、ネイティブアプリではそうではなく、ボトムナビゲーションごとに管理されています。

この差があるので、Webでボトムナビゲーションを取り入れると、どうしても履歴が変になる箇所がいっぱいありました。
かなり工夫した箇所なので詳しく書きたいのですが、そもそもSafariやChromeなどの通常のブラウザとLIFFブラウザでも仕様が異なる箇所があり、話がややこしすぎる上、需要がほぼないと思うので簡単な説明に留めておきます。

  • ボトムナビゲーションはLinkタグではなく、Buttonでrouter.replaceの遷移にする。
  • router.push、router.replace、router.backを駆使する
  • 画面遷移を避けるために、ボトムシートを使って1つのページで複数の操作を出来るようにする

魔改造しているので最初の動作確認では、古いデータが表示されたり&真っ白な画面が表示されてしまったり散々な状態になってました。
藁にもすがる思いでNext.js15にアプデすると正常に動作したので、Router Cacheが原因だったと思ってます。

ボトムナビゲーションの実装
// router.pushで遷移すると、ボトムナビゲーション間の遷移が履歴に残ってしまう
// 代わりにrouter.replace&router.prefetch
function NavItem({ url }: NavItemProps) {
  const router = useRouter();

  useEffect(() => {
    const prefetchUrl = () => {
        router.prefetch(url);
    };
    prefetchUrl();
  }, [url, router]);

  const handleClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
    e.preventDefault();
    router.replace(url);
  };

  return (
    <button onClick={handleClick}>
      <span>アイコン</span>
    </button>
  );
}

セーフエリア

セーフエリア内にコンテンツを表示しないと、画面下部のホームインジケーターに被ってしまいます。
これに対応させるめには、env(safe-area-inset-*)で表示端末のセーフエリア領域を取得する or tailwindcss-safe-area などのライブラリを使えば対応できます。

before after

引っ張って更新する機能(Pull-to-Refresh)

Webブラウザには、下に画面を引っ張ってリロードする機能が備わっています。

Webview(LIFFブラウザ)では、このリロード機能が封じられているにも関わらず、上下に引っ張ることができる挙動だけが残っています。
これによって、コンテンツ量が少ないページにおいても、スクロールすると画面が上下に動いてしまう挙動があったので対処しました。

bodyタグにoverflow-hiddenを付与することで完全に固定することが出来ました。

RootLayoutの実装
export default async function RootLayout({
  children,
}: Readonly<{
  children: ReactNode;
}>) {
  return (
    <html lang="ja">
      <body className={`${inter.className} overflow-hidden`}>
        <div className="mx-auto h-screen max-w-screen-sm px-3 pt-4">
          <div className="h-full overflow-y-auto">{children}</div>
          {!!GA_ID && <GoogleAnalytics gaId={GA_ID} />}
        </div>
      </body>
    </html>
  );
}

おわりに

筋肉エンジニアの方へ

ぜひ、職場の同僚や友達を巻き込んでご利用ください!
機能要望や改善点などを教えていただけると嬉しいです。

Xにて合トレ(都内)のお誘いお待ちしてます!

Discussion

tokectokec

「Next.jsのApp Routerを触ってみるとMPAのメンタルモデルに近く、非常に好感触」の部分とても共感です!僕もLaravelから入ったので余計に共感。とても、似てるよなーと思いながら触ってます。
LINE上で動くいわゆるミニアプリの需要これから来そうですね!たけしさんを見習い作ってみたいと思いました!

たけしたけし

コメント嬉しいです!
記事を読んでいただき、ありがとうございます!

NAORIKUNAORIKU

とても興味深く拝見させていただきました!!

Server ComponentやServer Actionを活用したかったので、Auth.jsのCredentialsProviderを活用して独自のIDトークンを発行しました。
(需要があるなら、詳細は別記事に書きたいと思います)

Auth.jsを活用した実装について、どのような流れでクライアントのLIFF SDKが出力した認証情報を取り扱っているか大変興味があります 👀
自分も過去に似たような実装をしたことがあり、クライアント側でのsignIn処理で redirect: falseにするなど工夫がいるなーと感じていました 🤔

await signIn('credentials',  { accessToken: liff.getAccessToken(), redirect: false });

LINEを活用したプロダクト設計もとても参考になりました!大変有用な記事ありがとうございました!!