専門的な話から趣味の話まで、
様々なテーマでお届け

S2ファクトリーが日々のウェブサイトや
アプリの制作を通じて、
役に立ちそうな技術情報や趣味の話まで
幅広いテーマで発信しています。

2024/11/27

社員旅行向けの Web アプリを開発してみた

社員旅行向けの Web アプリを開発してみたアイキャッチ

目次

01 経緯

2024年7月、社員旅行が実施されることになり、せっかくの機会を広報として活用できないかと議論を重ねました。

02 企画

真っ先に「何かシステムを作ってみようか?」という案が浮上。

「社員旅行のためだけにシステムを作るなんて大変では?」と思われるかもしれませんが、システム開発は弊社の得意分野です。
だからこそイメージも付きやすく、また、利用するユーザー規模が少ないので、いくらでも楽のできる方法も思いつきます実は意外と手軽な選択肢でした。

システムの方向性を考えた際には、次の点を念頭に置きました。

  • 旅程的に自由時間も多く、ある程度のグループはあるものの基本的にはバラバラに動くこともあり、何もないと社員旅行感が生まれないかも
  • 家族参加されるスタッフも多いことから、幅広い年齢層が楽しめる内容が良い

こうした要素を踏まえて、写真投稿でミッションを達成していく「スタンプラリー」型のSNSを作成することに決定。2 泊 3 日の社員旅行のためだけに、フルスクラッチでSNSを構築することになりました。

03 設計

画面をざっくり洗い出す

言い出しっぺでもある、テクニカルディレクターの takano がざっと洗い出しました。


以下は実際のメモ内容です

  • タイムライン
    • インスタっぽく?TikTokっぽく?
    • 「これ以降は既読」が欲しい
  • ミッションリスト
    • すべて
    • 達成済み
    • 未達成
  • ミッション詳細
    • 登録ボタン
      • 新規
      • 追加
      • 変更
    • 他の人の状況と登録写真
  • メンバーリスト
    • アイコン
    • アカウント名
    • スコア
  • メンバー詳細
    • スコア
    • ミッションの状況と登録写真

既視感のあるシステムなので、メモ程度の簡単な整理でも頭の中で全体のイメージができあがりました。
ここから具体的にシステム設計に進みます。

実装方針

システムの設計に取り掛かる前に、まずは全体の方向性や構成を考えることから始めました。

今回のプロジェクトでは、基本的に takano が一人で全て作り切ることを前提に進めることにしました。
普段はバックエンドエンジニアとしての業務が中心ですが、Android のネイティブアプリや Flutter を使ったハイブリッドアプリの開発経験もあるため、選択肢は少ないながらもいくつか考えられます。

フロントエンド

ネイティブアプリでは、スタッフにアプリをインストールしてもらう手間が発生し、手軽に利用してもらえないと判断したため、選択肢から外しました。
そこで、Webアプリケーションとして開発する方向に決め、以下の選択肢を検討しました。

  • React
    • 社内のフロントエンドエンジニアが主に使用しているため、習得すれば今後の業務にも役立つ可能性が高い
  • Kotlin Multiplatform
    • Android開発者として馴染みがあり、個人的に興味もある分野
  • Flutter on the Web
    • これまでネイティブアプリ開発で使ってきた経験があり、Flutter そのものへの好感度が高い

ただし、今回のプロジェクトは短期間での完成が求められるため、React の習得には十分な時間が確保できないと判断。また、Kotlin Multiplatform は現時点ではまだ時期尚早だと考え、消去法で Flutter を選択しました。


バックエンド

フロントエンドで Flutter を使おうと考えた時点で、バックエンドには自然と Firebase を使おうと考えていました。

フロントエンドの実装に注力したかったこともあり、バックエンドの構築に時間をかけられないと判断。また、以前からしっかり Firestore を利用したサービスを作ってみたいという思いもあり、Firebase を採用することにしました(普段は AWS を使用しているため、LambdaDynamoDBS3 でも同様に迅速に作成可能だったとは思いますが、今回はあえて Firebase を選びました)。

