公開日 2026-09-07

Flutterの時計注入を用途で分ける:業務時刻とクールダウンを混ぜない

AppClockを用途別に注入し、業務時刻を固定したテストでもクールダウンの経過を独立に検証できる構成をFlutterで作る。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. なぜ時計を 1 つにすると詰まるのか
  6. 3. 先に全体像をつかむ
  7. 4. プロジェクトを作成する
  8. 4-1. 環境構築がまだなら先に済ませる
  9. 4-2. Flutter プロジェクトを作成する
  10. 4-3. エミュレーターを起動する
  11. 5. lib/main.dart を書き換えて時計を 2 つの用途に分ける
  12. コードのポイント
  13. 6. テストで「業務時刻を固定したままクールダウンだけ進める」
  14. 7. 本番の配線と「判定だけ実時計を使う」場面
  15. 8. まとめ

FlutterのWidgetテスト入門(最初の1本) でテストの足場を作り、時刻に依存する業務ロジックをテストし始めると、すぐに壁に当たります。「今日の業務日はどこか」を確かめたくて時計を固定すると、同じ時計を見ている「直前の操作からの経過時間(クールダウン)」まで止まってしまい、片方を固定するともう片方が試せません。原因は、性質の違う時間判定を 1 つの時計で兼ねていることにあります。この記事では AppClock を用途別に分けて注入し、業務時刻を固定したテストでもクールダウンだけを独立に進められる形を、最小構成の Flutter アプリとして作ります。

題材は業務端末でよくある「操作の受付ゲート」です。受付のたびに業務日を記録しつつ、短時間の二重受付はクールダウンで弾きます。

1. ゴールと非対象

対象読者

  • Widget Test か unit test を一度は書いて、flutter test を回した経験がある人
  • 時刻に依存する判定をテストしようとして、時計を固定したら別の判定まで動かなくなった人
  • DateTime.now() を直接呼ぶコードを、テストしやすく直したい人

この記事で到達する状態

  • 時刻を読む口を AppClock という 1 つの抽象にまとめられる
  • 業務時刻用とクールダウン用で別インスタンスの時計を注入できる
  • 業務時刻を固定したまま、クールダウン時刻だけを進めるテストを書ける
  • 本番では両方に実時計を入れ、テストでは用途ごとに別の偽時計を入れる、という使い分けを説明できる

非対象

今回は時計の「分け方」に集中します。扱わないテーマは次のとおりです。

  • clock パッケージなど既存ライブラリの導入
  • タイムゾーンや夏時間の厳密な変換
  • Riverpod や get_it での DI 配線の詳説(注入する場所の例だけ示します)
  • 再試行バックオフそのものの実装(同じ考え方で時計を分ける、という指針だけ触れます)

2. なぜ時計を 1 つにすると詰まるのか

受付ゲートは、1 回の accept() で 2 種類の時間判定をします。

  • 業務日の判定: いまが「どの業務日」かを決める。深夜 0 時ではなく朝 5 時で日付が切り替わる運用は珍しくない。
  • クールダウンの判定: 直前の受付から十分な時間が経ったか。経過時間だけが問題で、暦の上の日付は関係しない。

この 2 つを 1 つの時計でまかなうと、テストで身動きが取れなくなります。たとえば、すべて同じ clock から計算する作りが次の例です。

// 悪い例: 1 つの時計を業務日にもクールダウンにも使い回す
class NaiveGate {
  NaiveGate(this._clock);

  final AppClock _clock;
  DateTime? _lastAcceptedAt;

  AcceptResult accept() {
    final DateTime now = _clock.now();
    // 業務日もクールダウンも、同じ now から計算してしまう
    ...
  }
}

NaiveGate(FakeClock(02:00)) のように時計を 02:00 に固定すると、業務日が前営業日になることは確かめられます。ところが固定したままでは now.difference(last) が常に 0 のままで、クールダウンが切れる経路に到達できないわけです。逆にクールダウンの満了を試そうと時計を進めると、業務時刻まで一緒に動き、朝 5 時の境界をまたいで業務日が変わります。時計を固定するとクールダウンが試せず、時計を進めると業務日のほうが変わってしまう、という板挟みです。

詰まりの正体は「経過時間を測る時計」と「暦の日付を決める時計」を同一視している点にあります。そこで採るのが、時計を用途で分けて注入する方針です。

