公開日 2026-07-19

Dart 3のsealed classとパターンマッチングで分岐を安全に書く

Dart 3の sealed class と switch 式のパターンマッチングを使い、API 結果の成功 / 0件 / 未認証 / 通信失敗を型で分けながら安全に分岐する入口を整理する。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. enum と null だけでは意味が崩れやすい理由
  6. 3. 先に分岐の流れを掴む
  7. 4. lib/main.dart を作って ApiResult<T> を表現する
  8. コードのポイント
  9. 5. switch 式で網羅的に分岐する
  10. 6. when 相当の書き方とどう使い分けるか
  11. 7. 既存記事とどうつなげるか

Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う で API レスポンスを Dart の型へ寄せ、Flutterの状態管理入門(Riverpod最小構成)Flutterでローディング・空状態・エラー表示を整える で画面の受け止め方を揃えたら、次に整理したいのが分岐の持ち方です。成功、0件、未認証、通信失敗を enumString? errorMessage のように並べていくと、状態が増えるほど「どの値がどの状態で有効なのか」が読み取りにくくなります。この記事では Dart 3 の sealed classswitch 式のパターンマッチングを使い、API 結果の分岐を型で表しながら安全に書く最小構成を確認します。

1. ゴールと非対象

対象読者

  • Flutter の REST API 通信、JSON モデル生成、Riverpod、ローディング / 空状態 / エラー表示の入口までは終わっている人
  • 状態が増えるにつれて enum と補助変数の組み合わせが重くなってきた人
  • Dart 3 の sealed class を見かけるが、実際にどこへ使うかまだ固まっていない人

この記事で到達する状態

  • sealed class と派生型で API 結果を表現できる
  • switch 式で Loading / Success / Empty / Unauthorized / NetworkError を網羅的に分岐できる
  • パターンマッチングで Success(:final data) のように値を取り出せる
  • when 相当のヘルパーと switch 式の使い分けを説明できる
  • 既存の REST API / Riverpod / ローディング記事へ、どこから組み込むか判断できる

非対象

  • freezed の導入とコード生成
  • 実 API 通信や dio の説明
  • Riverpod Generator や AsyncNotifier
  • 認証ガードやフォームバリデーションの実装
  • 複数ファイル分割やレイヤー設計の深掘り

今回は Dart 3 の分岐表現に絞ります。状態管理ライブラリの詳説へ広げず、まずは「状態ごとに必要な値が違うなら、型ごとに分けて受ける」という土台づくりに集中します。

2. enumnull だけでは意味が崩れやすい理由

たとえば API 呼び出し結果を次のように持ち始めることがあります。

  • status = success
  • items = [...]
  • errorMessage = null

この形は最初の 2 状態くらいなら読めます。問題は、状態が増えるにつれて「どの値がその状態で意味を持つのか」が崩れやすいところです。

  • 成功時だけ items が必要
  • 通信失敗時だけ errorMessage が必要
  • 未認証時は再ログイン導線が必要
  • 0件は失敗ではないため、エラーメッセージとは別扱いにしたい

状態ごとに必要な情報が違うなら、1つの型へ押し込むより分けたほうが読みやすくなります。Dart 3 の sealed class は、この「分けた型を switch で漏れなく受ける」ための土台です。

3. 先に分岐の流れを掴む

今回のサンプルでは、擬似 Repository が ApiResult<List<Shipment>> を返し、UI は switch 式で受け取ります。

flowchart LR
  A[画面で状態を選ぶ] --> B[ShipmentRepository.fetchShipments]
  B --> C{ApiResult<List<Shipment>>}
  C -->|Loading| D[switch式でローディングUI]
  C -->|Success| E[switch式で一覧UI]
  C -->|Empty| F[switch式で0件UI]
  C -->|Unauthorized| G[switch式で再ログイン導線]
  C -->|NetworkError| H[switch式でエラーUI]
  C --> I[when相当ヘルパーで要約文を生成]

見るべき点は 2 つです。

  • 結果を sealed class のどれか 1 つとして返す
  • 画面側では switch の各アームを埋め切り、分岐漏れを防ぐ