写真投稿機能を提供するにあたり、認証は必須だと考え、Firebase Authentication を採用。
データストアには Cloud Firestore を使用し、データベース設計もメモ書き程度にまとめて構想しました。

こちらが実際のメモ内容です。

  • ミッション
    • ID
    • タイトル
    • 説明文
    • ポイント
  • メンバー
    • ID
    • ステータスとかいる?
    • アカウント名
    • 合計ポイント
  • ポスト
    • ID
    • 時間
    • メンバーID
    • ミッションID
    • 画像 base64 ? cloud storage 使ってみる?
    • 写真コメント

洗い出してみることで、データの持ち方が次第にイメージできるようになります。

コメント機能の検討

「他者の投稿にコメントできた方が良いだろう」「リアクションしたくなる場面があるだろう」との考えから、コメント機能の実装を検討。しかし、長年の経験から「そこまで手を広げると間に合わないのでは?」という警告が頭をよぎり、既存のシステムを活用する方針に。

弊社では日頃からコミュニケーションツールとして Slack を利用しており、社員旅行中の連絡にも Slack が想定されていました。

そこで、

  • 写真を投稿すると、 Bot 経由で Slack にも投稿される仕組みを導入し、これを通知機能として活用
  • Slack 上の投稿に対して、リアクションやコメントをつけることで、コメント機能の代替とする

これにより、システムの開発範囲を抑えつつ、必要な機能を補完する形で Slack を活用することを決定しました。

プロトタイプ開発

概ねの設計が終わったところで、まずはざっとプロトタイプを作成します。

今回、このタイミングでプロトタイプを作成した目的は「デザイナーに機能を伝えるため」でした。
慣れないワイヤーフレームを書くより、実際に動く形で見せる方が早いと判断したからです。

このタイミングでプロトタイプをなんとなくでも作成しておくことで、以下のようなポイントを洗い出すことができました。

  • 残りの作業量の把握
  • 実現が難しい機能の別案検討
  • 本実装前に調査が必要な事項の明確化

これにより、今後の作業に対する見通しが立ち、安心感を持って進めることができるようになりました。

プロトタイプの仕様としては、UI は Flutter の Widget をそのまま使用し、装飾は一切なし。
バックエンドはしっかり構築し、 Riverpod を使って状態管理を行いながら Firebase と接続。モックではなく、データ連携が可能な状態になっており、写真の投稿もCloud Storage に保存して閲覧できるところまでは実装しました。

このプロトタイプを基に、次にデザインを社内のデザイナーに依頼します。
ただし、相手も他の案件を抱えており、どの程度の時間がかかるかわからなかったので、ここまではかなり駆け足で進めました。企画立案からプロトタイプ開発完了まで約 2 週間ほどです。

デザイン

アプリのデザインは、社内デザイナーの matsue が手掛けました。


社内案件ならではの自由度を活かし、プロトタイプを通じて実際に触れながら、細部まで丁寧に作り上げていきました。デザインには多くの工夫と遊び心が盛り込まれています。
ここでは、社員旅行アプリに込めた意図やデザインのポイントについてご紹介します。

テーマ

今回のテーマは「シンプルで見やすく、そして楽しく!」

配色は北海道の自然をイメージしました。広がる青空を彷彿とさせる彩度の高い青をメインに、草原を思わせる緑をアクセントカラーに採用。これにより、清々しく爽やかな雰囲気をデザインに取り入れています。
S2 のブランドカラー(ピンク、黄色、紺、黄緑)では普段使用しない色味をあえて採用し、旅行らしい新鮮さと特別感を演出しました。

フォントについてもテーマに合わせて選定しています。
スケジュール画面では、丸みがあり視認性の高い Zen Maru Gothic を採用し、読みやすさを重視。
一方で、ミッション画面には遊び心を取り入れるため、ゲームらしい雰囲気が漂う Dela Gothic One を使用しました。いずれも日本語対応フォントの中から、テーマに合うユニークな書体を選びました。

また、デザインのアクセントとして、S2 のオリジナルキャラクターを随所に大胆に使用しています。社員旅行という遊び心あふれるテーマに合わせ、親しみやすく楽しい雰囲気を感じられる仕上がりになりました。

