Flutterで複数画像の添付UIを作る の次に固めたいのが、入力結果と添付画像をローカルファイルへ書き出す流れです。業務アプリでは、画面上で確認できただけでは足りず、data.csv や添付画像をあとで渡せる形にまとめる場面が出てきます。この記事では path_provider を使い、出力先ディレクトリの決定、フォルダ名と画像名の組み立て、data.csv の生成、画像コピー、失敗時のエラー表示と再出力までを 1 画面で確認します。
1. ゴールと非対象
対象読者
- Flutter プロジェクトを作成して
flutter runした経験がある人 - Flutterで単一画面の入力フローを作る と Flutterで複数画像の添付UIを作る を一度試し、次にローカル保存の形までつなげたい人
- サーバー送信の前に、まずは端末内で出力結果を再確認できる流れを作りたい人
この記事で到達する状態
applicationDocumentsDirectory配下に出力先を作れる- フォルダ名を日時 + 検品 ID + ロット番号で組み立てられる
data.csvを 1 レコード 1 行で生成できる- 添付元ファイルをコピーして画像名をそろえられる
- 書き出し失敗時に入力を保持したまま再出力できる
非対象
image_pickerによる画像選択 UIpermission_handlerによる権限要求- 共有フォルダや外部ストレージへの保存
- API 送信や SQLite 保存
- バックグラウンド同期や ZIP 化
今回は「ローカルへどう出すか」に絞ります。画像を選ぶ UI は前の記事で固めたので、ここでは保存対象をどうまとめるかに集中します。
2. 先に出力フローを整理する
今回の書き出しフローは次の通りです。
flowchart TD
A[入力内容と添付元ファイルを確定する] --> B[出力時刻を取得する]
B --> C[フォルダ名を組み立てる]
C --> D[出力フォルダを作成する]
D --> E[data.csv を生成して書き出す]
E --> F[添付画像を連番付きでコピーする]
F --> G{最後まで成功したか}
G -->|Yes| H[出力結果を画面へ表示する]
G -->|No| I[途中フォルダを削除する]
I --> J[エラーを表示し入力は保持する]
J --> K[設定を直して再出力する]
ここで先に決めておきたいのは、書き出しの単位です。
- 1 回の出力で 1 フォルダを作る
- CSV と画像は同じフォルダへまとめる
- 画像名は「連番 + 検品 ID + ロット番号 + 用途」でそろえる
- 途中で失敗したら、半端なフォルダを削除してやり直せるようにする
この形にしておくと、後で API 送信や共有フォルダ保存へ広げる場合も、まずローカルにまとまった 1 単位をそのまま次段の入力に回せます。
3. プロジェクトを作成し、確認環境を用意する
3-1. Flutter の環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
この先は path_provider を追加し、flutter run で書き出しを確認する流れです。
3-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create my_local_file_export_app
cd my_local_file_export_app
3-3. エミュレーターを起動する
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
3-4. path_provider を追加する
プロジェクト直下で次のコマンドを実行します。
flutter pub add path_provider
今回は保存先パスの取得にだけ外部パッケージを使います。CSV 生成と画像コピーは dart:io だけで完結します。
4. lib/main.dart にローカルファイル書き出しを実装する
lib/main.dart は次の内容で作成します。
このファイルは、入力フォーム、サンプル添付元ファイルの準備、フォルダ名と画像名の組み立て、data.csv の生成、画像コピー、失敗シミュレーション、結果表示までを 1 画面で確認するサンプルです。前の記事では image_picker で XFile を扱いましたが、今回の主題は書き出し処理なので、アプリ内で最小の PNG を生成してコピー元ファイルにしています。既に XFile の一覧がある場合、path から File を作るだけで同じ流れへの差し替えが可能です。
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Local File Export Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const LocalFileExportPage(),
);
}
}
enum InspectionDecision {
accepted('合格'),
hold('保留'),
rejected('不良');
const InspectionDecision(this.label);
final String label;
}
class ExportDraft {
const ExportDraft({
required this.inspectionId,
required this.gtin,
required this.lotNumber,
required this.storageLane,
required this.operatorName,
required this.memo,
required this.decision,
});
final String inspectionId;
final String gtin;
final String lotNumber;
final String storageLane;
final String operatorName;
final String memo;
final InspectionDecision decision;
}
class PreparedAttachment {
const PreparedAttachment({
required this.label,
required this.sourceFile,
});
final String label;
final File sourceFile;
String get fileName => sourceFile.uri.pathSegments.last;
}
class ExportResult {
const ExportResult({
required this.exportDirectory,
required this.csvFile,
required this.csvPreview,
required this.exportedImages,
required this.exportedAt,
});
final Directory exportDirectory;
final File csvFile;
final String csvPreview;
final List<File> exportedImages;
final DateTime exportedAt;
}
class LocalFileExportPage extends StatefulWidget {
const LocalFileExportPage({super.key});
@override
State<LocalFileExportPage> createState() => _LocalFileExportPageState();
}
class _LocalFileExportPageState extends State<LocalFileExportPage> {
static const Map<String, String> _samplePngByLabel = <String, String>{
'front_view':
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO9lN3sAAAAASUVORK5CYII=',
'label_closeup':
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO9lN3sAAAAASUVORK5CYII=',
};
final TextEditingController _inspectionIdController =
TextEditingController(text: 'INSP-240315-001');
final TextEditingController _gtinController =
TextEditingController(text: '04912345678903');
final TextEditingController _lotNumberController =
TextEditingController(text: 'LOT-240315-A');
final TextEditingController _storageLaneController =
TextEditingController(text: 'A-03');
final TextEditingController _operatorController =
TextEditingController(text: 'sato');
final TextEditingController _memoController = TextEditingController(
text: '外箱の角を確認済み',
);
InspectionDecision _decision = InspectionDecision.accepted;
List<PreparedAttachment> _attachments = <PreparedAttachment>[];
ExportResult? _lastResult;
bool _simulateCsvFailure = false;
bool _simulateImageFailure = false;
bool _isPreparingAttachments = false;
bool _isExporting = false;
String? _statusMessage;
String? _errorMessage;
ExportDraft get _currentDraft => ExportDraft(
inspectionId: _inspectionIdController.text.trim(),
gtin: _gtinController.text.trim(),
lotNumber: _lotNumberController.text.trim(),
storageLane: _storageLaneController.text.trim(),
operatorName: _operatorController.text.trim(),
memo: _memoController.text.trim(),
decision: _decision,
);
List<String> get _blockingReasons {
final ExportDraft draft = _currentDraft;
final List<String> reasons = <String>[];
if (draft.inspectionId.isEmpty) {
reasons.add('検品 ID を入力してください。');
}
if (draft.gtin.length != 14) {
reasons.add('GTIN は 14 桁で入力してください。');
}
if (draft.lotNumber.isEmpty) {
reasons.add('ロット番号を入力してください。');
}
if (draft.storageLane.isEmpty) {
reasons.add('保管レーンを入力してください。');
}
if (draft.operatorName.isEmpty) {
reasons.add('作業者名を入力してください。');
}
if (_attachments.isEmpty) {
reasons.add('先にサンプル画像を準備してください。');
}
return reasons;
}
bool get _canExport => _blockingReasons.isEmpty && !_isExporting;
@override
void dispose() {
_inspectionIdController.dispose();
_gtinController.dispose();
_lotNumberController.dispose();
_storageLaneController.dispose();
_operatorController.dispose();
_memoController.dispose();
super.dispose();
}
Future<void> _prepareSampleAttachments() async {
setState(() {
_isPreparingAttachments = true;
});
try {
final Directory sampleDirectory = await _getSampleAttachmentDirectory();
await sampleDirectory.create(recursive: true);
final List<PreparedAttachment> attachments = <PreparedAttachment>[];
for (final MapEntry<String, String> entry in _samplePngByLabel.entries) {
final String fileName =
'${attachments.length + 1}_${_sanitizeSegment(entry.key)}.png';
final File file = File('${sampleDirectory.path}/$fileName');
await file.writeAsBytes(base64Decode(entry.value), flush: true);
attachments.add(
PreparedAttachment(
label: entry.key,
sourceFile: file,
),
);
}
if (!mounted) {
return;
}
setState(() {
_attachments = attachments;
_statusMessage = '添付元ファイルを ${attachments.length} 枚準備しました。';
_errorMessage = null;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_errorMessage = 'サンプル画像の準備に失敗しました: $error';
});
} finally {
if (!mounted) {
return;
}
setState(() {
_isPreparingAttachments = false;
});
}
}
Future<void> _exportFiles() async {
FocusScope.of(context).unfocus();
if (_blockingReasons.isNotEmpty) {
setState(() {
_errorMessage = _blockingReasons.first;
});
return;
}
final ExportDraft draft = _currentDraft;
final DateTime exportedAt = DateTime.now();
final Directory exportRoot = await _getExportRootDirectory();
final String folderName = _buildFolderName(draft, exportedAt);
final Directory exportDirectory = Directory('${exportRoot.path}/$folderName');
setState(() {
_isExporting = true;
});
try {
await exportDirectory.create(recursive: true);
if (_simulateCsvFailure) {
throw const FileSystemException(
'data.csv の書き出し失敗を再現しました。',
);
}
final String csvPreview = _buildCsvContent(
draft: draft,
attachmentCount: _attachments.length,
exportedAt: exportedAt,
);
final File csvFile = File('${exportDirectory.path}/data.csv');
await csvFile.writeAsString(csvPreview, flush: true);
final List<File> exportedImages = <File>[];
for (int index = 0; index < _attachments.length; index++) {
final PreparedAttachment attachment = _attachments[index];
if (_simulateImageFailure && index == 0) {
throw FileSystemException(
'1 枚目の画像コピー失敗を再現しました。',
attachment.sourceFile.path,
);
}
final String imageName = _buildImageFileName(
draft: draft,
attachment: attachment,
index: index,
);
final File copied = await attachment.sourceFile.copy(
'${exportDirectory.path}/$imageName',
);
exportedImages.add(copied);
}
if (!mounted) {
return;
}
setState(() {
_lastResult = ExportResult(
exportDirectory: exportDirectory,
csvFile: csvFile,
csvPreview: csvPreview,
exportedImages: exportedImages,
exportedAt: exportedAt,
);
_statusMessage =
'data.csv と画像 ${exportedImages.length} 枚を書き出しました。';
_errorMessage = null;
});
} catch (error) {
if (await exportDirectory.exists()) {
await exportDirectory.delete(recursive: true);
}
if (!mounted) {
return;
}
setState(() {
_errorMessage =
'書き出しに失敗しました。入力内容は保持したままです。トグルを戻して再出力してください。詳細: $error';
_lastResult = null;
_statusMessage = null;
});
} finally {
if (!mounted) {
return;
}
setState(() {
_isExporting = false;
});
}
}
Future<Directory> _getExportRootDirectory() async {
final Directory documentsDirectory = await getApplicationDocumentsDirectory();
final Directory exportRoot = Directory('${documentsDirectory.path}/exports');
await exportRoot.create(recursive: true);
return exportRoot;
}
Future<Directory> _getSampleAttachmentDirectory() async {
final Directory documentsDirectory = await getApplicationDocumentsDirectory();
return Directory('${documentsDirectory.path}/sample-attachments');
}
String _buildFolderName(ExportDraft draft, DateTime exportedAt) {
return '${_formatCompactTimestamp(exportedAt)}_${_sanitizeSegment(draft.inspectionId)}_${_sanitizeSegment(draft.lotNumber)}';
}
String _buildImageFileName({
required ExportDraft draft,
required PreparedAttachment attachment,
required int index,
}) {
final String extension = _fileExtension(attachment.sourceFile.path);
final String sequence = (index + 1).toString().padLeft(2, '0');
return '${sequence}_${_sanitizeSegment(draft.inspectionId)}_${_sanitizeSegment(draft.lotNumber)}_${_sanitizeSegment(attachment.label)}$extension';
}
String _buildCsvContent({
required ExportDraft draft,
required int attachmentCount,
required DateTime exportedAt,
}) {
final List<List<String>> rows = <List<String>>[
<String>[
'inspectionId',
'gtin',
'lotNumber',
'storageLane',
'operatorName',
'decision',
'memo',
'imageCount',
'exportedAt',
],
<String>[
draft.inspectionId,
draft.gtin,
draft.lotNumber,
draft.storageLane,
draft.operatorName,
draft.decision.label,
draft.memo,
attachmentCount.toString(),
exportedAt.toIso8601String(),
],
];
return rows.map(_toCsvLine).join('\n');
}
String _toCsvLine(List<String> values) {
return values.map(_escapeCsvValue).join(',');
}
String _escapeCsvValue(String value) {
final String escaped = value.replaceAll('"', '""');
return '"$escaped"';
}
String _sanitizeSegment(String value) {
return value
.trim()
.replaceAll(RegExp(r'[\\/:*?"<>|\s]+'), '-')
.replaceAll(RegExp(r'-+'), '-')
.replaceAll(RegExp(r'^-|-$'), '');
}
String _fileExtension(String path) {
final int dotIndex = path.lastIndexOf('.');
if (dotIndex < 0) {
return '.bin';
}
return path.substring(dotIndex);
}
String _formatCompactTimestamp(DateTime value) {
final String yyyy = value.year.toString().padLeft(4, '0');
final String mm = value.month.toString().padLeft(2, '0');
final String dd = value.day.toString().padLeft(2, '0');
final String hh = value.hour.toString().padLeft(2, '0');
final String min = value.minute.toString().padLeft(2, '0');
final String ss = value.second.toString().padLeft(2, '0');
return '${yyyy}${mm}${dd}_${hh}${min}${ss}';
}
@override
Widget build(BuildContext context) {
final ExportDraft draft = _currentDraft;
return Scaffold(
appBar: AppBar(
title: const Text('ローカルファイル書き出しデモ'),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'今回の出力単位',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'1 回の出力で 1 フォルダを作り、その中へ data.csv と画像をまとめます。失敗時は途中フォルダを削除し、入力内容は保持したまま再出力できる形にしています。',
),
],
),
),
),
const SizedBox(height: 16),
if (_statusMessage != null) ...<Widget>[
_MessageCard(
title: 'ステータス',
message: _statusMessage!,
color: Colors.teal,
),
const SizedBox(height: 16),
],
if (_errorMessage != null) ...<Widget>[
_MessageCard(
title: 'エラー',
message: _errorMessage!,
color: Colors.red,
),
const SizedBox(height: 16),
],
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'出力対象データ',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
TextField(
controller: _inspectionIdController,
decoration: const InputDecoration(
labelText: '検品 ID',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _gtinController,
decoration: const InputDecoration(
labelText: 'GTIN',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
),
const SizedBox(height: 12),
TextField(
controller: _lotNumberController,
decoration: const InputDecoration(
labelText: 'ロット番号',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _storageLaneController,
decoration: const InputDecoration(
labelText: '保管レーン',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _operatorController,
decoration: const InputDecoration(
labelText: '作業者名',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<InspectionDecision>(
value: _decision,
items: InspectionDecision.values
.map(
(InspectionDecision value) => DropdownMenuItem<InspectionDecision>(
value: value,
child: Text(value.label),
),
)
.toList(),
onChanged: (InspectionDecision? value) {
if (value == null) {
return;
}
setState(() {
_decision = value;
});
},
decoration: const InputDecoration(
labelText: '判定',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _memoController,
decoration: const InputDecoration(
labelText: 'メモ',
border: OutlineInputBorder(),
),
minLines: 2,
maxLines: 3,
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'添付元ファイル',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'画像選択 UI は前の記事で扱っているため、ここではアプリ内で小さな PNG を生成してコピー元にしています。',
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _isPreparingAttachments ? null : _prepareSampleAttachments,
icon: const Icon(Icons.collections),
label: Text(
_isPreparingAttachments ? '準備中...' : 'サンプル画像を準備する',
),
),
const SizedBox(height: 12),
if (_attachments.isEmpty)
const Text('まだ添付元ファイルはありません。')
else
..._attachments.map(
(PreparedAttachment attachment) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.image_outlined),
title: Text(attachment.fileName),
subtitle: Text(attachment.sourceFile.path),
),
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'書き出し前の確認',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SelectableText(
'フォルダ名プレビュー: ${_buildFolderName(draft, DateTime.now())}',
),
const SizedBox(height: 12),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('CSV 書き出し失敗を再現する'),
subtitle: const Text('ON のまま出力すると data.csv 作成前に中断します。'),
value: _simulateCsvFailure,
onChanged: (bool value) {
setState(() {
_simulateCsvFailure = value;
});
},
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('画像コピー失敗を再現する'),
subtitle: const Text('ON のまま出力すると 1 枚目の画像コピーで中断します。'),
value: _simulateImageFailure,
onChanged: (bool value) {
setState(() {
_simulateImageFailure = value;
});
},
),
if (_blockingReasons.isNotEmpty) ...<Widget>[
const SizedBox(height: 8),
Text(
'まだ書き出せない理由',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
..._blockingReasons.map(
(String reason) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('• '),
Expanded(child: Text(reason)),
],
),
),
),
],
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _canExport ? _exportFiles : null,
icon: const Icon(Icons.file_download_done_outlined),
label: Text(_isExporting ? '書き出し中...' : 'ローカルへ書き出す'),
),
],
),
),
),
const SizedBox(height: 16),
if (_lastResult != null)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'直近の出力結果',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
SelectableText('出力先: ${_lastResult!.exportDirectory.path}'),
const SizedBox(height: 8),
SelectableText('CSV: ${_lastResult!.csvFile.path}'),
const SizedBox(height: 8),
Text('出力時刻: ${_lastResult!.exportedAt.toIso8601String()}'),
const SizedBox(height: 12),
Text(
'data.csv の内容',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: SelectableText(_lastResult!.csvPreview),
),
const SizedBox(height: 12),
Text(
'コピーした画像',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
..._lastResult!.exportedImages.map(
(File file) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: SelectableText(file.path),
),
),
],
),
),
),
],
),
),
);
}
}
class _MessageCard extends StatelessWidget {
const _MessageCard({
required this.title,
required this.message,
required this.color,
});
final String title;
final String message;
final Color color;
@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.titleMedium?.copyWith(
color: color,
),
),
const SizedBox(height: 8),
Text(message),
],
),
),
);
}
}
5. コードのポイント
5-1. フォルダ名は出力時刻と業務キーをまとめて作る
書き出し先フォルダは日時だけでも重複しにくいものの、それだけでは後から一覧を見たときに内容を判別しづらくなります。そこで日時に加えて検品 ID とロット番号を含め、端末内に同種の出力が増えても見分けやすい形にしています。
String _buildFolderName(ExportDraft draft, DateTime exportedAt) {
return '${_formatCompactTimestamp(exportedAt)}_${_sanitizeSegment(draft.inspectionId)}_${_sanitizeSegment(draft.lotNumber)}';
}
_sanitizeSegment() でパス区切り文字や空白を - に寄せているため、入力欄に空白や記号が入ってもフォルダ名が崩れにくくなります。命名規則をここへ寄せておくと、後で共有フォルダや ZIP 出力へ広げる場合も差し替え箇所が 1 つで済みます。
5-2. data.csv はまず文字列で完成形を作る
CSV ライブラリを足さなくても、今回のような 1 レコード 1 行の最小構成なら十分です。先に 2 次元配列で行データを作り、_escapeCsvValue() でダブルクオートを逃がしてから連結すると、値にカンマが入っても崩れません。
String _buildCsvContent({
required ExportDraft draft,
required int attachmentCount,
required DateTime exportedAt,
}) {
final List<List<String>> rows = <List<String>>[
<String>[
'inspectionId',
'gtin',
'lotNumber',
'storageLane',
'operatorName',
'decision',
'memo',
'imageCount',
'exportedAt',
],
<String>[
draft.inspectionId,
draft.gtin,
draft.lotNumber,
draft.storageLane,
draft.operatorName,
draft.decision.label,
draft.memo,
attachmentCount.toString(),
exportedAt.toIso8601String(),
],
];
return rows.map(_toCsvLine).join('\n');
}
この形なら、後で列を追加したい場合もヘッダ行と値行を同じ並びで増やせます。出力前にアプリ上で csvPreview を見せているので、Files アプリや Device File Explorer を開かなくても内容確認が可能です。
5-3. 画像コピーが失敗したら途中フォルダを消す
data.csv だけ残って画像が欠けたフォルダを作ると、どこまで成功したのか判断しにくくなります。そこで今回は、CSV 書き出し後や画像コピー中に例外が出たら、同じ catch で対象フォルダごと削除しています。
try {
await exportDirectory.create(recursive: true);
final File csvFile = File('${exportDirectory.path}/data.csv');
await csvFile.writeAsString(csvPreview, flush: true);
for (int index = 0; index < _attachments.length; index++) {
final PreparedAttachment attachment = _attachments[index];
final String imageName = _buildImageFileName(
draft: draft,
attachment: attachment,
index: index,
);
await attachment.sourceFile.copy('${exportDirectory.path}/$imageName');
}
} catch (error) {
if (await exportDirectory.exists()) {
await exportDirectory.delete(recursive: true);
}
rethrow;
}
今回のサンプルでは rethrow ではなく UI 更新も同じメソッドで行っていますが、考え方は同じです。失敗時は前回成功分の結果カードを消し、入力欄はそのまま残します。トグルを OFF に戻せばすぐ再出力できます。
5-4. 前の記事の XFile 一覧があればコピー元だけ差し替えればよい
前の記事の複数画像添付 UI では XFile の一覧を持っていました。ローカル出力で必要なのは最終的にコピー元ファイルのパスです。XFile から File へ変換して PreparedAttachment を作れば、同じ処理へそのまま流せます。
final PreparedAttachment converted = PreparedAttachment(
label: pickedFile.name,
sourceFile: File(pickedFile.path),
);
この置き換えだけで済むなら、画像選択 UI とローカル出力ロジックを別ファイルに分けても責務は崩れません。次に permission_handler や共有処理を足すときも、書き出し本体のコードは触る範囲を小さく保てます。
6. flutter run で書き出しを確認する
コードを貼り付けたら、プロジェクト直下で次のコマンドを実行します。
flutter run
起動直後は、入力フォームと書き出し前確認が 1 画面に並びます。まずは入力初期値が入っていることと、サンプル画像未準備のため書き出しボタンが無効なことを確認します。
起動したら、次の順で確認すると差分が見えやすくなります。
- 先に「サンプル画像を準備する」を押し、添付元ファイル一覧が 2 件出ることを確認する
- 失敗トグルを両方 OFF のまま書き出し、結果カードに
data.csvと画像 2 枚のパスが出ることを確認する - 「CSV 書き出し失敗を再現する」を ON にして再出力し、エラーカードが出て直前の成功結果カードは消え、入力欄と添付元一覧が残ることを確認する
- CSV 失敗トグルを OFF に戻し、「画像コピー失敗を再現する」を ON にして再出力し、直前の成功結果カードが消えた状態で再試行できることを確認する
- 失敗トグルを両方 OFF に戻して再出力し、もう一度成功することを確認する
正常系のスクリーンショットを並べると、次の 2 枚で流れを追いやすくなります。
- サンプル画像を準備すると、添付元ファイル一覧が 2 件並び、書き出しボタンが有効になります。
- そのまま書き出しに成功すると、結果カードに出力先、
data.csv、コピー済み画像の一覧が表示されます。
失敗トグルで見たいのは、画面差分よりもエラーカード表示、結果カードが消えること、トグルを戻して再試行できることです。そのため、失敗時のスクリーンショットは本文に入れていません。
画像コピー失敗時に途中フォルダが残らないかは、アプリ画面だけでは分かりません。必要なら結果カードに出た出力先パスを手がかりに、Android Studio の Device File Explorer や Files アプリで exports/ 配下を確認してください。
path_provider でパスを取れた時点で終わりではありません。業務アプリで詰まりやすいのは、命名規則、途中失敗の扱い、再出力時にどこを保持するかのほうです。
今回は権限ダイアログや共有ストレージ保存を扱っていません。画像選択やカメラ、Bluetooth と合わせて Android 権限の分岐を整理したい場合は、次に permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) を読むとつながります。
7. まとめ
ローカルファイル書き出しで先に固めておきたいのは、保存 API の細部よりも、1 回の出力をどの単位で確定するかです。フォルダ名、data.csv、画像名、失敗時の巻き戻しを 1 つの流れにまとめておくと、後で API 送信や共有フォルダ保存を足しても処理の責務が散りにくくなります。
前段の添付 UI をまだ見ていない場合は Flutterで複数画像の添付UIを作る から先に読むと入りやすくなります。権限分岐を整理したい場合は permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) が次の導線です。