3. 先に全体像をつかむ

受付ゲートは、業務日には businessClock、クールダウンには cooldownClock と、用途で時計を呼び分けます。

flowchart TD
  A[受け付ける] --> G[OperationGate.accept]
  G -->|経過時間を測る| C[cooldownClock.now]
  G -->|業務日を決める| B[businessClock.now]
  C --> R{クールダウン中か}
  R -->|はい| X[却下]
  R -->|いいえ| Y[受理して業務日を記録]
  B --> Y

押さえる点は次の 2 つです。

  • 本番では businessClock も cooldownClock も同じ SystemClock(実時計)を入れる
  • テストでは用途ごとに別の FakeClock を入れ、片方を固定したままもう片方を進める

同じインターフェースの時計を 2 つ受け取るだけで、本番のふるまいは変えずにテストの自由度だけが上がります。

4. プロジェクトを作成する

4-1. 環境構築がまだなら先に済ませる

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

4-2. Flutter プロジェクトを作成する

次のコマンドでプロジェクトを作成します。

flutter create clock_injection
cd clock_injection

以降のコマンドとファイル操作は、このプロジェクト直下で行います。追加パッケージはありません。あとで flutter test を実行するため、DartPad ではなく実プロジェクトで進めます。

4-3. エミュレーターを起動する

利用可能なエミュレーター一覧を確認します。

flutter emulators

表示された ID を指定して起動します。

flutter emulators --launch <emulator_id>

5. lib/main.dart を書き換えて時計を 2 つの用途に分ける

flutter create が生成した lib/main.dart を、次の内容で書き換えます。内容は、時刻の抽象 AppClock、実時計 SystemClock、受付ゲート OperationGate、結果を見せる最小 UI までを 1 ファイルにまとめたものです。

import 'package:flutter/material.dart';

void main() {
  runApp(const ClockInjectionDemoApp());
}

/// アプリ内で時刻を読む口を 1 つに絞る抽象。
/// 用途ごとに別インスタンスを注入できるよう、メソッドは `now()` だけにする。
abstract interface class AppClock {
  DateTime now();
}

/// 本番で使う実時計。業務時刻・クールダウンとも、本番ではこれを注入する。
class SystemClock implements AppClock {
  const SystemClock();

  @override
  DateTime now() => DateTime.now();
}

/// 受付を却下する理由。
enum RejectReason {
  withinCooldown('クールダウン中');

  const RejectReason(this.label);

  final String label;
}

/// 受付の判定結果。受理なら業務日を持ち、却下なら理由を持つ。
final class AcceptResult {
  const AcceptResult.accepted({required this.businessDate})
      : isAccepted = true,
        rejectReason = null;

  const AcceptResult.rejected(this.rejectReason)
      : isAccepted = false,
        businessDate = null;

  final bool isAccepted;
  final DateTime? businessDate;
  final RejectReason? rejectReason;
}

/// 操作の受付ゲート。時刻を 2 つの用途で使う。
/// - businessClock: どの業務日として記録するかを決める「業務時刻」
/// - cooldownClock: 直前の受付からの経過時間を測る「クールダウン時刻」
///
/// この 2 つを別インスタンスで受け取るのが肝心な点。業務時刻を固定したテストでも、
/// クールダウン時刻だけを進められる。
class OperationGate {
  OperationGate({
    required AppClock businessClock,
    required AppClock cooldownClock,
    this.cooldown = const Duration(seconds: 3),
    this.businessDayStartHour = 5,
  })  : _businessClock = businessClock,
        _cooldownClock = cooldownClock;

  final AppClock _businessClock;
  final AppClock _cooldownClock;
  final Duration cooldown;
  final int businessDayStartHour;

  DateTime? _lastAcceptedAt;

  AcceptResult accept() {
    final DateTime elapsedNow = _cooldownClock.now();
    final DateTime? last = _lastAcceptedAt;
    if (last != null && elapsedNow.difference(last) < cooldown) {
      return const AcceptResult.rejected(RejectReason.withinCooldown);
    }

    _lastAcceptedAt = elapsedNow;
    return AcceptResult.accepted(
      businessDate: _businessDateOf(_businessClock.now()),
    );
  }

