公開日 2026-08-26

Flutterでdioとinterceptorでトークンリフレッシュを実装する(401で自動更新・再試行)

dioのInterceptorでアクセストークンを付与し、401を受けたらリフレッシュして元のリクエストを再試行、失敗時はログアウトする最小構成をFlutterで作る。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 全体の流れ(401からリフレッシュ・再試行まで)
  6. 3. プロジェクトを作成し、dio を追加する
  7. 3-1. 環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. dio を追加する
  11. 4. トークン保管庫とインターセプタを作る
  12. コードのポイント
  13. 5. 動かして確認する
  14. 6. テストで 401→リフレッシュ→再試行とログアウトを確かめる
  15. 7. まとめ

JWTとsecure_storageでFlutterのログイン状態を管理する でトークンを保存し起動時に復元できるようにすると、次に詰まるのがアクセストークンの期限切れです。保護APIを叩いて 401 Unauthorized が返るたびにログイン画面へ戻していては、リフレッシュトークンを持っている意味がありません。この記事では dio の Interceptor を使い、リクエストにトークンを付け、401 を受けたら 1 回だけリフレッシュして元のリクエストを再試行し、リフレッシュにも失敗したらログアウトする最小構成を作ります。

題材は GET /api/protected を叩くだけのアプリです。実サーバーは用意せず、flutter run でもテストでも同じ偽バックエンドを差し込んで動かします。

1. ゴールと非対象

対象読者

  • ログイン状態の保持までは実装し、flutter run / flutter test を回した経験がある人
  • アクセストークンの期限切れで 401 が返ったときの扱いを決めかねている人
  • http から dio へ移って、共通処理を Interceptor に寄せたい人

この記事で到達する状態

  • dio の Interceptor でリクエストに Authorization を自動付与する
  • 401 応答を受けてリフレッシュし、元のリクエストを 1 回だけ再試行できる
  • 同時に来た 401 でもリフレッシュを 1 回に抑える単発化の考え方
  • リフレッシュに失敗したらトークンを捨ててログアウトへ落とせる

非対象

今回は 401 からの復帰に集中します。扱わないテーマは次のとおりです。

  • リフレッシュエンドポイントのサーバー側実装
  • flutter_secure_storage への保存処理(前提記事に譲り、ここはメモリ保管で示す)
  • 大量の並行リクエストに対する高度なキュー制御(最小の単発化にとどめる)
  • トークンの有効期限設計やローテーション方針そのもの

2. 全体の流れ(401からリフレッシュ・再試行まで)

成功時は、401 をきっかけにリフレッシュと再試行が自動で挟まります。

sequenceDiagram
  participant UI as 画面
  participant Dio as Dio + AuthInterceptor
  participant API as バックエンド
  UI->>Dio: GET /protected
  Dio->>API: Authorization: Bearer(期限切れ)
  API-->>Dio: 401
  Dio->>API: POST /auth/refresh
  API-->>Dio: 200 新しいトークン
  Dio->>API: GET /protected(新トークンで再試行)
  API-->>Dio: 200
  Dio-->>UI: 成功(呼び出し側は 401 を意識しない)

リフレッシュ自体が 401 になった場合は、トークンを破棄してログアウトへ落とします。呼び出し側のコードは dio.get('/protected') を書くだけで、401 の復帰は Interceptor が引き受ける形です。

3. プロジェクトを作成し、dio を追加する

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

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

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

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

flutter create dio_refresh
cd dio_refresh

以降のコマンドとファイル操作は、このプロジェクト直下で行います。

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

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

flutter emulators

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

flutter emulators --launch <emulator_id>

3-4. dio を追加する

プロジェクト直下で次のコマンドを実行します。

flutter pub add dio

pubspec.yaml に次の行が入ります(執筆時のバージョンは dio: ^5.9.2)。

dependencies:
  dio: ^5.9.2

4. トークン保管庫とインターセプタを作る

flutter create が生成した lib/main.dart を、次の内容で書き換えます。トークン保管庫 TokenStore、認証 Interceptor AuthInterceptor、Dio を組み立てる buildAuthedDio、動作確認用の偽バックエンド FakeBackendAdapter、最小 UI までを 1 ファイルにまとめました。