スケジュール機能

当初の構成にはなかった「スケジュール」機能をデザインに加えました。

北海道出身の社員から目的地や北海道に関する情報を聞き、注意事項や各スポットでできるアクティビティ、場所の簡単な説明などを写真付きで記載しました。次の目的地をイメージしやすいよう構成を工夫しています。

ミッション画面

社員旅行アプリの目玉の一つが「ミッション機能」
この機能は、参加者が旅行中に様々なアクティビティを楽しみながら写真を投稿し、ミッションを達成することでポイントを獲得できる仕組みです。

ミッションの目的は、「全員が共有できる体験」を作り出すこと。例えば、2日目は丸一日自由時間、初日の夜もそれぞれが自由に過ごせる時間があり、個人で行動する場面が多くなる状況でした。そんな中、ミッションを通じて共通の話題や目標を持つことで、一体感を生み出す狙いがあります。

ミッションは、社員旅行前にチームでアイデアを出し合って決定。
例えば「〇〇の写真を撮る」「地元の名物を食べる」といった課題に加え、社員の写真を撮影するというミッションも加えました。これにより、ポイント獲得だけでなく、自然と社員同士の交流が生まれ、思い出として写真に残る仕組みを意識しました。

さらに、ポイントを獲得するだけではなく、何度も投稿したくなるよう、投稿そのものが楽しめるデザインを目指しています。

ミッション画面

04 開発

UI 開発

デザインも決まったので本格的に実装を進めていきます。
今回の開発では「デザイナーにあまり負担をかけたくない」という方針を大前提とし、できるだけ実装側で解決できるよう工夫しました。これにより、デザインの修正や調整にかかる手間を減らしつつ、スムーズな開発を目指しました。

基本的には淡々と実装していくのみですが、特に工夫した点や注力した箇所についていくつかご紹介します。

地味に面倒なヘッダー、フッターのカーブ

カーブ



Figma では Rectangle と Elipse を重ねて書いたようで、私には上手く扱うスキルがありません。

開発画面


「別の方法で描いてもらう」「SVG で書き出してもらう」といった選択肢もありましたが、実装してみること自体が経験になるし、楽しめそうだと思ったので、コードで書いてみることにしました。

ヘッダー

AppBar(
    shape: const HeaderAppBarShape(),
    :
);

class HeaderAppBarShape extends ContinuousRectangleBorder {
  const HeaderAppBarShape();
  final bottomPadding = 12.0;

  @override
  Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
    var path = Path();
    path.moveTo(0, 0);
    path.lineTo(0, rect.height - bottomPadding);

    path.quadraticBezierTo(
        rect.width / 2,
        rect.height - 50,
        rect.width,
        rect.height - bottomPadding
    );
    path.lineTo(rect.width, 0);
    path.lineTo(0, 0);
    path.close();

    return path;
  }
}


フッター

ClipPath(
    clipper: BottomNavigationBackgroundClipper(),
    :
);

class BottomNavigationBackgroundClipper extends CustomClipper<Path> {
  final topPadding = 12.0;

  @override
  getClip(Size size) {
    var path = Path();
    path.moveTo(0, topPadding);
    path.quadraticBezierTo(size.width / 2, 60, size.width, topPadding);

    path.lineTo(size.width, size.height);
    path.lineTo(0, size.height);
    path.lineTo(0, topPadding);
    path.close();
    return path;
  }

  @override
  bool shouldReclip(BottomNavigationBackgroundClipper oldClipper) => false;
}

改めて見るとかなり雑ですが、これで実現できました。

スケジュール表記

よく見かけるタイプのUIですが、いざ実装となるとどう組み立てるか一瞬考えさせられます。
さらに、スケジュールはギリギリまで変更が入る可能性があるため、変更に対応しやすい形で設計する必要がありました。

スケジュール


よく見ると 9:20~9:30 の間隔と 9:30~10:00 の間隔が違うのでこれも考慮しつつ、MEMO のように長文が入る場合は、デバイスの幅によって高さ(行数)が変化するため、左に走る縦線も右側の要素の高さに合わせて自動で伸びるようにしたいと考えました。