  /// 業務日は深夜 0 時ではなく businessDayStartHour で切り替える。
  /// 例: 開始 5 時なら、02:00 の操作は前営業日として扱う。
  DateTime _businessDateOf(DateTime moment) {
    final DateTime shifted = moment.subtract(
      Duration(hours: businessDayStartHour),
    );
    return DateTime(shifted.year, shifted.month, shifted.day);
  }
}

class ClockInjectionDemoApp extends StatelessWidget {
  const ClockInjectionDemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Clock Injection Demo',
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
      ),
      home: const OperationGatePage(),
    );
  }
}

class OperationGatePage extends StatefulWidget {
  const OperationGatePage({super.key});

  @override
  State<OperationGatePage> createState() => _OperationGatePageState();
}

class _OperationGatePageState extends State<OperationGatePage> {
  // 本番は業務時刻・クールダウンの両方に実時計を注入する。
  final OperationGate _gate = OperationGate(
    businessClock: const SystemClock(),
    cooldownClock: const SystemClock(),
  );

  AcceptResult? _lastResult;
  int _acceptedCount = 0;

  void _onPressed() {
    final AcceptResult result = _gate.accept();
    setState(() {
      _lastResult = result;
      if (result.isAccepted) {
        _acceptedCount++;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    final AcceptResult? result = _lastResult;

    return Scaffold(
      appBar: AppBar(title: const Text('操作の受付ゲート')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Text(
              'クールダウンは 3 秒です。連続して押すと、3 秒以内の 2 回目以降は却下されます。',
              style: theme.textTheme.bodyLarge,
            ),
            const SizedBox(height: 24),
            FilledButton(
              onPressed: _onPressed,
              child: const Padding(
                padding: EdgeInsets.symmetric(vertical: 16),
                child: Text('受け付ける'),
              ),
            ),
            const SizedBox(height: 24),
            if (result == null)
              Text('まだ受け付けていません。', style: theme.textTheme.bodyMedium)
            else if (result.isAccepted)
              _ResultCard(
                color: theme.colorScheme.primaryContainer,
                title: '受理しました',
                message: '業務日: ${_formatDate(result.businessDate!)}',
              )
            else
              _ResultCard(
                color: theme.colorScheme.errorContainer,
                title: '却下しました',
                message: '理由: ${result.rejectReason!.label}',
              ),
            const SizedBox(height: 16),
            Text(
              '受理した回数: $_acceptedCount',
              style: theme.textTheme.titleMedium,
            ),
          ],
        ),
      ),
    );
  }

  String _formatDate(DateTime date) {
    final String month = date.month.toString().padLeft(2, '0');
    final String day = date.day.toString().padLeft(2, '0');
    return '${date.year}-$month-$day';
  }
}

class _ResultCard extends StatelessWidget {
  const _ResultCard({
    required this.color,
    required this.title,
    required this.message,
  });

  final Color color;
  final String title;
  final String message;

  @override
  Widget build(BuildContext context) {
    return Card(
      color: color,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(title, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            Text(message),
          ],
        ),
      ),
    );
  }
}

flutter run で起動すると、まだ一度も受け付けていない状態から始まります。

起動直後の受付ゲート画面。「まだ受け付けていません。」と表示され、受理した回数は 0

「受け付ける」を押すと、業務日付きで受理されます。表示される業務日は、実行した時刻を朝 5 時基準で切り替えた結果です。深夜 0 時から朝 5 時までに押せば、前日の日付になります。

「受理しました」と業務日が表示され、受理した回数が 1 になった状態

3 秒以内にもう一度押すと、クールダウン中として却下されます。ここで見ている経過時間は cooldownClock のほうで、業務日を決めた businessClock とは別インスタンスです。

「却下しました」と「理由: クールダウン中」が表示された状態

コードのポイント

① 時刻を読む口を AppClock 1 つに絞る

abstract interface class AppClock {
  DateTime now();
}

class SystemClock implements AppClock {
  const SystemClock();

  @override
  DateTime now() => DateTime.now();
}

時刻の入口を now() だけの抽象にまとめます。DateTime.now() を業務ロジックの中で直接呼ばず、必ず AppClock 越しに読む形へ寄せておくと、後からの差し替えが自由です。abstract interface class にすることで、実装専用(implements 前提)であることも示せます。

② 業務時刻とクールダウンを別インスタンスで受け取る