import 'dart:convert';
import 'dart:typed_data';

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';

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

/// アクセストークンとリフレッシュトークンの保管庫。
/// このサンプルはメモリ上に持つ。本番は flutter_secure_storage へ置き換える
/// (その場合は読み書きが非同期になる)。
class TokenStore {
  TokenStore({String? accessToken, String? refreshToken})
      : _accessToken = accessToken,
        _refreshToken = refreshToken;

  String? _accessToken;
  String? _refreshToken;

  String? get accessToken => _accessToken;
  String? get refreshToken => _refreshToken;

  void update({required String accessToken, required String refreshToken}) {
    _accessToken = accessToken;
    _refreshToken = refreshToken;
  }

  void clear() {
    _accessToken = null;
    _refreshToken = null;
  }
}

/// リクエストにトークンを付け、401 を受けたらリフレッシュして元のリクエストを
/// 1 回だけ再試行する Interceptor。リフレッシュに失敗したらログアウトする。
class AuthInterceptor extends Interceptor {
  AuthInterceptor({
    required this.tokenStore,
    required this.onLogout,
    required Dio bareClient,
    this.refreshPath = '/auth/refresh',
  }) : _bareClient = bareClient;

  final TokenStore tokenStore;
  final VoidCallback onLogout;
  final String refreshPath;

  /// インターセプタを通さない素の Dio。リフレッシュと再試行に使い、再帰を防ぐ。
  final Dio _bareClient;

  /// 同時に来た 401 で何度もリフレッシュしないための単発化フラグ。
  Future<bool>? _refreshing;

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final String? token = tokenStore.accessToken;
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    final bool shouldRefresh = err.response?.statusCode == 401 &&
        err.requestOptions.extra['retried'] != true;
    if (!shouldRefresh) {
      handler.next(err);
      return;
    }

    final bool refreshed = await _refreshOnce();
    if (!refreshed) {
      tokenStore.clear();
      onLogout();
      handler.next(err);
      return;
    }

    try {
      final RequestOptions options = err.requestOptions;
      options.extra['retried'] = true;
      options.headers['Authorization'] = 'Bearer ${tokenStore.accessToken}';
      final Response<dynamic> response = await _bareClient.fetch<dynamic>(options);
      handler.resolve(response);
    } on DioException catch (error) {
      handler.next(error);
    }
  }

  Future<bool> _refreshOnce() {
    return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
  }

  Future<bool> _doRefresh() async {
    final String? refreshToken = tokenStore.refreshToken;
    if (refreshToken == null) {
      return false;
    }
    try {
      final Response<dynamic> response = await _bareClient.post<dynamic>(
        refreshPath,
        data: <String, dynamic>{'refreshToken': refreshToken},
      );
      final Map<String, dynamic> body = response.data as Map<String, dynamic>;
      tokenStore.update(
        accessToken: body['accessToken'] as String,
        refreshToken: body['refreshToken'] as String,
      );
      return true;
    } on DioException {
      return false;
    }
  }
}

/// AuthInterceptor を載せた Dio を組み立てる。
/// テストでは [adapter] に偽バックエンドを渡し、本番では渡さず実通信にする。
Dio buildAuthedDio({
  required TokenStore tokenStore,
  required VoidCallback onLogout,
  HttpClientAdapter? adapter,
  String baseUrl = 'https://api.example.com',
}) {
  final Dio bareClient = Dio(BaseOptions(baseUrl: baseUrl));
  final Dio dio = Dio(BaseOptions(baseUrl: baseUrl));
  if (adapter != null) {
    dio.httpClientAdapter = adapter;
    bareClient.httpClientAdapter = adapter;
  }
  dio.interceptors.add(
    AuthInterceptor(
      tokenStore: tokenStore,
      onLogout: onLogout,
      bareClient: bareClient,
    ),
  );
  return dio;
}

