メインコンテンツへスキップ
見出し画像

【1つのコードでアプリもWebも】Flutter×Supabaseで作る1on1予約管理システム

    📌 この記事では、Flutterを使って「モバイルで予約・Webで管理」する1on1予約管理システムを作りながら、Flutter × Dart × Supabaseの組み合わせで何ができるのかを実践的に紹介します。上司との1on1だけでなく、コーチング予約やお客様とのアポイント調整にも応用できる汎用設計を目指します。

    はじめに

    画像

    🎀 Erica「上司との1on1、日程調整って地味に面倒じゃない?Slackで『いつが空いてます?』って聞かれて、カレンダー見ながら『じゃあ火曜の午後で』みたいなやり取り、結構時間取られるのよね。今回はそれをFlutterとSupabaseで解決してみたよ」

    こういった日程調整の手間は、1on1に限らずコーチングの予約やお客様とのアポイント取得でもよくある悩みです。今回は「候補日時を自動で出して、選んでもらって、確定する」というシンプルな仕組みを、実際に手を動かしながら作ってみました。

    エンジニア初級〜中級の方に向けて、なぜFlutterを学ぶ価値があるのか、実際にどう設計・実装していくのかを、この1on1予約管理システムを題材に順を追って解説していきます。

    📝 Flutterとは?なぜ今学ぶべきか

    🔹 1つのコードでiOS・Android・Web・デスクトップを同時開発できる仕組み

    Flutter開発とは、Googleが開発したオープンソースのフレームワーク「Flutter」を使い、1つのプログラムコード(コードベース)だけでiOS・Android・Web・デスクトップ向けのアプリを同時に作成する開発手法です。

    たとえば今回作る1on1予約管理システムのように「予約者はスマホアプリで操作し、管理者はPCのWeb画面で操作する」という要件があった場合、従来であればモバイルアプリとWebアプリをそれぞれ別の技術で開発する必要がありました。Flutterであれば、同じコードベースをベースにモバイル向けとWeb向けをビルドし分けることができます。

    🔹 Flutter案件が増えている背景

    今の時代、ほとんどのワーキングパーソンはスマホで仕事を探し、転職活動を行っています。人材マネジメント企業を含め、企業側も優秀な人材に早くアクセスしたいというニーズを強めており、採用・人材系のプロダクトはスマホファーストで作られることが当たり前になっています。

    一方で管理側の業務は依然としてPCのWeb画面で行われることが多く、「求職者・応募者はスマホ、企業の管理担当者はPC」というマルチデバイス対応が前提のシステムが増え続けています。これはまさに今回作る1on1予約管理システムの構造(予約者はモバイル、管理者はWeb)と同じです。

    筆者は転職エージェントの案件リサーチを定期的に行っていますが、こうしたマルチデバイス対応が求められる開発案件において、Flutterが選ばれるケースが増えている実感があります。1つのコードベースでモバイルとWebを同時にカバーできるという特性が、まさにこの「スマホの求職者×PCの管理側」という構造にフィットしているためです。

    また、Flutterは一企業や個人が趣味で作っているフレームワークではなく、Googleが開発・継続的にメンテナンスしているオープンソースフレームワークです。大手企業がバックにいることで、長期的なサポートやエコシステムの安定性という面でも安心して選びやすい技術と言えます。

    🔹 他のクロスプラットフォーム手法との比較

    React NativeやKotlin Multiplatformといった選択肢もありますが、FlutterはUIコンポーネントを独自に描画する仕組みを持っているため、プラットフォームごとの見た目の差異が少なく、デザインの再現性が高いという特徴があります。今回のようにモバイルとWebで一貫したUI・ロジックを保ちたいケースでは、この特徴が活きてきます。

    📝 開発環境:VS Code + Claude Codeで指示ベース開発

    🔹 VS Code + 拡張機能で環境構築は数分で完了

    Flutter開発を始めるのに、大がかりな環境構築は必要ありません。VS Codeに「Flutter」「Dart」の拡張機能を入れるだけで、Hot Reloadボタンやデバッグ機能がエディタに統合され、すぐに開発を始められます。

    🔹 コードを書くのではなく、指示を出して組み上げる

    今回の1on1予約管理システムは、VS Code上でClaude Codeを併用しながら「指示ベース」で作っていきました。たとえば3×3マトリクスUIも、実際は次のような指示から実装がスタートしています。

    「直近3営業日×9時・13時・16時で9個のチェックボックスを、縦に時間・横に日付のテーブル形式で表示して。3個選択したら残りをグレーアウトするようにして」

    このように、実装したいUIや挙動を具体的な言葉で指示すると、Claude Codeがウィジェットの骨組みからロジックまで一気に組み立ててくれます。細かい構文を逐一調べながら手で書いていくスタイルに比べて、圧倒的にスピードが違います。

    🔹 地味に時間がかかる作業ほど任せる価値がある

    UIの実装だけでなく、SupabaseのテーブルスキーマとFlutter側のモデルクラスの整合性チェックや、エラーメッセージから原因を特定する作業など、「動作は理解できるが手を動かすと地味に時間がかかる」タスクほど、Claude Codeに任せるメリットが大きくなります。今回の開発でも、テーブル設計を先に固めてからClaude Codeにモデルクラスの生成を指示する、という流れで進めています。

    📝 Dart言語のやさしさと開発体験

    🔹 他言語経験者にも読みやすい構文

    DartはJavaやC#、TypeScriptなどを触ったことがあるエンジニアであれば、比較的スムーズに読み書きできる構文になっています。クラスベースのオブジェクト指向、null安全(null safety)による堅牢性など、モダンな言語設計を採用している点も学習コストを下げてくれるポイントです。

    🔹 Hot Reloadで変更が即反映される開発体験

    Flutter開発の大きな魅力の一つがHot Reloadです。コードを変更して保存するだけで、アプリを再起動せずに変更内容が画面に即座に反映されます。今回の予約システムのように、UIの微調整を繰り返しながら作り込んでいく開発スタイルとは特に相性が良く、体感的な開発スピードが大きく変わります。

    🔹 初級者がつまずきやすい箇所

    null安全の書き方(?や!の使い分け)や、非同期処理(Future、async/await)まわりは、最初はつまずきやすいポイントです。ただしいずれも一度パターンを覚えてしまえば、他の言語での非同期処理の理解にも応用できる知識になります。

    📝 題材:1on1予約管理システムを作る

    🔹 全体フロー

    今回作るシステムの流れはシンプルです。

    ① 候補日時を自動生成(直近3営業日×9時・13時・16時=9枠) ② 予約者がモバイルアプリで候補から3つまで選択 ③ 管理者がWeb管理画面で選択結果を確認し、最終日程を確定 ④ 確定した日程を予約者に通知

    この4ステップを、Flutterの1つのコードベースからモバイル・Web両方の画面を作り、Supabaseでデータをつなぐことで実現します。

    🔹 候補日時のロジック:直近3営業日×3枠

    候補日時は手動で登録するのではなく、システム側で自動生成します。直近の営業日(土日を除く)を3日分取得し、それぞれに9時・13時・16時の3つの時間帯を掛け合わせることで、合計9個の候補枠が生成されます。

    「候補日を管理者が毎回手入力する」という運用は地味に手間がかかるため、この自動生成の仕組みによって、管理者側の負担をゼロに近づけられる設計にしています。

    🔹 なぜこの題材がFlutter学習に向いているか

    この1on1予約管理システムは、Flutter学習の題材として理にかなっています。理由は「予約者向けのモバイル画面」と「管理者向けのWeb画面」という、性質の異なる2つのUIを同じロジック・同じコードベースの上に作る必要があるからです。

    • モバイル側:直感的なタップ操作でのチェックボックス選択

    • Web側:一覧性の高いテーブル表示と確定操作

    この2つを別々の技術スタックで作ると、ロジックの二重管理や仕様のズレが発生しやすくなります。Flutterであれば、候補日時生成のロジックやデータモデルを共通化しつつ、UIだけをそれぞれのデバイスに最適化する、という開発が可能です。

    🔹 上司との1on1だけでなく、幅広い日程調整に応用できる汎用設計

    今回は「上司と部下の1on1」を題材にしていますが、この仕組みはコーチングの予約や、お客様とのアポイント取得にもそのまま応用できます。「候補を出す側」と「選ぶ側」がいて、最終的に管理者・運営側が確定する、という構造自体は業種を問わず共通しているためです。

    📝 モバイル側の設計(予約者向け)

    「題材」セクションで触れた通り、このパートでは「予約者が直感的にタップで選べる」というモバイルならではのUIを、Claude Codeへの指示ベースで組み上げていく様子を見ていきます。

    🔹 候補日時の自動生成ロジック

    まず、直近の営業日3日分を求めるロジックです。土日を除外しながら、当日から順に日付をカウントしていきます。

    List<DateTime> getNextBusinessDays(int count) {
      final List<DateTime> businessDays = [];
      DateTime current = DateTime.now().add(const Duration(days: 1));
    
      while (businessDays.length < count) {
        // 土曜(6)・日曜(7)を除外
        if (current.weekday != DateTime.saturday &&
            current.weekday != DateTime.sunday) {
          businessDays.add(current);
        }
        current = current.add(const Duration(days: 1));
      }
      return businessDays;
    }
    

    この3日分の日付と、固定の時間帯(9時・13時・16時)を掛け合わせて、9個の候補枠を作ります。管理者側で手入力する必要がない、という点がこのロジックの狙いでした。

    class TimeSlot {
      final DateTime date;
      final int hour;
      bool isSelected;
    
      TimeSlot({required this.date, required this.hour, this.isSelected = false});
    }
    
    List<TimeSlot> generateTimeSlots() {
      final days = getNextBusinessDays(3);
      const hours = [9, 13, 16];
    
      return [
        for (final day in days)
          for (final hour in hours)
            TimeSlot(date: day, hour: hour),
      ];
    }
    

    🔹 3×3マトリクス表とチェックボックスの実装

    実際にこのUIは、Claude Codeに「直近3営業日×9時・13時・16時で9個のチェックボックスを、縦に時間・横に日付のテーブル形式で表示して。3個選択したら残りをグレーアウトするようにして」と指示するところから実装がスタートしています。生成されたTimeSlotのリストを、縦=時間・横=日付のテーブル状に表示します。

    画像
    class BookingSlotSelector extends StatefulWidget {
      const BookingSlotSelector({super.key});
    
      @override
      State<BookingSlotSelector> createState() => _BookingSlotSelectorState();
    }
    
    class _BookingSlotSelectorState extends State<BookingSlotSelector> {
      late final List<TimeSlot> slots;
      static const int maxSelectable = 3;
    
      @override
      void initState() {
        super.initState();
        slots = generateTimeSlots();
      }
    
      int get selectedCount => slots.where((s) => s.isSelected).length;
    
      void toggleSlot(TimeSlot slot) {
        setState(() {
          slot.isSelected = !slot.isSelected;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        final days = getNextBusinessDays(3);
        const hours = [9, 13, 16];
    
        return Column(
          children: [
            Text('$selectedCount / $maxSelectable 件選択中'),
            const SizedBox(height: 8),
            for (final hour in hours)
              Row(
                children: [
                  SizedBox(width: 60, child: Text('$hour:00')),
                  for (final day in days)
                    Expanded(
                      child: Checkbox(
                        value: slots
                            .firstWhere((s) =>
                                s.date.day == day.day && s.hour == hour)
                            .isSelected,
                        onChanged: (checked) {
                          final slot = slots.firstWhere(
                              (s) => s.date.day == day.day && s.hour == hour);
    
                          // 3件選択済みかつ未選択のセルはタップ不可
                          if (!slot.isSelected && selectedCount >= maxSelectable) {
                            return;
                          }
                          toggleSlot(slot);
                        },
                      ),
                    ),
                ],
              ),
          ],
        );
      }
    }
    

    ポイントはonChanged内での制御です。すでに3件選択済みの状態で未選択のチェックボックスがタップされた場合はreturnで処理を止めることで、4件目以降を選択できないようにしています。あえてonChangedをnullにしないことで、選択済みのチェックボックスだけは常にON/OFFを切り替えられるようにしている点が細かい工夫です。

    こうした「動くけれど、細かい配慮が必要な制御」こそ、Claude Codeに指示を出して一気に組んでもらい、あとから挙動を確認しながら微調整する、という進め方が効率的でした。

    🔹 選択内容の送信とステータス確認

    3件選択した状態で送信ボタンを押すと、Supabaseの予約者選択テーブルにレコードを書き込みます。送信後は「確定待ち」のステータス表示に切り替え、管理者が日程を確定するまで予約者側の画面はこの状態を保持します。

    Future<void> submitSelection(List<TimeSlot> selected) async {
      await supabase.from('slot_selections').insert([
        for (final slot in selected)
          {
            'date': slot.date.toIso8601String(),
            'hour': slot.hour,
            'user_id': supabase.auth.currentUser!.id,
          }
      ]);
    }
    


    画像

    📝 Web管理画面側の設計(管理者向け)

    モバイル側は「候補から選ぶ」というシンプルな体験に絞りましたが、Web管理画面は「予約者からの選択結果を俯瞰し、最終決定を下す」という一覧性・操作性が求められる画面になります。ここもFlutter Webとして同じコードベースの中で作っていきます。

    🔹 予約者からの選択結果を一覧表示

    予約者がモバイル側で送信した選択結果を、Supabaseから取得して一覧表示します。誰が・どの候補日時を選んだかを、日時ごとにまとめて表示することで、管理者が全体の重なり具合を一目で把握できるようにします。

    class SlotSelection {
      final String userId;
      final String userName;
      final DateTime date;
      final int hour;
    
      SlotSelection({
        required this.userId,
        required this.userName,
        required this.date,
        required this.hour,
      });
    }
    
    Future<List<SlotSelection>> fetchSelections() async {
      final rows = await supabase
          .from('slot_selections')
          .select('user_id, date, hour, profiles(name)')
          .order('date');
    
      return rows.map<SlotSelection>((row) {
        return SlotSelection(
          userId: row['user_id'],
          userName: row['profiles']['name'],
          date: DateTime.parse(row['date']),
          hour: row['hour'],
        );
      }).toList();
    }
    

    🔹 一覧をテーブル形式で表示し、確定操作を行う

    取得した選択結果を、日時ごとにグルーピングしてテーブル表示します。管理者は一覧の中から最終的に確定したい日時の行を選び、確定ボタンを押すだけで完了する操作感を目指します。

    class AdminBookingTable extends StatefulWidget {
      const AdminBookingTable({super.key});
    
      @override
      State<AdminBookingTable> createState() => _AdminBookingTableState();
    }
    
    class _AdminBookingTableState extends State<AdminBookingTable> {
      List<SlotSelection> selections = [];
      SlotSelection? confirmedSlot;
    
      @override
      void initState() {
        super.initState();
        loadSelections();
      }
    
      Future<void> loadSelections() async {
        final data = await fetchSelections();
        setState(() => selections = data);
      }
    
      Future<void> confirmSlot(SlotSelection slot) async {
        await supabase.from('confirmed_bookings').insert({
          'user_id': slot.userId,
          'date': slot.date.toIso8601String(),
          'hour': slot.hour,
        });
        setState(() => confirmedSlot = slot);
        // ここで通知送信処理を呼び出す(次セクションで解説)
      }
    
      @override
      Widget build(BuildContext context) {
        return DataTable(
          columns: const [
            DataColumn(label: Text('日時')),
            DataColumn(label: Text('予約者')),
            DataColumn(label: Text('操作')),
          ],
          rows: selections.map((s) {
            return DataRow(cells: [
              DataCell(Text('${s.date.month}/${s.date.day} ${s.hour}:00')),
              DataCell(Text(s.userName)),
              DataCell(
                ElevatedButton(
                  onPressed: () => confirmSlot(s),
                  child: const Text('この日程で確定'),
                ),
              ),
            ]);
          }).toList(),
        );
      }
    }
    

    管理者向けのUIはモバイルのタップ操作とは異なり、DataTableのような一覧性重視のウィジェットを使うことで、PCでの操作感に合った作りになります。ここもモバイル側と同じくClaude Codeに「予約者の選択結果を日時・予約者名・確定ボタンのテーブルで表示して」と指示するところから組み立てています。

    🔹 候補日時の生成ロジックを管理側で調整できるようにするか

    今回は「直近3営業日×9時・13時・16時」を固定ロジックにしていますが、将来的に時間帯や対象日数を管理者側で変更したいというニーズも出てきます。今回はシンプルさを優先して固定値にしていますが、拡張する場合は候補生成のパラメータ(対象日数・時間帯リスト)をSupabaseの設定テーブルに持たせ、Flutter側で読み込む形にすると柔軟性が上がります。

    📝 Supabaseとの連携

    🔹 テーブル設計:候補日時テーブル/予約者選択テーブル/確定予約テーブル

    今回のシステムは、役割の異なる3つのテーブルで構成します。

    • slot_selections(予約者選択テーブル):予約者がモバイルで選んだ候補日時を保持。1人につき最大3件のレコード

    • confirmed_bookings(確定予約テーブル):管理者がWeb画面で確定した最終日程を保持

    • profiles(プロフィールテーブル):予約者の氏名など、認証ユーザーに紐づく情報

    候補日時そのものはシステム側で自動生成する仕様のため、「候補日時マスタ」のようなテーブルは持たず、slot_selectionsに予約者が選んだ日時を直接書き込む設計にしています。これにより、テーブル数を最小限に抑えられます。

    🔹 RLSでモバイル(予約者)とWeb(管理者)の権限を分離

    予約者は自分自身の選択結果しか操作できず、管理者は全予約者の選択結果を閲覧・確定できる、という権限の違いをRow Level Security(RLS)で実現します。

    -- 予約者は自分のレコードのみ挿入・閲覧可能
    create policy "Users can insert their own selections"
    on slot_selections for insert
    with check (auth.uid() = user_id);
    
    create policy "Users can view their own selections"
    on slot_selections for select
    using (auth.uid() = user_id);
    
    -- 管理者ロールは全レコードを閲覧・確定可能
    create policy "Admins can view all selections"
    on slot_selections for select
    using (auth.jwt() ->> 'role' = 'admin');
    
    create policy "Admins can insert confirmed bookings"
    on confirmed_bookings for insert
    with check (auth.jwt() ->> 'role' = 'admin');
    

    このように、アプリ側のコードで権限チェックを頑張るのではなく、データベース側でアクセス制御を完結させておくことで、モバイル・Web両方から同じSupabaseプロジェクトに安全にアクセスできます。

    🔹 無料版でどこまで作れるか

    今回のような小〜中規模の予約システムであれば、Supabaseの無料版で十分に運用可能です。無料版には500MBのデータベース容量、月間5万人までの認証ユーザー、月5GBのegressといった制限がありますが、社内の1on1やコーチング予約程度の利用規模であれば問題になりません。

    実際、筆者が運用しているnote分析ダッシュボード「noter-report」もSupabase無料版で稼働していますが、特に問題なく動いています。個人開発や検証段階のプロジェクトであれば、まずは無料版で始めて、利用規模が拡大してから有料プランへの移行を検討する、という進め方で十分です。

    なお無料版には「7日間アクセスがないとプロジェクトが一時停止する」という仕様があるため、常時稼働させたいデモ環境などでは、定期的にヘルスチェックのリクエストを送る仕組みを用意しておくと安心です。

    📝 通知の仕組み

    🔹 日程確定時にどう通知するか(選択肢の整理)

    管理者が日程を確定した後、予約者に結果を知らせる方法はいくつか考えられます。

    • メール通知:Supabase Edge Functionsから外部のメール配信サービス(Resend、SendGridなど)を呼び出す方法。実装がシンプルで、初級〜中級者にも扱いやすい

    • プッシュ通知:Firebase Cloud Messaging(FCM)と連携し、アプリ上に通知を届ける方法。ユーザー体験は良いが、FCMの設定やデバイストークンの管理など、実装の手間が増える

    • アプリ内のステータス表示のみ:通知は行わず、アプリを開いた際に「確定待ち」→「確定済み」の表示が切り替わるだけにする、最もシンプルな方法

    🔹 今回はメール通知を採用

    初級〜中級者向けの記事という位置づけと、Supabase無料版で完結させたいという方針から、今回はメール通知を採用します。Supabase Edge Functionsを使い、日程確定時にメール配信サービスのAPIを呼び出す構成です。

    // Supabase Edge Function: send-confirmation
    import { serve } from "https://deno.land/std/http/server.ts";
    
    serve(async (req) => {
      const { userEmail, date, hour } = await req.json();
    
      const res = await fetch("https://api.resend.com/emails", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${Deno.env.get("RESEND_API_KEY")}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          from: "booking@example.com",
          to: userEmail,
          subject: "1on1の日程が確定しました",
          html: `<p>${date} ${hour}:00 で日程が確定しました。</p>`,
        }),
      });
    
      return new Response(JSON.stringify({ success: res.ok }), {
        headers: { "Content-Type": "application/json" },
      });
    });
    

    Flutter側(管理画面)では、confirmSlot処理の中でこのEdge Functionを呼び出す形になります。

    Future<void> notifyConfirmation(SlotSelection slot, String userEmail) async {
      await supabase.functions.invoke('send-confirmation', body: {
        'userEmail': userEmail,
        'date': '${slot.date.month}/${slot.date.day}',
        'hour': slot.hour,
      });
    }
    

    🔹 実装の難易度と初級〜中級者向けの落としどころ

    メール通知はEdge Functionsと外部APIの呼び出しだけで完結するため、FCMのようなプラットフォーム別の設定(証明書やトークン管理)が不要で、初級者でも比較的つまずきにくい実装です。プッシュ通知はユーザー体験としては優れていますが、記事の題材としてはまず「メール通知で最小限動くものを作る」ところから始め、余力があればプッシュ通知への拡張を検討する、という段階的な進め方をおすすめします。

    📝 汎用化のポイント:1on1以外への応用

    🔹 コーチング予約への応用

    今回の仕組みは「候補日時を自動で出し、相手に3つまで選んでもらい、こちらで確定する」という構造そのものが汎用的です。コーチングの予約であれば、「管理者」をコーチ、「予約者」をクライアントに置き換えるだけで、ほぼそのまま転用できます。コーチング特有の要件(セッション時間の変更、複数コーチへの振り分けなど)が出てきた場合も、テーブル設計にコーチIDやセッション種別のカラムを追加する程度の拡張で対応できます。

    🔹 お客様からのアポイント取得への応用

    営業やカスタマーサポートなど、社外のお客様との日程調整にも同じ構造がそのまま使えます。「管理者」を営業担当、「予約者」をお客様に置き換えれば、アポイント取得システムとして機能します。社内の1on1と違い、お客様は認証済みユーザーとは限らないため、その場合はSupabaseの匿名アクセス用のリンク(トークン付きURLなど)を発行する形に調整する必要がありますが、候補日時の生成・選択・確定というコアロジックは変わりません。

    🔹 候補日時・確定ロジックを汎用テーブル設計にしておくメリット

    slot_selectionsやconfirmed_bookingsといったテーブル名や構造を、特定の用途(1on1)に寄せすぎず、汎用的な名前・カラム構成にしておくことで、利用シーンが変わっても候補生成ロジックと確定フローの骨格をそのまま使い回せます。今回の設計であれば、「誰が」「どの候補を選んだか」「誰が確定したか」というシンプルな構造を保っているため、応用の幅が広がります。

    おわりに

    画像

    🎀 Erica「実際に作ってみると、1on1の日程調整ってこんなにシンプルな仕組みで解決できるんだ、って自分でも驚いたよ。候補を自動で出して、3つ選んでもらって、確定して通知する。それだけなんだけど、地味に面倒だったやり取りがなくなるのって結構嬉しいよね。Flutterならモバイルもウェブも1つのコードで作れちゃうし、Supabaseの無料版でも十分動くから、気になった人はぜひ手を動かしてみてほしいな」

    今回は上司との1on1を題材にしましたが、コーチングの予約やお客様とのアポイント取得にもそのまま応用できる設計にしています。もしよければ、まずは自分の身近な「日程調整の面倒」から題材を見つけて、小さく作ってみるのがおすすめです。

    最後までお読みくださり、ありがとうございます! 私は京都生まれ大阪育ちなので、記事では結構ボケているつもりです。お好きな記事のコメント欄にツッコミ、お待ちしています😊


    あなたへのおすすめ