FlutterでGS1-128バーコードを解析する の次に固めたいのが、読取結果の確認と登録を別画面へ散らさず、1 画面で完結させる入力フローです。この記事では外部パッケージを使わず、読取文字列の貼り付け、解析結果の表示専用領域、選択項目、必須チェック、保存可能条件の制御、登録履歴までを Flutter だけで確認します。
1. ゴールと非対象
対象読者
- Flutter プロジェクトを作成して
flutter runした経験がある人 - Flutterでスキャン入力を受けて処理する と FlutterでGS1-128バーコードを解析する を一度試し、次に確認と登録の流れをまとめたい人
- 単一画面アプリで、読取結果と手入力項目の責務を分けたい人
この記事で到達する状態
- 読取文字列を確定し、結果を表示専用カードへ分離できる
- 判定、保管レーン、作業者名、理由メモを 1 画面で入力できる
- 保存ボタンの有効 / 無効を必須条件に合わせて制御できる
- 保留 / 不良時だけ理由メモを必須にする、といった条件分岐を入れられる
- 保存後に履歴を残しつつ、次の入力待ちへ戻せる
非対象
- カメラプラグインによる読み取り
- GS1 の括弧なし文字列や全 AI の詳細解析
- 画像添付やファイル書き出し
- API 送信やローカル DB 保存
- Riverpod などの状態管理ライブラリ
今回は「1 件の入力を、どの条件なら保存できるか」を 1 画面で整理することに絞ります。画像添付やファイル出力は、この流れが固まった後のほうが足し込みやすくなります。
2. 先に画面の流れを整理する
今回の入力フローは次の通りです。
flowchart TD
A[読取文字列を貼り付ける] --> B[解析ボタンで確定]
B --> C{必須 AI が揃うか}
C -->|No| D[エラー表示]
C -->|Yes| E[表示専用カードへ反映]
E --> F[判定を選ぶ]
F --> G[保管レーンを選ぶ]
G --> H[作業者名を入力する]
H --> I{保留 / 不良か}
I -->|Yes| J[理由メモを必須にする]
I -->|No| K[保存可能条件を再判定]
J --> K
K --> L{保存可能か}
L -->|No| M[不足条件をチェックリスト表示]
L -->|Yes| N[履歴へ追加]
N --> O[次の入力待ちへ戻す]
ここで先に決めておきたいのは、読取結果と追加入力欄を同じ扱いにしないことです。
- 読取結果は表示専用にして、元データを UI 側で書き換えない
- 追加入力欄は「利用者が補う情報」に限定する
- 保存不可の理由は、ボタンを無効にするだけでなく一覧でも見せる
この 3 点を分けておくと、後から画像添付やファイル出力を足すときも、どこへ状態を追加すればよいか迷いにくくなります。
3. プロジェクトを作成し、確認環境を用意する
3-1. Flutter の環境構築がまだなら先に済ませる
環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。
このサンプルは flutter run で確認できます。外部パッケージは不要なので、DartPad での確認も可能です。
3-2. Flutter プロジェクトを作成する
次のコマンドでプロジェクトを作成します。
flutter create my_single_screen_flow_app
cd my_single_screen_flow_app
3-3. エミュレーターを起動する
利用可能なエミュレーター一覧を確認します。
flutter emulators
表示された ID を指定して起動します。
flutter emulators --launch <emulator_id>
今回は外部パッケージがないので、ここまで済めばそのまま lib/main.dart を貼り付けて動かせます。ボタンの押しやすさや縦長レイアウトの見え方は flutter run のほうが確認しやすくなります。
4. lib/main.dart に単一画面入力フローを実装する
lib/main.dart は次の内容で作成します。
このファイルは、読取文字列の確定、表示専用カード、判定と保管レーンの選択、作業者名入力、条件付き理由メモ、保存可能条件のチェックリスト、履歴表示までを 1 画面で確認するサンプルです。注目点は _blockingReasons で保存不可理由を一元管理し、ボタン活性と画面表示の両方で同じ判定を使っているところにあります。パーサは括弧付き GS1 形式に絞った簡易実装で、括弧なし文字列や可変長 AI の詳細処理は前段の GS1 記事へ分けています。
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Single Screen Input Flow Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const SingleScreenInputPage(),
);
}
}
enum InspectionDecision {
accepted('合格'),
hold('保留'),
rejected('不良');
const InspectionDecision(this.label);
final String label;
}
class ParsedScanData {
const ParsedScanData({
required this.rawInput,
required this.gtin,
required this.expiryDate,
required this.lotNumber,
required this.serialNumber,
});
final String rawInput;
final String gtin;
final DateTime expiryDate;
final String lotNumber;
final String? serialNumber;
}
class SavedInspectionRecord {
const SavedInspectionRecord({
required this.parsedData,
required this.decision,
required this.storageLane,
required this.operatorName,
required this.reasonMemo,
required this.savedAt,
});
final ParsedScanData parsedData;
final InspectionDecision decision;
final String storageLane;
final String operatorName;
final String? reasonMemo;
final DateTime savedAt;
}
class ScanParseException implements Exception {
const ScanParseException(this.message);
final String message;
@override
String toString() => message;
}
class MiniGs1Parser {
static String sanitize(String input) {
return input
.replaceAll('\r', '')
.replaceAll('\n', '')
.replaceAll(RegExp(r'[\u0000-\u001F\u007F]'), '')
.trim();
}
static ParsedScanData parse(String input) {
final String sanitized = sanitize(input);
if (sanitized.isEmpty) {
throw const ScanParseException('読取文字列が空です。サンプルを入れるか、文字列を貼り付けてください。');
}
final String gtin = _extractRequired(
sanitized,
ai: '01',
validator: (value) => RegExp(r'^\d{14}$').hasMatch(value),
errorMessage: '(01) GTIN は 14 桁の数字である必要があります。',
);
final String expiry = _extractRequired(
sanitized,
ai: '17',
validator: (value) => RegExp(r'^\d{6}$').hasMatch(value),
errorMessage: '(17) 有効期限は YYMMDD の 6 桁である必要があります。',
);
final String lotNumber = _extractRequired(
sanitized,
ai: '10',
validator: (value) => value.isNotEmpty && value.length <= 20,
errorMessage: '(10) ロット番号は 1〜20 文字で指定してください。',
);
final String? serialNumber = _extractOptional(
sanitized,
ai: '21',
);
return ParsedScanData(
rawInput: sanitized,
gtin: gtin,
expiryDate: _parseExpiryDate(expiry),
lotNumber: lotNumber,
serialNumber: serialNumber,
);
}
static String _extractRequired(
String input, {
required String ai,
required bool Function(String value) validator,
required String errorMessage,
}) {
final String? value = _extractOptional(input, ai: ai);
if (value == null || !validator(value)) {
throw ScanParseException(errorMessage);
}
return value;
}
static String? _extractOptional(String input, {required String ai}) {
final RegExp pattern = RegExp('\\($ai\\)([^()]+)');
final RegExpMatch? match = pattern.firstMatch(input);
if (match == null) {
return null;
}
return match.group(1)?.trim();
}
static DateTime _parseExpiryDate(String value) {
final int yy = int.parse(value.substring(0, 2));
final int mm = int.parse(value.substring(2, 4));
final int dd = int.parse(value.substring(4, 6));
final DateTime date = DateTime(2000 + yy, mm, dd);
if (date.month != mm || date.day != dd) {
throw const ScanParseException('(17) 有効期限の日付が不正です。');
}
return date;
}
}
class SingleScreenInputPage extends StatefulWidget {
const SingleScreenInputPage({super.key});
@override
State<SingleScreenInputPage> createState() => _SingleScreenInputPageState();
}
class _SingleScreenInputPageState extends State<SingleScreenInputPage> {
static const String _validSample =
'(01)04912345678903(17)260101(10)LOT-77(21)ABC12345';
static const String _missingLotSample =
'(01)04912345678903(17)260101(21)ABC12345';
static const List<String> _storageLanes = <String>['A-01', 'A-02', 'B-05'];
final TextEditingController _scanController = TextEditingController();
final TextEditingController _operatorController = TextEditingController();
final TextEditingController _reasonController = TextEditingController();
final List<SavedInspectionRecord> _history = <SavedInspectionRecord>[];
ParsedScanData? _parsedData;
InspectionDecision? _selectedDecision;
String? _selectedStorageLane;
String? _parseErrorMessage;
String _statusMessage = 'サンプルを入れるか、括弧付き GS1 文字列を貼り付けて確定してください。';
bool get _requiresReasonMemo {
return _selectedDecision == InspectionDecision.hold ||
_selectedDecision == InspectionDecision.rejected;
}
List<String> get _blockingReasons {
final List<String> reasons = <String>[];
if (_parsedData == null) {
reasons.add('読取結果を確定してください。');
}
if (_selectedDecision == null) {
reasons.add('判定を選択してください。');
}
if (_selectedStorageLane == null) {
reasons.add('保管レーンを選択してください。');
}
if (_operatorController.text.trim().length < 2) {
reasons.add('作業者名を 2 文字以上で入力してください。');
}
if (_requiresReasonMemo && _reasonController.text.trim().isEmpty) {
reasons.add('保留または不良のときは理由メモを入力してください。');
}
return reasons;
}
bool get _canSave => _blockingReasons.isEmpty;
@override
void initState() {
super.initState();
_operatorController.addListener(_handleFormChanged);
_reasonController.addListener(_handleFormChanged);
}
@override
void dispose() {
_scanController.dispose();
_operatorController
..removeListener(_handleFormChanged)
..dispose();
_reasonController
..removeListener(_handleFormChanged)
..dispose();
super.dispose();
}
void _handleFormChanged() {
setState(() {});
}
void _loadSample(String value) {
setState(() {
_scanController.text = value;
_statusMessage = 'サンプルを入力欄へ入れました。解析ボタンで確定してください。';
});
}
void _parseCurrentScan() {
try {
final ParsedScanData parsed = MiniGs1Parser.parse(_scanController.text);
setState(() {
_parsedData = parsed;
_parseErrorMessage = null;
_statusMessage = '読取結果を確定しました。判定と保管レーンを選んで保存してください。';
});
} on ScanParseException catch (error) {
setState(() {
_parsedData = null;
_parseErrorMessage = error.message;
_statusMessage = '読取結果を確定できませんでした。必須項目を確認してください。';
});
}
}
void _saveRecord() {
if (!_canSave || _parsedData == null || _selectedDecision == null || _selectedStorageLane == null) {
setState(() {
_statusMessage = '保存条件が揃っていません。チェックリストを確認してください。';
});
return;
}
setState(() {
_history.insert(
0,
SavedInspectionRecord(
parsedData: _parsedData!,
decision: _selectedDecision!,
storageLane: _selectedStorageLane!,
operatorName: _operatorController.text.trim(),
reasonMemo: _reasonController.text.trim().isEmpty
? null
: _reasonController.text.trim(),
savedAt: DateTime.now(),
),
);
if (_history.length > 5) {
_history.removeLast();
}
_scanController.clear();
_reasonController.clear();
_parsedData = null;
_parseErrorMessage = null;
_selectedDecision = null;
_selectedStorageLane = null;
_statusMessage = '履歴へ追加しました。次の入力を待機しています。';
});
}
void _clearAll() {
setState(() {
_scanController.clear();
_reasonController.clear();
_parsedData = null;
_parseErrorMessage = null;
_selectedDecision = null;
_selectedStorageLane = null;
_statusMessage = '作業者名以外をクリアしました。次の読取を貼り付けてください。';
});
}
String _formatDate(DateTime value) {
final String month = value.month.toString().padLeft(2, '0');
final String day = value.day.toString().padLeft(2, '0');
return '${value.year}-$month-$day';
}
String _formatTime(DateTime value) {
final String hour = value.hour.toString().padLeft(2, '0');
final String minute = value.minute.toString().padLeft(2, '0');
final String second = value.second.toString().padLeft(2, '0');
return '$hour:$minute:$second';
}
ButtonStyle _wideButtonStyle(BuildContext context) {
return FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
textStyle: Theme.of(context).textTheme.titleMedium,
);
}
Widget _buildReadonlyRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
width: 96,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
child: SelectableText(value),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
final ColorScheme colorScheme = theme.colorScheme;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => FocusScope.of(context).unfocus(),
child: 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.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(_statusMessage),
const SizedBox(height: 12),
TextField(
controller: _scanController,
minLines: 2,
maxLines: 4,
decoration: InputDecoration(
labelText: '読取文字列',
hintText: '(01)04912345678903(17)260101(10)LOT-77(21)ABC12345',
border: const OutlineInputBorder(),
errorText: _parseErrorMessage,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: <Widget>[
OutlinedButton(
onPressed: () => _loadSample(_validSample),
child: const Text('正常サンプル'),
),
OutlinedButton(
onPressed: () => _loadSample(_missingLotSample),
child: const Text('不足サンプル'),
),
],
),
const SizedBox(height: 12),
FilledButton.icon(
style: _wideButtonStyle(context),
onPressed: _parseCurrentScan,
icon: const Icon(Icons.qr_code_2),
label: const Text('読取結果を確定する'),
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'読取結果(表示専用)',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
if (_parsedData == null)
Text(
'まだ確定済みの読取結果がありません。先に上の入力欄から確定してください。',
style: TextStyle(color: colorScheme.onSurfaceVariant),
)
else ...<Widget>[
_buildReadonlyRow('GTIN', _parsedData!.gtin),
_buildReadonlyRow('有効期限', _formatDate(_parsedData!.expiryDate)),
_buildReadonlyRow('ロット', _parsedData!.lotNumber),
_buildReadonlyRow('シリアル', _parsedData!.serialNumber ?? '-'),
_buildReadonlyRow('元の文字列', _parsedData!.rawInput),
],
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'追加で入力する項目',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(
'判定',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: InspectionDecision.values.map((InspectionDecision decision) {
return ChoiceChip(
label: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(decision.label),
),
selected: _selectedDecision == decision,
onSelected: (_) {
setState(() {
_selectedDecision = decision;
});
},
);
}).toList(),
),
const SizedBox(height: 16),
Text(
'保管レーン',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _storageLanes.map((String lane) {
return ChoiceChip(
label: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(lane),
),
selected: _selectedStorageLane == lane,
onSelected: (_) {
setState(() {
_selectedStorageLane = lane;
});
},
);
}).toList(),
),
const SizedBox(height: 16),
TextField(
controller: _operatorController,
decoration: const InputDecoration(
labelText: '作業者名',
hintText: '例: 佐藤',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _reasonController,
minLines: 2,
maxLines: 3,
decoration: InputDecoration(
labelText: _requiresReasonMemo ? '理由メモ(必須)' : '理由メモ(任意)',
hintText: '保留や不良の理由、補足があれば入力します',
border: const OutlineInputBorder(),
),
),
],
),
),
),
const SizedBox(height: 16),
Card(
color: colorScheme.surfaceContainerHighest,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'保存可能条件',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
_canSave ? '保存できます。' : 'まだ不足している項目があります。',
style: TextStyle(
color: _canSave ? colorScheme.primary : colorScheme.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
..._blockingReasons.map((String reason) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(Icons.error_outline, color: colorScheme.error),
const SizedBox(width: 8),
Expanded(child: Text(reason)),
],
),
);
}),
if (_blockingReasons.isEmpty)
const Row(
children: <Widget>[
Icon(Icons.check_circle_outline),
SizedBox(width: 8),
Expanded(child: Text('読取結果、判定、保管レーン、作業者名が揃っています。')),
],
),
],
),
),
),
const SizedBox(height: 16),
FilledButton.icon(
style: _wideButtonStyle(context),
onPressed: _canSave ? _saveRecord : null,
icon: const Icon(Icons.save),
label: const Text('履歴へ保存する'),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _clearAll,
icon: const Icon(Icons.refresh),
label: const Text('入力をリセットする'),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'登録履歴(最新 5 件)',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
if (_history.isEmpty)
const Text('まだ登録履歴がありません。')
else
..._history.map((SavedInspectionRecord record) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(
'${record.decision.label} / ${record.storageLane} / ${record.parsedData.lotNumber}',
),
subtitle: Text(
'GTIN: ${record.parsedData.gtin}\n'
'作業者: ${record.operatorName}\n'
'理由: ${record.reasonMemo ?? '-'}',
),
trailing: Text(_formatTime(record.savedAt)),
isThreeLine: true,
),
);
}),
],
),
),
),
],
),
),
),
);
}
}
コードのポイント
① 読取結果を表示専用カードへ分ける
if (_parsedData == null)
Text(
'まだ確定済みの読取結果がありません。先に上の入力欄から確定してください。',
)
else ...<Widget>[
_buildReadonlyRow('GTIN', _parsedData!.gtin),
_buildReadonlyRow('有効期限', _formatDate(_parsedData!.expiryDate)),
]
読取結果を TextField へ戻すと元データと追加入力の境目が曖昧になるため、表示専用に分けておくと「スキャンで入った情報」と「利用者が補う情報」を UI 上で自然に切り分けられます。
② 保存不可理由を 1 か所で判定する
List<String> get _blockingReasons {
final List<String> reasons = <String>[];
if (_parsedData == null) {
reasons.add('読取結果を確定してください。');
}
if (_selectedDecision == null) {
reasons.add('判定を選択してください。');
}
if (_requiresReasonMemo && _reasonController.text.trim().isEmpty) {
reasons.add('保留または不良のときは理由メモを入力してください。');
}
return reasons;
}
保存ボタンの有効 / 無効と、画面に出す不足条件を別々に書くと、後から条件を増やしたときにずれやすくなります。getter へまとめておくと、UI の見た目と保存判定を同じ基準で揃えられます。
③ 大きめのチップと縦並びボタンで操作しやすくする
ChoiceChip(
label: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(decision.label),
),
selected: _selectedDecision == decision,
onSelected: (_) {
setState(() {
_selectedDecision = decision;
});
},
)
単一画面アプリでは、画面遷移を減らす代わりに 1 画面あたりの操作量が増えます。文字とタップ領域を少し大きめにしておくと、エミュレーターでも実機でも押し間違いを減らせます。
コードを貼り付けたら、次のコマンドで起動します。
flutter run
初期表示は縦に長いので、上部の読取カード、中部の追加入力欄、下部の保存条件を分けて見ると追いやすくなります。
5. 保存可能条件を確認する
次の入力で主要な挙動を確認できます。
| 操作 | 期待結果 | 見るポイント |
|---|---|---|
正常サンプルを確定し、合格、A-01、佐藤 を入力 | 保存可能 | チェックリストが消え、保存ボタンが有効になる |
| 正常サンプルを確定するが、判定を選ばない | 保存不可 | 判定を選択してください。 が残る |
保留 を選び、理由メモを空のままにする | 保存不可 | 理由メモ必須のメッセージが追加される |
| 不足サンプルを確定する | 読取エラー | 読取結果を確定できませんでした。 が表示される |
| 正常保存のあともう一度サンプルを入れる | 次の入力へ戻れる | 履歴は残り、入力欄だけが次の受付用に戻る |
ここで確認したいのは、保存ボタンそのものより「どの条件が足りないか」をすぐ追えるかです。単一画面で入力項目が増えるほど、利用者はどこで止まっているのかを見失いやすくなります。チェックリストを同じ画面へ出しておけば、理由の切り分けも早くなります。
Android Emulator で見るなら、次の順で試すと流れを追いやすくなります。
- 正常サンプルを入れて、表示専用カードへ値が反映されることを確認する
- 判定や保管レーンを 1 つずつ選び、保存ボタンがどの条件で有効になるかを見る
保留か不良を選び、理由メモ必須が追加されることを確認する- 保存後に履歴へ追加され、次の入力待ちへ戻ることを確認する
上の 4 手順に対応するスクリーンショットは次の通りです。
- 正常サンプルを確定すると、表示専用カードに GTIN、有効期限、ロット、シリアル、元の文字列が並びます。まずは「読取結果を UI 側で書き換えない」状態になっていることを確認します。
合格、保管レーン、作業者名がそろうと、保存可能条件が保存できます。に変わり、保存ボタンが有効になります。どの入力で保存可能へ切り替わるかをここで見ます。
保留を選ぶと、理由メモのラベルが必須に変わり、未入力の間は不足条件が残ります。条件分岐の結果が入力欄とチェックリストの両方へ反映されているかを確認します。
- 保存後は履歴カードに最新の 1 件が追加され、次の入力へ進める状態に戻ります。履歴が残ることと、単一画面のまま次件受付へ切り替わることをここで確認できます。
6. まとめ
外部パッケージなしで、読取結果の確認と登録を 1 画面で完結させる最小構成を作りました。ここまでで、表示専用領域、選択項目、必須チェック、保存可能条件の制御、履歴表示までがそろいます。
続けて取り組むなら、次の順がつながりやすくなります。
- FlutterでGS1-128バーコードを解析する を見直し、どの項目を表示専用領域へ出すかを整理する
- permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth) を読み、実機機能やファイル操作へ進む前の権限導線を整える
- Flutterでスキャン入力を受けて処理する と組み合わせ、貼り付けではなく入力待機型の単一画面へ広げる