/// 動作確認用の偽バックエンド。実サーバーの代わりに `flutter run` とテストの両方で使う。
/// - `/protected`: Authorization が現在有効なトークンなら 200、違えば 401
/// - `/auth/refresh`: refreshSucceeds が true なら新トークンを返す、false なら 401
class FakeBackendAdapter implements HttpClientAdapter {
  FakeBackendAdapter({
    required this.validAccessToken,
    required this.refreshSucceeds,
  });

  final String validAccessToken;
  final bool refreshSucceeds;

  int protectedCalls = 0;
  int refreshCalls = 0;

  @override
  Future<ResponseBody> fetch(
    RequestOptions options,
    Stream<Uint8List>? requestStream,
    Future<void>? cancelFuture,
  ) async {
    switch (options.path) {
      case '/protected':
        protectedCalls++;
        if (options.headers['Authorization'] == 'Bearer $validAccessToken') {
          return _json(<String, dynamic>{'message': 'protected ok'}, 200);
        }
        return _json(<String, dynamic>{'error': 'unauthorized'}, 401);
      case '/auth/refresh':
        refreshCalls++;
        if (refreshSucceeds) {
          return _json(<String, dynamic>{
            'accessToken': validAccessToken,
            'refreshToken': 'refresh-rotated',
          }, 200);
        }
        return _json(<String, dynamic>{'error': 'invalid refresh token'}, 401);
      default:
        return _json(<String, dynamic>{'error': 'not found'}, 404);
    }
  }

  ResponseBody _json(Map<String, dynamic> body, int statusCode) {
    return ResponseBody.fromString(
      jsonEncode(body),
      statusCode,
      headers: <String, List<String>>{
        Headers.contentTypeHeader: <String>['application/json'],
      },
    );
  }

  @override
  void close({bool force = false}) {}
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Token Refresh Demo',
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: const TokenRefreshPage(),
    );
  }
}

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

  @override
  State<TokenRefreshPage> createState() => _TokenRefreshPageState();
}

class _TokenRefreshPageState extends State<TokenRefreshPage> {
  late TokenStore _store;
  late Dio _dio;

  bool _refreshExpired = false;
  bool _loggedOut = false;
  String _status = 'まだ呼んでいません。';

  @override
  void initState() {
    super.initState();
    _rebuildClient();
  }

  // 期限切れアクセストークンから始め、初回呼び出しでリフレッシュが走るようにする。
  void _rebuildClient() {
    _store = TokenStore(accessToken: 'access-expired', refreshToken: 'refresh-1');
    _loggedOut = false;
    _dio = buildAuthedDio(
      tokenStore: _store,
      onLogout: () => setState(() => _loggedOut = true),
      adapter: FakeBackendAdapter(
        validAccessToken: 'access-fresh',
        refreshSucceeds: !_refreshExpired,
      ),
    );
  }

  Future<void> _callProtected() async {
    try {
      final Response<dynamic> response = await _dio.get<dynamic>('/protected');
      final Map<String, dynamic> body = response.data as Map<String, dynamic>;
      setState(() {
        _status = '成功: ${body['message']} / token=${_store.accessToken}';
      });
    } on DioException catch (error) {
      setState(() {
        final String code = '${error.response?.statusCode ?? '通信失敗'}';
        _status = _loggedOut
            ? '失敗($code) → リフレッシュできずログアウトしました。'
            : '失敗($code) でした。';
      });
    }
  }

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

