公開日 2026-08-06

Flutterで複数画像の添付UIを作る

Flutterでギャラリーから複数画像を選び、上限枚数の制御、サムネイル一覧、削除、差し替えまでを1画面で確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先に添付フローを整理する
  6. 3. プロジェクトを作成し、確認環境を用意する
  7. 3-1. Flutter の環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. image_picker を追加する
  11. 3-5. エミュレーターへサンプル画像を入れる
  12. 4. lib/main.dart に複数画像添付 UI を実装する
  13. コードのポイント
  14. 5. flutter run で動かして確認するポイント
  15. 6. まとめ

Flutterで単一画面の入力フローを作る の次に固めたいのが、1 件の入力へ画像を複数添付する UI です。業務アプリでは、検品写真、現場写真、補助画像を 1 枚だけで済ませない場面がよくあります。この記事では image_picker を使い、ギャラリーからの複数選択、上限枚数の制御、サムネイル一覧、削除、差し替えまでを 1 画面で確認します。

1. ゴールと非対象

対象読者

  • Flutter プロジェクトを作成して flutter run した経験がある人
  • Flutterで単一画面の入力フローを作る の次に、画像添付の UI を足したい人
  • ファイル出力やアップロードの前に、まず添付一覧の扱いを固めたい人

この記事で到達する状態

  • image_picker でギャラリーから複数画像を選択できる
  • 添付上限を超えた分を UI 側で抑止できる
  • サムネイル一覧をグリッド表示し、各画像を削除できる
  • 1 枚ずつ差し替えながら添付内容を整えられる
  • 後続のファイル出力やアップロードへつなぎやすい一覧状態を保持できる

非対象

  • カメラ起動による撮影
  • Multipart での画像アップロード
  • ローカルファイル出力や CSV 生成
  • permission_handler による明示的な権限要求
  • Riverpod などの状態管理ライブラリ

今回は「添付一覧をどう持つか」と「利用者がどう編集するか」に絞ります。撮影、保存、送信は後続の段階へ分けます。

2. 先に添付フローを整理する

今回の流れは次の通りです。

flowchart TD
  A[登録対象レコードを確認する] --> B[画像を複数選択する]
  B --> C{上限枚数を超えるか}
  C -->|はい| D[残り枠に入る分だけ追加する]
  C -->|いいえ| E[選択分をそのまま追加する]
  D --> F[サムネイルグリッドへ反映する]
  E --> F
  F --> G{並びを見て修正が必要か}
  G -->|削除| H[対象画像を削除する]
  G -->|差し替え| I[1 枚だけ再選択する]
  H --> F
  I --> F
  F --> J{最低枚数を満たすか}
  J -->|いいえ| K[確定不可のまま待つ]
  J -->|はい| L[添付内容を確定する]

ここで先に決めておきたいのは、添付一覧の責務です。

  • 画像選択は「一覧へ追加する操作」に限定する
  • 上限判定は選択後に UI 側で一元管理する
  • 差し替えは「1 枚を別画像へ入れ替える操作」として分ける
  • 確定時には現在の添付一覧をスナップショットとして保存する

この形にしておくと、次の記事で data.csv や画像ファイル出力を足す時も、どの時点の一覧を保存対象にするかが曖昧になりません。

3. プロジェクトを作成し、確認環境を用意する

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

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

このサンプルは flutter run で確認できます。

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

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

flutter create my_multi_image_attachment_app
cd my_multi_image_attachment_app

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

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

flutter emulators

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

flutter emulators --launch <emulator_id>

3-4. image_picker を追加する

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

flutter pub add image_picker

今回はギャラリーからの選択に絞るため、追加パッケージは image_picker だけです。明示的な権限分岐や撮影は扱いません。

3-5. エミュレーターへサンプル画像を入れる

エミュレーターに画像が入っていない場合は、手元の JPG または PNG をエミュレーター画面へドラッグ&ドロップしてください。投入後に Photos または Files アプリで見えることを確認しておくと、後のギャラリー選択が止まりません。

検証用の画像をすぐ用意したい場合は、下のボタンからサンプル PNG を新しいタブで開きます。画像を保存し、エミュレーターへ入れてください。

端末側に複数枚を選べる画像ソースがあれば準備完了です。

サンプル PNG を入れたあと、Downloads に 4 枚並んでいれば準備完了です。

Downloads に検証用 PNG を 4 枚入れた状態

4. lib/main.dart に複数画像添付 UI を実装する