こうした点を踏まえ、基本的には Table を活用しつつ、各パーツを関数化して編集が容易な形で実装を進めました。

Table(
    defaultColumnWidth: const IntrinsicColumnWidth(),
    columnWidths: const {
      2: FlexColumnWidth(1),
    },
    children: [
      scheduleTimeStart("9:20", "ホテルのロビーに集合"),
      scheduleSpacer(24),
      scheduleTime("9:30", "ホテル出発"),
      scheduleSpacer(40),
      scheduleTime("10:00 - 11:00", "観光2"),
      scheduleSpacer(24),
      scheduleContents([
        scheduleContentTitleWithLink("場外市場", "https://www.jyogaishijyo.com/"),
        const SizedBox(height: 16),
        Text("個性いっぱいの60店舗!", style: CustomTextStyles.scheduleBody),
        const SizedBox(height: 32),
        const ScheduleTag(title: "MEMO"),
        const SizedBox(height: 16),
        Text(
            """ツアーというほど時間もないですが海産物を買い漁ったりしてみてください
食事を狙うのもあり(混み具合をよく見てね)(あとすぐ昼食だよ)""",
            style: CustomTextStyles.scheduleBody,
        ),
      ]),
      scheduleSpacer(40),
      scheduleTime("11:30 - 13:15", "お昼と観光3"),
      :
    ],
);


関数の中身は例えばこんな感じです。

TableRow scheduleTime(String time, String title, {RichText? richText}) {
  return TableRow(
    children: [
      TableCell(
        verticalAlignment: TableCellVerticalAlignment.fill,
        child: Stack(
          alignment: Alignment.center,
          children: [
            Positioned(
              top: 0,
              bottom: 0,
              child: Container(
                width: 4,
                color: CustomColors.scheduleBar,
              ),
            ),
            Positioned(
              top: 4,
              child: Container(
                width: 16,
                height: 16,
                decoration: const ShapeDecoration(
                  color: CustomColors.scheduleDot,
                  shape: OvalBorder(),
                ),
              ),
            ),
          ],
        ),
      ),
      const SizedBox(width: 8),
      Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(time, style: CustomTextStyles.scheduleTime),
          const SizedBox(width: 16),
          richText ?? Text(title, style: CustomTextStyles.scheduleTimeTitle),
        ],
      ),
    ],
  );
}

システム開発

Firestore

最終的に、以下のような形で Entity を定義し、Firestore に保存しています。

// ミッション情報
class Mission {
  final String id;
  final String title;    // 撮影するお題
  final String category; // カテゴリー名("FOOD", "PLACE" など)
  final int point;       // 達成時の獲得ポイント数
  final int sortKey;     // 並び替え用のキー
}
// メンバー情報
class Member {
  final String id;
  final String accountName;             // アカウント名(表示名)
  final int totalPoint;                 // 総獲得ポイント数
  final Map<String, int> categoryPoint; // 各カテゴリーごとの獲得ポイント数
}

ユーザーアクションによってのみポイントが変動するため、都度集計するのではなく、増減値を直接保持する形にしました。

各カテゴリーごとにランキングを集計して表示するために、categoryPoint を設けています。
totalPoint の増減に合わせて categoryPoint も編集する設計にしました(後から考えると categoryPoint だけで全体のポイントも表現できるため、totalPoint は不要だったかもしれません)。

// メンバーごとのミッション達成状況
typedef MemberMission = Map<String, int?>;

投稿の削除が可能であり、同じミッションに複数回投稿できる仕様のため、以下の設計を採用しています。

  • 各メンバーごとに、そのミッションに対して何回投稿しているかをカウント
  • カウントの変化に応じてポイントを増減
    • 0 ⇨ 1 に変化した際:ポイントを増加
    • 1 ⇨ 0 に変化した際:ポイントを減少
  • このカウントは、ミッションの達成状況(フラグ)としても利用
// 投稿情報
class Post {
  final String id;