    return Scaffold(
      appBar: AppBar(title: const Text('dio トークンリフレッシュ')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            Text(
              '期限切れトークンで保護APIを呼ぶと、401 を受けて自動でリフレッシュし、'
              '元のリクエストを再試行します。',
              style: theme.textTheme.bodyLarge,
            ),
            const SizedBox(height: 16),
            SwitchListTile(
              title: const Text('リフレッシュトークンも失効させる'),
              subtitle: const Text('オンにするとリフレッシュが失敗し、ログアウトします。'),
              value: _refreshExpired,
              onChanged: (bool value) {
                setState(() {
                  _refreshExpired = value;
                  _rebuildClient();
                  _status = 'まだ呼んでいません。';
                });
              },
            ),
            const SizedBox(height: 8),
            FilledButton(
              onPressed: _callProtected,
              child: const Padding(
                padding: EdgeInsets.symmetric(vertical: 16),
                child: Text('保護APIを呼ぶ'),
              ),
            ),
            const SizedBox(height: 24),
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16),
                child: Text(_status, style: theme.textTheme.titleMedium),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

FakeBackendAdapter は実サーバーの代役です。本番では buildAuthedDio に adapter を渡さず、baseUrl を自分の API に向けるだけで、同じ Interceptor がそのまま動く形です。

コードのポイント

① onRequest でトークンを付ける

void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
  final String? token = tokenStore.accessToken;
  if (token != null) {
    options.headers['Authorization'] = 'Bearer $token';
  }
  handler.next(options);
}

すべてのリクエストの前に Authorization ヘッダーを付けます。呼び出し側は個々のリクエストでトークンを意識せず、付与漏れも防げる形です。トークンが無い起動直後は付けずに通します。

② onError で 401 をリフレッシュへ回し、二重実行を防ぐ

final bool shouldRefresh = err.response?.statusCode == 401 &&
    err.requestOptions.extra['retried'] != true;
if (!shouldRefresh) {
  handler.next(err);
  return;
}

final bool refreshed = await _refreshOnce();
// ...
Future<bool> _refreshOnce() {
  return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
}

401 で、まだ再試行していないリクエストだけをリフレッシュ対象にします。_refreshOnce() は進行中のリフレッシュがあればその Future を使い回すため、画面から同時に複数のリクエストが 401 になっても、リフレッシュは 1 回だけです。extra['retried'] で「このリクエストは再試行済み」と印を付け、再試行が再び 401 でもループしません。

③ 素の Dio で再試行し、再帰とループを断つ

final RequestOptions options = err.requestOptions;
options.extra['retried'] = true;
options.headers['Authorization'] = 'Bearer ${tokenStore.accessToken}';
final Response<dynamic> response = await _bareClient.fetch<dynamic>(options);
handler.resolve(response);

リフレッシュ後の再試行は、Interceptor を載せていない _bareClient で送ります。同じ Dio で再送すると Interceptor を再び通って読みづらくなるため、素の Dio に分けた形です。リフレッシュ呼び出しも同じ _bareClient を使うので、/auth/refresh 自体が 401 を巻き込むことはありません。

5. 動かして確認する

flutter run で起動すると、期限切れのアクセストークンを持った状態で立ち上がります。

起動直後の画面。「まだ呼んでいません。」と表示され、「保護APIを呼ぶ」ボタンと「リフレッシュトークンも失効させる」スイッチが並ぶ

「保護APIを呼ぶ」を押すと、内部で 401 → リフレッシュ → 再試行が走り、成功表示とともにトークンが更新されます。表示される token=access-fresh は、リフレッシュで受け取った新しいアクセストークンです。期限切れの access-expired のままなら、再試行は通っていません。

「成功: protected ok / token=access-fresh」と表示された結果カード

「リフレッシュトークンも失効させる」をオンにしてから押すと、リフレッシュが失敗し、ログアウト表示へ切り替わる動きです。

「失敗(401) → リフレッシュできずログアウトしました。」と表示された結果カード

6. テストで 401→リフレッシュ→再試行とログアウトを確かめる

lib/main.dart を書き換えたことで、flutter create 直後に入っている test/widget_test.dart は元のカウンターアプリ前提のままになり、失敗します。この記事では Interceptor のテストに絞って進めるため、test/widget_test.dart は削除してかまいません。

そのうえで test/auth_interceptor_test.dart を次の内容で作成します。偽バックエンドを差し込み、復帰と失敗の両方を確かめる構成です。

import 'package:dio/dio.dart';
import 'package:dio_refresh/main.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  group('AuthInterceptor', () {
    test('401を受けたらリフレッシュし、元のリクエストを再試行して成功する', () async {
      final TokenStore store = TokenStore(
        accessToken: 'access-expired',
        refreshToken: 'refresh-1',
      );
      var loggedOut = false;
      final FakeBackendAdapter adapter = FakeBackendAdapter(
        validAccessToken: 'access-fresh',
        refreshSucceeds: true,
      );
      final Dio dio = buildAuthedDio(
        tokenStore: store,
        onLogout: () => loggedOut = true,
        adapter: adapter,
      );

      final Response<dynamic> response = await dio.get<dynamic>('/protected');

      expect(response.statusCode, 200);
      expect((response.data as Map<String, dynamic>)['message'], 'protected ok');
      // 期限切れで 1 回 401、再試行で 1 回の計 2 回呼ばれている。
      expect(adapter.protectedCalls, 2);
      expect(adapter.refreshCalls, 1);
      // トークンが更新されている。
      expect(store.accessToken, 'access-fresh');
      expect(loggedOut, isFalse);
    });