lib/main.dart は次の内容で作成します。

このファイルは、登録対象カード、複数画像の追加、上限制御、重複除外、差し替え、削除、確定履歴までを 1 画面で確認するサンプルです。注目点は _appendPickedImages() で上限と重複をまとめて扱い、_saveCurrentAttachments() で現在の一覧を別リストへ複写しているところにあります。

import 'dart:io';

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

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

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

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

class WorkRecord {
  const WorkRecord({
    required this.inspectionId,
    required this.itemName,
    required this.lotNumber,
    required this.storageLane,
  });

  final String inspectionId;
  final String itemName;
  final String lotNumber;
  final String storageLane;
}

class AttachmentItem {
  const AttachmentItem({
    required this.id,
    required this.file,
    required this.addedAt,
  });

  final String id;
  final XFile file;
  final DateTime addedAt;

  String get fileName {
    if (file.name.isNotEmpty) {
      return file.name;
    }

    return file.path.split(RegExp(r'[\\/]')).last;
  }
}

class SavedAttachmentBatch {
  const SavedAttachmentBatch({
    required this.record,
    required this.items,
    required this.savedAt,
  });

  final WorkRecord record;
  final List<AttachmentItem> items;
  final DateTime savedAt;
}

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

  @override
  State<MultiImageAttachmentPage> createState() =>
      _MultiImageAttachmentPageState();
}

class _MultiImageAttachmentPageState extends State<MultiImageAttachmentPage> {
  static const int _minAttachmentCount = 1;
  static const int _maxAttachmentCount = 4;

  final ImagePicker _picker = ImagePicker();
  final WorkRecord _record = const WorkRecord(
    inspectionId: 'INSP-240315-001',
    itemName: '冷蔵ケース部品 A',
    lotNumber: 'LOT-240315-A',
    storageLane: 'A-03',
  );

  List<AttachmentItem> _currentItems = <AttachmentItem>[];
  final List<SavedAttachmentBatch> _savedBatches = <SavedAttachmentBatch>[];
  bool _isPicking = false;

  int get _remainingSlots => _maxAttachmentCount - _currentItems.length;

  bool get _canSave => _currentItems.length >= _minAttachmentCount;

  Future<void> _pickMultipleImages() async {
    if (_remainingSlots <= 0) {
      _showSnackBar('上限 $_maxAttachmentCount 枚に達しています。差し替えか削除を行ってください。');
      return;
    }

    setState(() {
      _isPicking = true;
    });

    try {
      final List<XFile> picked = await _picker.pickMultiImage(imageQuality: 85);
      if (picked.isEmpty) {
        return;
      }

      _appendPickedImages(picked);
    } finally {
      if (!mounted) {
        return;
      }

      setState(() {
        _isPicking = false;
      });
    }
  }

  void _appendPickedImages(List<XFile> picked) {
    final Set<String> existingPaths =
        _currentItems.map((AttachmentItem item) => item.file.path).toSet();
    final List<XFile> uniqueIncoming = <XFile>[];

    for (final XFile file in picked) {
      final bool isDuplicate =
          existingPaths.contains(file.path) ||
          uniqueIncoming.any((XFile incoming) => incoming.path == file.path);
      if (isDuplicate) {
        continue;
      }
      uniqueIncoming.add(file);
    }

    if (uniqueIncoming.isEmpty) {
      _showSnackBar('追加できる新しい画像がありませんでした。');
      return;
    }

    final List<XFile> accepted = uniqueIncoming.take(_remainingSlots).toList();
    final DateTime now = DateTime.now();

    setState(() {
      _currentItems = <AttachmentItem>[
        ..._currentItems,
        for (int index = 0; index < accepted.length; index++)
          AttachmentItem(
            id: '${now.microsecondsSinceEpoch}-$index',
            file: accepted[index],
            addedAt: now,
          ),
      ];
    });

    if (accepted.length < uniqueIncoming.length) {
      _showSnackBar(
        '上限 $_maxAttachmentCount 枚のため、${accepted.length} 枚だけ追加しました。',
      );
      return;
    }

    _showSnackBar('${accepted.length} 枚の画像を追加しました。');
  }