success かどうかだけを見る形より、状態名と必要データが 1 対 1 で結びつくため、後から状態が増えても読みやすさを保ちやすい構造です。

4. lib/main.dart を作って ApiResult<T> を表現する

このサンプルは flutter run で確認できます。外部パッケージ不要なので、DartPad でも確認できます。

lib/main.dart は次の内容で作成します。ApiResult<T> の型定義、when 相当 extension、UI の switch 分岐までを 1 ファイルにまとめました。先に ApiResult<T> を定義しておくと、その後の分岐を追いやすくなります。

import 'dart:async';

import 'package:flutter/material.dart';

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

enum DemoScenario {
  success('成功'),
  empty('0件'),
  unauthorized('認証切れ'),
  networkError('通信失敗');

  const DemoScenario(this.label);

  final String label;
}

class Shipment {
  const Shipment({
    required this.code,
    required this.customerName,
    required this.status,
  });

  final String code;
  final String customerName;
  final String status;
}

sealed class ApiResult<T> {
  const ApiResult();
}

final class Loading<T> extends ApiResult<T> {
  const Loading();
}

final class Success<T> extends ApiResult<T> {
  const Success(this.data);

  final T data;
}

final class Empty<T> extends ApiResult<T> {
  const Empty();
}

final class Unauthorized<T> extends ApiResult<T> {
  const Unauthorized();
}

final class NetworkError<T> extends ApiResult<T> {
  const NetworkError(this.message);

  final String message;
}

extension ApiResultWhen<T> on ApiResult<T> {
  R when<R>({
    required R Function() loading,
    required R Function(T data) success,
    required R Function() empty,
    required R Function() unauthorized,
    required R Function(String message) networkError,
  }) {
    return switch (this) {
      Loading<T>() => loading(),
      Success<T>(:final data) => success(data),
      Empty<T>() => empty(),
      Unauthorized<T>() => unauthorized(),
      NetworkError<T>(:final message) => networkError(message),
    };
  }
}

class ShipmentRepository {
  static const List<Shipment> _sampleShipments = <Shipment>[
    Shipment(
      code: 'S-1001',
      customerName: '東京商事',
      status: '未出荷',
    ),
    Shipment(
      code: 'S-1002',
      customerName: '大阪物産',
      status: 'ピッキング中',
    ),
    Shipment(
      code: 'S-1003',
      customerName: '名古屋販売',
      status: '確認待ち',
    ),
  ];

  Future<ApiResult<List<Shipment>>> fetchShipments(DemoScenario scenario) async {
    await Future<void>.delayed(const Duration(milliseconds: 800));

    return switch (scenario) {
      DemoScenario.success =>
        const Success<List<Shipment>>(_sampleShipments),
      DemoScenario.empty => const Empty<List<Shipment>>(),
      DemoScenario.unauthorized => const Unauthorized<List<Shipment>>(),
      DemoScenario.networkError => const NetworkError<List<Shipment>>(
        '倉庫サーバーへ接続できませんでした。Wi-Fi と API サーバー状態を確認してください。',
      ),
    };
  }
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Sealed Class Demo',
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
      ),
      home: const SealedClassDemoPage(),
    );
  }
}

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

  @override
  State<SealedClassDemoPage> createState() => _SealedClassDemoPageState();
}

class _SealedClassDemoPageState extends State<SealedClassDemoPage> {
  final ShipmentRepository _repository = ShipmentRepository();

  DemoScenario _selectedScenario = DemoScenario.success;
  ApiResult<List<Shipment>> _result = const Loading<List<Shipment>>();

  @override
  void initState() {
    super.initState();
    unawaited(_reload());
  }

  Future<void> _reload() async {
    setState(() {
      _result = const Loading<List<Shipment>>();
    });

    final ApiResult<List<Shipment>> next = await _repository.fetchShipments(
      _selectedScenario,
    );

    if (!mounted) {
      return;
    }

    setState(() {
      _result = next;
    });
  }

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