  final String memberId;        // 投稿したメンバーのID
  final String accountName;     // 投稿したメンバーの表示名

  final String missionId;       // 投稿対象のミッションID
  final String title;           // 投稿対象のミッションのお題名
  final String category;        // 投稿対象のミッションのカテゴリー名
  final int point;              // 投稿対象のミッションの達成時の獲得ポイント

  final DateTime createdAt;     // 投稿日時(タイムラインでのソートキーになる)
  final List<PostImage> images; // 画像の情報
  final String comment;         // 画像に対する投稿者のコメント
  final String? slackTs;        // 紐づいた Slack の timestamp
}

// 投稿画像情報
class PostImage {
  final String mainUrl;         // メイン画像の公開URL
  final String thumbnailUrl;    // サムネイル画像の公開URL
  final String path;            // Cloud Storage のパス
  final int width;              // 画像の幅
  final int height;             // 画像の高さ
}

前半はすべて Mission と Member の値をコピーしています。

今回のシステムでは、利用開始後にアカウント名やミッション情報が編集されることはありません。そのため、投稿情報を表示するたびに他のコレクションを参照するよりも、必要なデータをコピーして保持する方が、高速かつシンプルです。

画像データは Cloud Storage に保存し、以下の工夫を取り入れました。

  • パフォーマンス最適化
    ダウンロード用の URL を発行し、投稿データに格納することで、画像取得を効率化
  • 認証への配慮
    認証機能を利用していますが、発行される URL は推測が難しい形式のため、セキュリティ面も問題ないと判断

さらに、投稿データには widthheight を保持する設計に。
画像のアスペクト比を元に、読み込み中のプレースホルダー表示で正確なサイズを確保できます。これにより、画面がガタガタすることなく、スムーズなレイアウトが実現します。

slackTs は、うっかり投稿すべきでない写真を選択してしまった場合、投稿後に削除しても Slack 上に残る問題が発生する可能性がありました。
そこで、Slack の投稿時に得られるタイムスタンプを記録し、投稿が削除された際には Slack 上の投稿も連動して削除するように実装しました。

これらの設計により、投稿の表示速度、画像の読み込み体験、そして Slack 連携の安全性と利便性を高めることができました。

ちなみにルールは想像の通りですが、

Firestore

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {
  	function requireS2FDomain() {
  		return request.auth != null && request.auth.token.email.matches('.*@s2factory[.]co[.]jp$');
    }
    
    match /member_mission/{userId} {
      allow write: if requireS2FDomain() && request.auth.uid == userId;
      allow read: if requireS2FDomain();
    }
    match /members/{userId} {
      allow write: if requireS2FDomain() && request.auth.uid == userId;
      allow read: if requireS2FDomain();
    }
    match /missions/{document=**} {
      allow write: if false;
      allow read: if requireS2FDomain();
    }
    match /posts/{document=**} {
      allow update, delete: if requireS2FDomain() && resource.data.memberId == request.auth.uid;
      allow read, create: if requireS2FDomain();
    }
  }
}


Storage

rules_version = '2';

service firebase.storage {
  match /b/{bucket}/o {
  	function requireS2FDomain() {
  		return request.auth != null && request.auth.token.email.matches('.*@s2factory[.]co[.]jp$');
    }
    
    match /{userId}/{allPaths=**} {
      allow write: if requireS2FDomain() && request.auth.uid == userId;
      allow read: if requireS2FDomain();
    }
  }
}

requireS2FDomain によって、特定ドメインのメールアドレスを持つ認証済みアカウントのみアクセスを許可

Google Workspace を利用して特定のドメインで絞り込む方法も考えられますが、今回は Workspace を使っていない組織でも同様のことが実現できるか試す意図もあり、別の形で実装しました。

投稿時の処理フロー

最も肝となる投稿時の流れは以下の通りです。

  1. ログイン状態のチェック
  2. 写真のアップロード(複数)
    1. 画像の幅と高さを取得
    2. メインサイズとサムネイルサイズに縮小
    3. それぞれ Cloud Storage へアップロード
    4. ダウンロードURLの取得
  3. MemberMission を取得し投稿済みかどうかをチェック
  4. 未投稿であれば Member の totalPoint と categoryPoint を加算
  5. MemberMission を更新
  6. Post を作成