  Future<void> _replaceImage(int index) async {
    final XFile? replacement = await _picker.pickImage(
      source: ImageSource.gallery,
      imageQuality: 85,
    );

    if (replacement == null) {
      return;
    }

    final bool alreadyUsed = _currentItems
        .asMap()
        .entries
        .any((MapEntry<int, AttachmentItem> entry) {
          return entry.key != index && entry.value.file.path == replacement.path;
        });

    if (alreadyUsed) {
      _showSnackBar('同じ画像がすでに添付されています。別の画像を選んでください。');
      return;
    }

    final DateTime now = DateTime.now();
    setState(() {
      final AttachmentItem current = _currentItems[index];
      _currentItems = <AttachmentItem>[
        ..._currentItems.sublist(0, index),
        AttachmentItem(
          id: current.id,
          file: replacement,
          addedAt: now,
        ),
        ..._currentItems.sublist(index + 1),
      ];
    });

    _showSnackBar('画像を差し替えました。');
  }

  void _removeImage(int index) {
    final String fileName = _currentItems[index].fileName;
    setState(() {
      _currentItems = <AttachmentItem>[
        ..._currentItems.sublist(0, index),
        ..._currentItems.sublist(index + 1),
      ];
    });

    _showSnackBar('$fileName を削除しました。');
  }

  void _clearAllImages() {
    if (_currentItems.isEmpty) {
      return;
    }

    setState(() {
      _currentItems = <AttachmentItem>[];
    });

    _showSnackBar('現在の添付一覧をクリアしました。');
  }

  void _saveCurrentAttachments() {
    if (!_canSave) {
      _showSnackBar('最低 1 枚の画像を追加してください。');
      return;
    }

    final DateTime now = DateTime.now();
    setState(() {
      _savedBatches.insert(
        0,
        SavedAttachmentBatch(
          record: _record,
          items: List<AttachmentItem>.unmodifiable(_currentItems),
          savedAt: now,
        ),
      );
      _currentItems = <AttachmentItem>[];
    });

    _showSnackBar('添付内容を確定しました。次の入力へ進めます。');
  }

  void _showSnackBar(String message) {
    ScaffoldMessenger.of(context)
      ..hideCurrentSnackBar()
      ..showSnackBar(SnackBar(content: Text(message)));
  }