    final String switchSummary = switch (_result) {
      Loading<List<Shipment>>() => 'switch: 読み込み中を表示します。',
      Success<List<Shipment>>(:final data) when data.length == 1 =>
        'switch: 1件の出荷データを表示します。',
      Success<List<Shipment>>(:final data) =>
        'switch: ${data.length}件の出荷データを表示します。',
      Empty<List<Shipment>>() => 'switch: 取得成功だが 0 件です。',
      Unauthorized<List<Shipment>>() =>
        'switch: ログイン画面へ戻す分岐を選べます。',
      NetworkError<List<Shipment>>(:final message) =>
        'switch: 通信失敗です。$message',
    };

    final String whenSummary = _result.when(
      loading: () => 'when相当: ローディング文言を返します。',
      success: (List<Shipment> data) =>
        'when相当: 成功時だけ data を受け取り、${data.length}件として扱えます。',
      empty: () => 'when相当: 0件専用の文言を返します。',
      unauthorized: () => 'when相当: 未認証専用の導線へつなげられます。',
      networkError: (String message) =>
        'when相当: エラー内容を受け取り、短い要約へ流せます。$message',
    );

    final String firstShipmentLabel = switch (_result) {
      Success<List<Shipment>>(
        data: [Shipment(:final code, :final customerName, :final status), ...],
      ) => '先頭データ: $code / $customerName / $status',
      Success<List<Shipment>>() => '先頭データ: 成功だが一覧は空です。',
      Loading<List<Shipment>>() => '先頭データ: 読み込み後に表示します。',
      Empty<List<Shipment>>() => '先頭データ: 0件です。',
      Unauthorized<List<Shipment>>() => '先頭データ: 未認証のためありません。',
      NetworkError<List<Shipment>>() => '先頭データ: 通信失敗のためありません。',
    };

    return Scaffold(
      appBar: AppBar(
        title: const Text('sealed class で API 結果を分ける'),
        actions: <Widget>[
          IconButton(
            onPressed: _reload,
            icon: const Icon(Icons.refresh),
            tooltip: '再読み込み',
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Text(
            '状態を切り替えると、同じ ApiResult<List<Shipment>> を switch と when相当でどう扱うか確認できます。',
            style: theme.textTheme.bodyLarge,
          ),
          const SizedBox(height: 16),
          ScenarioSelector(
            selectedScenario: _selectedScenario,
            onSelected: (DemoScenario scenario) {
              setState(() {
                _selectedScenario = scenario;
              });
              unawaited(_reload());
            },
          ),
          const SizedBox(height: 16),
          InfoCard(
            title: 'switch式の要約',
            message: switchSummary,
          ),
          const SizedBox(height: 12),
          InfoCard(
            title: 'when相当の要約',
            message: whenSummary,
          ),
          const SizedBox(height: 12),
          InfoCard(
            title: 'リストパターンの例',
            message: firstShipmentLabel,
          ),
          const SizedBox(height: 24),
          _buildResultPane(theme),
        ],
      ),
    );
  }

  Widget _buildResultPane(ThemeData theme) {
    return switch (_result) {
      Loading<List<Shipment>>() => const StateCard(
        icon: Icons.hourglass_bottom,
        title: '読み込み中',
        message: 'API 応答待ちの間は Loading をそのまま UI へ渡します。',
        showProgress: true,
      ),
      Success<List<Shipment>>(:final data) => Card(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(
                '出荷一覧',
                style: theme.textTheme.titleMedium,
              ),
              const SizedBox(height: 8),
              Text('取得件数: ${data.length}件'),
              const SizedBox(height: 12),
              for (final Shipment shipment in data)
                Padding(
                  padding: const EdgeInsets.only(bottom: 12),
                  child: ShipmentCard(shipment: shipment),
                ),
            ],
          ),
        ),
      ),
      Empty<List<Shipment>>() => const StateCard(
        icon: Icons.inbox_outlined,
        title: '出荷データがありません',
        message: '0件は失敗ではないため、空状態専用の UI を出します。',
      ),
      Unauthorized<List<Shipment>>() => StateCard(
        icon: Icons.lock_outline,
        title: 'ログインし直してください',
        message: '401 を例外文字列へ押し込めず、未認証専用の分岐として扱えます。',
        actionLabel: 'ログイン画面へ戻る',
        onAction: () {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text('ここでログイン画面へ遷移します。')),
          );
        },
      ),
      NetworkError<List<Shipment>>(:final message) => StateCard(
        icon: Icons.cloud_off,
        title: '通信に失敗しました',
        message: message,
        actionLabel: '再試行する',
        onAction: _reload,
      ),
    };
  }
}