処理が重くなりがちな部分には Future を使用し、並列化して処理時間を短縮。ユーザーが待てる範囲の速度を維持しました。
3~6 の Firestore への書き込みはトランザクション処理を利用して一貫性を確保。初めての利用でしたが、実装がシンプルで非常に使いやすいと感じました。

Slack への投稿

Slack への投稿はそれほど難しくありませんが、アクセストークンを付与したリクエストを行う必要があります。
社内で使うものなので、フロントエンドにアクセストークンを持たせても問題ないのですが、普段からセキュリティには注意を払っているため、たとえ社内向けのアプリでもそれは気持ち悪く感じてしまいます。

しかし、バックエンドを実装しないというポリシーで開発を進めてきたため、アクセストークンをどこに隠蔽するかを検討。
考えた末、以下のようにプロキシを利用することにしました。

# Slack API
SetEnvIf Request_URI "^/slack-notification/" IS_SLACK
RequestHeader SET host "slack.com" env=IS_SLACK
RequestHeader SET authorization "Bearer xoxb-*******" env=IS_SLACK
RewriteRule ^slack-notification/(.*) https://slack.com/api/$1 [P,L]

社内の認証で保護されたサーバー上で運用しているため、不正なアクセスができない構造になっています。
この方法でアクセストークンを安全に隠蔽しつつ、Slack への投稿機能を実現しました。

05 完成

完成したものがこちらです。

スケジュール画面
ミッション
ミッション
タイムライン
メンバー

06 まとめ

みんな楽しんでくれたようで、評判は上々でした。

最終日には、ミッションの獲得ポイントを集計し、上位3名を表彰カテゴリーごとのランキングトップと総合得点トップの参加者に景品を授与し、社員旅行を締めくくりました。

賞品の木彫りの熊
賞品の木彫りの熊


サービスは旅行後も残しておき、参加者からは「思い出を振り返ったり、家族に旅行の話をするときに便利だった」といった声も寄せられました。

普段の業務では、利用者の顔が直接見えない場面での開発が多い中、今回は「自分が作ったものを便利に楽しく使ってもらう」というモノづくりの醍醐味を久しぶりに実感でき、やってよかったと感じています。

ちなみに、Google Cloud のインフラ費用は、開発・テスト期間も含めて税込 15 円(工夫の甲斐あってか、想像以上に安く済みました)。
一方で、バックエンドを実装しない方針にこだわりすぎた結果、無理をした部分もあり、その影響でいくつかのバグが発生してしまいました。次回は方針を少し緩め、バックエンドを取り入れることも検討しようと思います。

また、自分も参加者として旅行に参加する中で、出先での改修が難しい場面も多く、変更が発生しそうな部分については、あらかじめ CMS を用意しておくべきだったというのが反省です。

今回は、開発スピードを重視し、デザイン以外は一人で手掛けるという制約のもとで進めたため、内容的にはプロダクト品質には程遠いものでしたが、それでも何かの参考になれば幸いです。

また、こうしたアプリの開発に興味がある方やご相談をご希望の方は、ぜひお気軽にお問い合わせください。私たちが全力でサポートいたします。

S2ファクトリー株式会社

様々な分野のスペシャリストが集まり、Webサイトやスマートフォンアプリの企画・設計から制作、システム開発、インフラ構築・運用などの業務を行っているウェブ制作会社です。

実績

案件のご依頼、ご相談、その他ご質問はこちらからお問い合わせください。

案件のご依頼、ご相談、
その他ご質問は
こちらから
お問い合わせください。

様々な分野のスペシャリストがお客様と
ともに「できそう」を導き出します。

S2ファクトリー株式会社

様々な分野のスペシャリストが集まり、Webサイトやスマートフォンアプリの企画・設計から制作、システム開発、インフラ構築・運用などの業務を行っているウェブ制作会社です。

実績

案件のご依頼、ご相談、その他ご質問はこちらからお問い合わせください。

目次