  String _formatDateTime(DateTime value) {
    String twoDigits(int number) => number.toString().padLeft(2, '0');

    return '${value.year}-${twoDigits(value.month)}-${twoDigits(value.day)} '
        '${twoDigits(value.hour)}:${twoDigits(value.minute)}';
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('複数画像添付 UI デモ'),
      ),
      body: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              _RecordSummaryCard(record: _record),
              const SizedBox(height: 16),
              _buildAttachmentHeader(),
              const SizedBox(height: 12),
              _buildAttachmentBody(),
              const SizedBox(height: 16),
              FilledButton.icon(
                onPressed: _canSave ? _saveCurrentAttachments : null,
                icon: const Icon(Icons.task_alt),
                label: const Text('添付内容を確定する'),
              ),
              const SizedBox(height: 24),
              Text(
                '確定履歴',
                style: Theme.of(context).textTheme.titleLarge,
              ),
              const SizedBox(height: 8),
              if (_savedBatches.isEmpty)
                const Text('まだ確定履歴はありません。画像を 1 枚以上添付してから確定してください。')
              else
                Column(
                  children: _savedBatches.map(_buildSavedBatchCard).toList(),
                ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildAttachmentHeader() {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Text(
          '添付画像',
          style: Theme.of(context).textTheme.titleLarge,
        ),
        const SizedBox(height: 8),
        Text(
          '最低 $_minAttachmentCount 枚、最大 $_maxAttachmentCount 枚まで添付できます。'
          ' 現在は ${_currentItems.length} 枚で、残り $_remainingSlots 枚です。',
        ),
        const SizedBox(height: 12),
        Wrap(
          spacing: 8,
          runSpacing: 8,
          children: <Widget>[
            FilledButton.icon(
              onPressed: _isPicking ? null : _pickMultipleImages,
              icon: const Icon(Icons.photo_library_outlined),
              label: Text(_isPicking ? '画像を選択中...' : '画像を追加する'),
            ),
            OutlinedButton.icon(
              onPressed: _currentItems.isEmpty ? null : _clearAllImages,
              icon: const Icon(Icons.clear_all),
              label: const Text('一覧をクリアする'),
            ),
          ],
        ),
      ],
    );
  }

  Widget _buildAttachmentBody() {
    if (_currentItems.isEmpty) {
      return Container(
        width: double.infinity,
        padding: const EdgeInsets.all(20),
        decoration: BoxDecoration(
          color: Colors.teal.shade50,
          borderRadius: BorderRadius.circular(16),
          border: Border.all(color: Colors.teal.shade100),
        ),
        child: const Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Icon(Icons.add_photo_alternate_outlined, size: 32),
            SizedBox(height: 12),
            Text(
              'まだ画像は添付されていません。',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 8),
            Text(
              'まずはギャラリーから複数画像を選び、必要に応じて削除や差し替えを行います。',
            ),
          ],
        ),
      );
    }

    return GridView.builder(
      shrinkWrap: true,
      physics: const NeverScrollableScrollPhysics(),
      itemCount: _currentItems.length,
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        mainAxisSpacing: 12,
        crossAxisSpacing: 12,
        childAspectRatio: 0.78,
      ),
      itemBuilder: (BuildContext context, int index) {
        final AttachmentItem item = _currentItems[index];

        return Card(
          clipBehavior: Clip.antiAlias,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              Expanded(
                child: Image.file(
                  File(item.file.path),
                  fit: BoxFit.cover,
                  errorBuilder: (BuildContext context, Object error, StackTrace? stackTrace) {
                    return const ColoredBox(
                      color: Color(0xFFE5E7EB),
                      child: Center(
                        child: Icon(Icons.broken_image_outlined, size: 36),
                      ),
                    );
                  },
                ),
              ),
              Padding(
                padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: <Widget>[
                    Text(
                      item.fileName,
                      maxLines: 1,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(fontWeight: FontWeight.w600),
                    ),
                    const SizedBox(height: 4),
                    Text(
                      '追加: ${_formatDateTime(item.addedAt)}',
                      style: Theme.of(context).textTheme.bodySmall,
                    ),
                  ],
                ),
              ),
              Padding(
                padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
                child: Row(
                  children: <Widget>[
                    Expanded(
                      child: TextButton.icon(
                        onPressed: () => _replaceImage(index),
                        icon: const Icon(Icons.swap_horiz),
                        label: const Text('差し替え'),
                      ),
                    ),
                    IconButton(
                      onPressed: () => _removeImage(index),
                      icon: const Icon(Icons.delete_outline),
                      tooltip: '削除',
                    ),
                  ],
                ),
              ),
            ],
          ),
        );
      },
    );
  }

  Widget _buildSavedBatchCard(SavedAttachmentBatch batch) {
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              '${batch.record.inspectionId}${batch.items.length} 枚で確定',
              style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
            ),
            const SizedBox(height: 6),
            Text('保存時刻: ${_formatDateTime(batch.savedAt)}'),
            const SizedBox(height: 8),
            Text(
              batch.items.map((AttachmentItem item) => item.fileName).join(' / '),
            ),
          ],
        ),
      ),
    );
  }
}

class _RecordSummaryCard extends StatelessWidget {
  const _RecordSummaryCard({required this.record});

  final WorkRecord record;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(
              '登録対象',
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 12),
            _SummaryRow(label: '検品ID', value: record.inspectionId),
            _SummaryRow(label: '品目', value: record.itemName),
            _SummaryRow(label: 'ロット', value: record.lotNumber),
            _SummaryRow(label: '保管レーン', value: record.storageLane),
          ],
        ),
      ),
    );
  }
}

class _SummaryRow extends StatelessWidget {
  const _SummaryRow({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          SizedBox(
            width: 88,
            child: Text(
              label,
              style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                    color: Colors.grey.shade700,
                  ),
            ),
          ),
          Expanded(
            child: Text(
              value,
              style: const TextStyle(fontWeight: FontWeight.w600),
            ),
          ),
        ],
      ),
    );
  }
}

コードのポイント

① 上限枚数と重複除外を 1 か所で扱う

void _appendPickedImages(List<XFile> picked) {
  final Set<String> existingPaths =
      _currentItems.map((AttachmentItem item) => item.file.path).toSet();
  final List<XFile> uniqueIncoming = <XFile>[];

  for (final XFile file in picked) {
    final bool isDuplicate =
        existingPaths.contains(file.path) ||
        uniqueIncoming.any((XFile incoming) => incoming.path == file.path);
    if (isDuplicate) {
      continue;
    }
    uniqueIncoming.add(file);
  }

  final List<XFile> accepted = uniqueIncoming.take(_remainingSlots).toList();
  // accepted だけを一覧へ追加する
}

添付上限の判定と重複除外を別々の場所へ散らすと、後から挙動を追いにくくなります。ここでは「追加してよい画像だけを最終的に返す」形に寄せ、一覧更新前の判断を 1 か所へ集めています。