class ScenarioSelector extends StatelessWidget {
  const ScenarioSelector({
    super.key,
    required this.selectedScenario,
    required this.onSelected,
  });

  final DemoScenario selectedScenario;
  final ValueChanged<DemoScenario> onSelected;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Text(
          '擬似 API の結果を切り替える',
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 12),
        SingleChildScrollView(
          scrollDirection: Axis.horizontal,
          child: SegmentedButton<DemoScenario>(
            segments: DemoScenario.values
                .map(
                  (DemoScenario scenario) => ButtonSegment<DemoScenario>(
                    value: scenario,
                    label: Text(
                      scenario.label,
                      softWrap: false,
                    ),
                  ),
                )
                .toList(growable: false),
            selected: <DemoScenario>{selectedScenario},
            onSelectionChanged: (Set<DemoScenario> values) {
              onSelected(values.first);
            },
          ),
        ),
      ],
    );
  }
}

class InfoCard extends StatelessWidget {
  const InfoCard({
    super.key,
    required this.title,
    required this.message,
  });

  final String title;
  final String message;

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

class StateCard extends StatelessWidget {
  const StateCard({
    super.key,
    required this.icon,
    required this.title,
    required this.message,
    this.actionLabel,
    this.onAction,
    this.showProgress = false,
  });

  final IconData icon;
  final String title;
  final String message;
  final String? actionLabel;
  final VoidCallback? onAction;
  final bool showProgress;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: <Widget>[
            Icon(icon, size: 40),
            const SizedBox(height: 12),
            Text(
              title,
              style: Theme.of(context).textTheme.titleMedium,
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 8),
            Text(
              message,
              textAlign: TextAlign.center,
            ),
            if (showProgress) ...<Widget>[
              const SizedBox(height: 16),
              const CircularProgressIndicator(),
            ],
            if (actionLabel != null && onAction != null) ...<Widget>[
              const SizedBox(height: 16),
              FilledButton(
                onPressed: onAction,
                child: Text(actionLabel!),
              ),
            ],
          ],
        ),
      ),
    );
  }
}

class ShipmentCard extends StatelessWidget {
  const ShipmentCard({
    super.key,
    required this.shipment,
  });

  final Shipment shipment;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(12),
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
      ),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              shipment.code,
              style: Theme.of(context).textTheme.titleSmall,
            ),
            const SizedBox(height: 4),
            Text('取引先: ${shipment.customerName}'),
            Text('状態: ${shipment.status}'),
          ],
        ),
      ),
    );
  }
}

コードのポイント

sealed class で継承を封じ、final class で状態を列挙する

sealed class ApiResult<T> {
  const ApiResult();
}

final class Success<T> extends ApiResult<T> {
  const Success(this.data);

  final T data;
}

sealed を付けると、同一ライブラリ外からの継承が禁止されます。派生型が Loading / Success / Empty / Unauthorized / NetworkError の 5 つだけと確定するため、switch の網羅チェックが機能します。final class にすることで、派生型をさらに継承する道も閉じています。

② 状態ごとに必要な値だけを持たせる

final class Success<T> extends ApiResult<T> {
  const Success(this.data);
  final T data;
}

final class NetworkError<T> extends ApiResult<T> {
  const NetworkError(this.message);
  final String message;
}

Success だけが data を持ち、NetworkError だけが message を持ちます。LoadingEmpty は値を持ちません。1 つの型に全フィールドを押し込む構造と違い、「成功ではないのに items が残っている」といった状態の混線が構造上起きません。