OperationGate({
  required AppClock businessClock,
  required AppClock cooldownClock,
  this.cooldown = const Duration(seconds: 3),
  this.businessDayStartHour = 5,
})  : _businessClock = businessClock,
      _cooldownClock = cooldownClock;

OperationGate は同じ AppClock 型を 2 つ受け取ります。型は同じでも役割が違うので、フィールドを分けて持ちます。本番ではどちらにも SystemClock を渡すので、ふるまいは 1 つの時計のときと同じです。違いはテストのときだけ現れます。

③ クールダウンは cooldownClock、業務日は businessClock で読む

AcceptResult accept() {
  final DateTime elapsedNow = _cooldownClock.now();
  final DateTime? last = _lastAcceptedAt;
  if (last != null && elapsedNow.difference(last) < cooldown) {
    return const AcceptResult.rejected(RejectReason.withinCooldown);
  }

  _lastAcceptedAt = elapsedNow;
  return AcceptResult.accepted(
    businessDate: _businessDateOf(_businessClock.now()),
  );
}

経過時間は cooldownClock から、業務日は businessClock から読む形です。どの判定がどの時計を見ているかが行ごとに対応するので、テストで片方だけ動かしたときの結果も追いやすくなります。

6. テストで「業務時刻を固定したままクールダウンだけ進める」

lib/main.dart を書き換えたことで、flutter create 直後に入っている test/widget_test.dart は元のカウンターアプリ前提のままになり、失敗します。残したまま flutter test を実行したときの出力がこれです。

test/widget_test.dart:16:35: Error: Couldn't find constructor 'MyApp'.
    await tester.pumpWidget(const MyApp());
                                  ^^^^^

生成されたテストが組み立てるのは MyApp で、書き換え後の lib/main.dart にその名前のクラスは無い状態です。テストが走る前のコンパイルで止まるため、結果は Some tests failed. だけになります。この記事では受付ゲートのテストに絞って進めるため、test/widget_test.dart は削除してかまいません。

ここが時計を分けた効果の出る場所です。test/operation_gate_test.dart を次の内容で作成します。

import 'package:clock_injection/main.dart';
import 'package:flutter_test/flutter_test.dart';

/// テスト用の操作できる時計。値を固定したり、任意に進めたりできる。
class FakeClock implements AppClock {
  FakeClock(this._value);

  DateTime _value;

  @override
  DateTime now() => _value;

  void advance(Duration duration) => _value = _value.add(duration);
}

void main() {
  group('OperationGate の業務日判定', () {
    test('開始 5 時境界より前の操作は前営業日として扱う', () {
      // 02:00 は業務日の開始(5 時)前なので前日扱い。
      final FakeClock business = FakeClock(DateTime(2026, 1, 15, 2));
      final FakeClock cooldown = FakeClock(DateTime(2026, 1, 15, 2));
      final OperationGate gate = OperationGate(
        businessClock: business,
        cooldownClock: cooldown,
      );

      final AcceptResult result = gate.accept();

      expect(result.isAccepted, isTrue);
      expect(result.businessDate, DateTime(2026, 1, 14));
    });

    test('開始 5 時以降の操作は当日として扱う', () {
      final FakeClock business = FakeClock(DateTime(2026, 1, 15, 9));
      final FakeClock cooldown = FakeClock(DateTime(2026, 1, 15, 9));
      final OperationGate gate = OperationGate(
        businessClock: business,
        cooldownClock: cooldown,
      );

      expect(gate.accept().businessDate, DateTime(2026, 1, 15));
    });
  });

  group('OperationGate のクールダウン', () {
    test('業務時刻を固定したままでもクールダウンは独立に進む', () {
      // 業務時刻は 02:00 に固定し、前営業日のまま動かさない。
      final FakeClock business = FakeClock(DateTime(2026, 1, 15, 2));
      // クールダウン時刻だけを別インスタンスで進める。
      final FakeClock cooldown = FakeClock(DateTime(2026, 1, 15, 2));
      final OperationGate gate = OperationGate(
        businessClock: business,
        cooldownClock: cooldown,
        cooldown: const Duration(seconds: 3),
      );

      // 1 回目は受理。
      expect(gate.accept().isAccepted, isTrue);

      // 1 秒後はクールダウン中なので却下。
      cooldown.advance(const Duration(seconds: 1));
      final AcceptResult second = gate.accept();
      expect(second.isAccepted, isFalse);
      expect(second.rejectReason, RejectReason.withinCooldown);

      // さらに進めて合計 4 秒。クールダウンを抜けて受理。
      cooldown.advance(const Duration(seconds: 3));
      final AcceptResult third = gate.accept();
      expect(third.isAccepted, isTrue);

      // 業務時刻は動かしていないので、業務日は前営業日のまま。
      expect(third.businessDate, DateTime(2026, 1, 14));
    });
  });
}