② 差し替えは 1 枚選択に分ける

Future<void> _replaceImage(int index) async {
  final XFile? replacement = await _picker.pickImage(
    source: ImageSource.gallery,
    imageQuality: 85,
  );

  if (replacement == null) {
    return;
  }

  // 他の添付画像と重複しないか確認してから index の要素だけ置き換える
}

複数選択と差し替えを同じ操作にすると、「どの画像を差し替えたのか」が利用者に伝わりにくくなります。差し替えだけ pickImage() に分けると、対象カードごとの再編集として理解しやすくなります。

③ 確定時は現在の一覧をスナップショットにする

SavedAttachmentBatch(
  record: _record,
  items: List<AttachmentItem>.unmodifiable(_currentItems),
  savedAt: now,
)

そのまま同じリスト参照を持つと、確定後に現在の添付一覧を消した時、履歴側まで空になる危険があります。List.unmodifiable() でその時点の一覧を切り出しておくと、後続のファイル出力やアップロード処理でも扱いやすくなります。

5. flutter run で動かして確認するポイント

コードを貼り付けたら、次のコマンドでアプリを起動します。

flutter run

起動直後は、登録対象カード、添付画像の空状態、確定履歴の空状態が 1 画面に並びます。まずは追加前の見え方を確認しておくと、その後の差分を追いやすくなります。

起動直後の空状態

Android Emulator で見るなら、次の順で試すと流れを追いやすくなります。

  1. まず 2 枚の画像を選び、サムネイルが 2 枚並ぶことを確認する
  2. さらに 3 枚以上選び、上限 4 枚を超えた分が追加されず、スナックバーで説明されることを確認する
  3. 1 枚を差し替え、カード内のファイル名と画像が更新されることを確認する
  4. 1 枚を削除し、残り枠が増えることを確認する
  5. 1 枚以上残した状態で確定し、履歴にファイル名一覧が残ることを確認する

image_picker がギャラリー選択まで通れば、それだけで十分というわけではありません。業務アプリで詰まりやすいのは、上限超過時にどう扱うか、差し替えをどの粒度でやるか、確定後にどの一覧を保存対象にするかのほうです。

上の 5 手順に対応するスクリーンショットは次の通りです。

  1. まず 2 枚を選ぶと、グリッドに 2 枚のカードが並びます。ここでは「複数選択で追加できたか」と「追加時刻が各カードに出るか」を確認します。
2 枚選択してサムネイルが並んだ状態
  1. さらに 3 枚以上を選ぶと、一覧は 4 枚で止まり、下部のスナックバーに上限到達の説明が出ます。追加できなかった分を黙って捨てず、利用者へ伝わることが重要です。
4 枚で上限に達し、スナックバーが表示された状態
  1. 差し替えでは、対象カードだけが別画像に入れ替わり、スナックバーで操作結果が分かります。複数選択の再実行ではなく、1 枚単位の再編集として見えるかをここで確認します。
1 枚を差し替えた直後の状態
  1. 削除すると一覧枚数が減り、残り枠が戻ります。削除対象のファイル名がスナックバーに出ると、利用者はどれを消したかを見失いません。
1 枚を削除した直後の状態
  1. 1 枚以上残した状態で確定すると、現在の添付一覧は履歴カードへ残り、編集用の一覧は空に戻ります。履歴と現在編集中の一覧が混ざらないことを最後に確認します。
1 枚確定して履歴に残った状態

今回は明示的な権限分岐を扱っていません。まずは次に Flutterでデータをファイルに書き出す を読み、現在の添付一覧を data.csv と画像ファイルへどう出すかを固めるとつながります。画像選択やカメラ、Bluetooth の許可状態を UI に出し分けたい場合は、その後に permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) を読むと整理しやすくなります。

6. まとめ

複数画像添付 UI で先に固めておきたいのは、選択できることそのものより、一覧状態の持ち方です。上限、重複、差し替え、削除、確定時のスナップショットを分けておくと、次にファイル出力やアップロードへ進む時も処理の置き場がぶれません。

前段の入力フローをまだ見ていない場合は Flutterで単一画面の入力フローを作る から先に読むと入りやすくなります。添付した画像をローカルへ出したい場合は Flutterでデータをファイルに書き出す が次の導線です。権限分岐を整理したい場合は、その後に permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) を読むとつながります。

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