    test('リフレッシュにも失敗したらトークンを捨ててログアウトする', () async {
      final TokenStore store = TokenStore(
        accessToken: 'access-expired',
        refreshToken: 'refresh-expired',
      );
      var loggedOut = false;
      final FakeBackendAdapter adapter = FakeBackendAdapter(
        validAccessToken: 'access-fresh',
        refreshSucceeds: false,
      );
      final Dio dio = buildAuthedDio(
        tokenStore: store,
        onLogout: () => loggedOut = true,
        adapter: adapter,
      );

      await expectLater(
        dio.get<dynamic>('/protected'),
        throwsA(isA<DioException>()),
      );

      expect(loggedOut, isTrue);
      expect(store.accessToken, isNull);
      expect(store.refreshToken, isNull);
      // 再試行はしないので protected は 1 回だけ。
      expect(adapter.protectedCalls, 1);
      expect(adapter.refreshCalls, 1);
    });

    test('有効なトークンがあればリフレッシュせずそのまま成功する', () async {
      final TokenStore store = TokenStore(
        accessToken: 'access-fresh',
        refreshToken: 'refresh-1',
      );
      final FakeBackendAdapter adapter = FakeBackendAdapter(
        validAccessToken: 'access-fresh',
        refreshSucceeds: true,
      );
      final Dio dio = buildAuthedDio(
        tokenStore: store,
        onLogout: () {},
        adapter: adapter,
      );

      final Response<dynamic> response = await dio.get<dynamic>('/protected');

      expect(response.statusCode, 200);
      expect(adapter.protectedCalls, 1);
      expect(adapter.refreshCalls, 0);
    });
  });
}

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

00:00 +0: AuthInterceptor 401を受けたらリフレッシュし、元のリクエストを再試行して成功する
00:00 +1: AuthInterceptor リフレッシュにも失敗したらトークンを捨ててログアウトする
00:00 +2: AuthInterceptor 有効なトークンがあればリフレッシュせずそのまま成功する
00:00 +3: All tests passed!

protectedCalls が 2 であることが、再試行まで届いた証拠です。1 回目で 401、リフレッシュ後の 2 回目で 200 を受け取れています。失敗系では protectedCalls が 1 のままで、ログアウトとトークン破棄まで確認できます。偽バックエンドを差し替えるだけなので、実サーバーを立てずに 401 からの復帰経路を再現できます。

7. まとめ

トークンの自動更新は、dio の Interceptor へ寄せると呼び出し側が薄くなります。

  • onRequest でトークンを付け、付与漏れをなくす
  • onError で 401 を捕まえ、単発リフレッシュ → 再試行 → 失敗時ログアウトへ分岐する
  • 再試行とリフレッシュは素の Dio で送り、再帰とループを避ける

まずは flutter pub add dio で 1 つの保護APIを Interceptor 経由に置き換え、401 の復帰を Interceptor へ移してみてください。トークンの保存先は JWTとsecure_storageでFlutterのログイン状態を管理する の flutter_secure_storage に、エラー表示は FlutterでREST APIを呼び出してJSONとエラーを扱う の受け止め方につなぐと、認証まわりの導線がそろいます。

シリーズ 40/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の最小構成)