3 つ目のテストが、この設計の狙いそのものです。business は 02:00 に固定したまま一度も動かさず、cooldown だけを合計 4 秒進めています。これで「受理 → クールダウン中で却下 → 経過後に再び受理」の流れを確かめられます。しかも最後の受理でも businessDate は前営業日の 2026-01-14 のままです。業務時刻の時計に触れていないので、業務日は揺れません。時計を 1 つに統一していたら、このテストは成り立たないわけです。

flutter test を実行すると、3 件とも通ります。

00:00 +0: OperationGate の業務日判定 開始 5 時境界より前の操作は前営業日として扱う
00:00 +1: OperationGate の業務日判定 開始 5 時以降の操作は当日として扱う
00:00 +2: OperationGate のクールダウン 業務時刻を固定したままでもクールダウンは独立に進む
00:00 +3: All tests passed!

FakeClock は now() を返すだけの小さなクラスで、advance() で値を進めます。テストごとに業務用とクールダウン用を別々に用意し、片方を固定したまま片方を進められるところが、用途分割の恩恵です。

7. 本番の配線と「判定だけ実時計を使う」場面

本番のウィジェットでは、両方に実時計を入れます。

final OperationGate _gate = OperationGate(
  businessClock: const SystemClock(),
  cooldownClock: const SystemClock(),
);

本番は 1 つの時計を共有していたときと同じ動きです。分割の代償は「SystemClock を 2 回書く」だけで、実行時のふるまいは変わりません。Riverpod などで注入する場合も、合成する場所が Provider に移るだけで考え方は同じです(Riverpodで状態管理を始める入門 の Provider に OperationGate を載せ替えられます)。

用途を分けておくと、テストの混ぜ方も選べます。

  • 業務日のロジックだけ確かめたいとき: 業務時刻を FakeClock で固定し、クールダウンは結果に影響しない範囲で固定する
  • クールダウンだけ確かめたいとき: 業務時刻は固定したまま、クールダウンの FakeClock を advance() で進める
  • 経過時間を実時間で見たい統合テスト: クールダウンにだけ SystemClock を入れ、業務時刻は固定しておく

最後の形が「判定だけ実時計を使う」場面です。業務日は再現性のために固定したいが、経過時間は実際の Future.delayed と合わせて確かめたい、というときに選べます。再試行のバックオフ時計を足すときも同じ考え方です。業務時刻やクールダウンとは別の 3 つ目の AppClock として分けておくと、テストが互いに干渉しません。

8. まとめ

時刻に依存する判定が 2 つ以上あるなら、時計は用途で分けて注入します。

  • 経過時間を測る時計と、暦の日付を決める時計は性質が違う
  • 1 つに統一すると、片方を固定したテストでもう片方が動かせず詰まる
  • AppClock を用途別に注入すれば、本番の動きを変えずにテストの自由度だけが上がる

手元のコードで DateTime.now() を直接呼んでいる判定を 1 つ探し、それが他の時間判定と混ざっていないか見てください。混ざっているなら、AppClock を 2 つに割るところから始めると無理なく取り入れられます。テストの足場づくりは FlutterのWidgetテスト入門(最初の1本) を、結果の型を増やすときの分岐整理は Dart 3のsealed classとパターンマッチングで分岐を安全に書く を合わせて読むと、判定とテストの両輪を整えやすくなります。

シリーズ 39/42

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)
  39. 39. Flutterの時計注入を用途で分ける:業務時刻とクールダウンを混ぜない 現在の記事
  40. 40. Flutterでdioとinterceptorでトークンリフレッシュを実装する(401で自動更新・再試行)
  41. 41. Flutterでフォーム入力とバリデーションを組む(Form・TextFormField最小構成)
  42. 42. Flutterで一覧検索とページングを実装する(業務検索UIの最小構成)