when 相当 extension の中身も switch

return switch (this) {
  Loading<T>() => loading(),
  Success<T>(:final data) => success(data),
  Empty<T>() => empty(),
  Unauthorized<T>() => unauthorized(),
  NetworkError<T>(:final message) => networkError(message),
};

Success<T>(:final data) はオブジェクトパターンによる値の取り出しです。data フィールドを直接束縛し、アーム内でそのまま使えます。when 相当ヘルパーの実装も結局この switch 式なので、Dart 3 では専用構文がなくても同じ考え方で組み立てられます。

5. switch 式で網羅的に分岐する

上のコードで見るべき switch は 3 か所あります。

  • switchSummary: 文言を返す switch
  • firstShipmentLabel: オブジェクトパターンとリストパターンを使う switch
  • _buildResultPane(): Widget 自体を返す switch

たとえば次の形です。

  • Success<List<Shipment>>(:final data)
    • Success のときだけ data を取り出す
  • when data.length == 1
    • ガードを付けて、同じ型でも追加条件で分岐する
  • data: [Shipment(:final code, :final customerName, :final status), ...]
    • Success の中身が 1 件以上あるときだけ、先頭要素の値を取り出す

sealed class を使っているため、switch の各アームを埋め切らないとコンパイルエラーになります。新しい状態を 1 つ足したときに、どこで分岐漏れが起きているかを IDE がすぐ教えてくれる点が大きな利点です。

Widget を返す switch 式の読み方も同じです。

  • Loading ならローディングカード
  • Success なら一覧
  • Empty なら 0 件専用カード
  • Unauthorized なら再ログイン導線
  • NetworkError なら再試行付きエラーカード

初回読み込みの Loading は一瞬で切り替わるため、ここでは見分けやすい 4 状態を載せます。要約カードと下部の結果ペインが、同じ ApiResult<T> に合わせて切り替わる点を見るのがコツです。

成功時は Success<List<Shipment>> から件数、要約、一覧の 3 か所をまとめて組み立てます。

成功時の一覧表示

0件時は失敗扱いにせず、Empty<List<Shipment>> 専用の空状態 UI を出します。

0件時の空状態表示

認証切れでは Unauthorized<List<Shipment>> に応じて、一覧の代わりに再ログイン導線を見せます。

認証切れ時の再ログイン導線

通信失敗では NetworkError<List<Shipment>>message を受け取り、要約カードと再試行ボタン付きのエラー UI へ流します。

通信失敗時の再試行 UI

この形にしておくと、状態が増えたときも if の入れ子を増やさずに済みます。分岐の種類が UI と 1 対 1 に対応するので、レビュー時も確認しやすい構造です。

6. when 相当の書き方とどう使い分けるか

コード内では extension ApiResultWhen<T> として when 相当のヘルパーも入れています。これは freezedwhen に近い書き味です。

書き方向いている場面特徴
switch分岐そのものをその場で読みたいとき言語機能なので網羅性が見えやすい
when 相当要約文や値変換を短く書きたいとき呼び出し側が揃うが、実装は別場所へ移る

今回のサンプルでは、Widget 分岐や件数サマリーは switch 式で書き、短い説明文は when 相当で返しています。この使い分けにすると、分岐ロジックの中心は switch に残しつつ、繰り返し書きたくない変換だけをヘルパーへ寄せられます。

when 相当を使う場合でも、実装の中身は結局 switch です。つまり Dart 3 では、when 専用の仕組みがなくても、sealed classswitch があれば同じ考え方を組み立てられます。

7. 既存記事とどうつなげるか

この書き方は、既存の Flutter 記事へ次のように持ち込めます。

非同期状態と業務状態を 1 つの enum へ詰め込むより、段階ごとに型を分けたほうが分岐の責務がはっきりします。まずは今ある API 呼び出しの戻り値を 1 つ選び、sealed classswitch へ置き換えるところから始めると取り入れやすくなります。

シリーズ 21/38

このシリーズ

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最小構成)