NCMBのFlutter SDKを使ってカメラメモアプリを作る（その2：写真とデータを保存する）

NCMBでは公式SDKとしてSwift/Objective-C/Java/Unity/JavaScript SDKを用意しています。また、それ以外にもコミュニティSDKとして、非公式ながらFlutter/React Native/Google Apps Script/C#/Ruby/Python/PHPなど幅広い言語向けにSDKが開発されています。

今回はコミュニティSDKの一つ、Flutter SDKを使ってカメラメモアプリを作ってみます。[前回は画面の仕様とSDKの初期化について解説しました](https://qiita.com/goofmint/items/7ea7738f26ba44f72dc2)。今回は写真を選択して、メモデータと一緒に保存するまでの流れを解説します。

## 完成版のコード

作成したデモアプリのコードは[NCMBMania/flutter_camera_memo: FlutterとNCMBによるカメラメモアプリです](https://github.com/NCMBMania/flutter_camera_memo)にアップロードしてあります。

## 入力画面について

入力画面はタブを使って読み込まれます。これはMainPage/_MainPageStateになります。

```dart
class MainPage extends StatefulWidget {
  const MainPage({
    Key? key,
  }) : super(key: key);
  @override
  State<MainPage> createState() => _MainPageState();
}

// タブ画面を表示します
class _MainPageState extends State<MainPage> {
  final _tabs = [
    const Tab(text: 'アップロード', icon: Icon(Icons.photo)),
    const Tab(text: 'メモ', icon: Icon(Icons.list)),
  ];

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: _tabs.length,
      child: Scaffold(
        appBar: AppBar(
          title: const Text('メモアプリ'),
          bottom: TabBar(
            tabs: _tabs,
          ),
        ),
        body: const TabBarView(children: [
          FormPage(),
          ListPage(),
        ]),
      ),
    );
  }
}
```

## フォーム画面の構築

フォーム画面は `FormPage` および `_FormPageState` になります。 `FormPage` は `_FormPageState` を読み込むだけです。

```dart
class FormPage extends StatefulWidget {
  const FormPage({
    Key? key,
  }) : super(key: key);
  @override
  State<FormPage> createState() => _FormPageState();
}
```

### _FormPageStateの変数

`_FormPageState` では以下の変数を扱います。

```dart
class _FormPageState extends State<FormPage> {class _FormPageState extends State<FormPage> {
  // 選択した画像
  Uint8List? _image;
  // 選択したファイルの拡張子
  String? _extension;
  // 画像ピッカー
  final picker = ImagePicker();
  // 入力したメモ
  String _text = '';
  // 入力したメモの操作用
  TextEditingController? _textEditingController;
```

### 初期化

初期化時に `_textEditingController` を初期化します。

```dart
// 初期化
@override
void initState() {
	super.initState();
	_textEditingController = TextEditingController(text: _text);
}
```

### 画面構築

画面構築は次のようになります。写真が選択されていない時にはアイコンを表示し、選択されるとその写真をプレビュー表示します。

```dart
@override
Widget build(BuildContext context) {
	return Center(
		child: Column(
			mainAxisAlignment: MainAxisAlignment.center,
			children: <Widget>[
				const Spacer(),
				const Text(
					'写真を選択して、メモを入力してください',
				),
				// 画像が指定された場合は画像を表示
				// なければアイコンを表示（初期表示）
				_image != null
						? GestureDetector(
								child: SizedBox(
									child: Image.memory(_image!),
									height: 200,
								),
								onTap: _selectPhoto,
							)
						: IconButton(
								iconSize: 200,
								icon: const Icon(
									Icons.photo,
									color: Colors.blue,
								),
								onPressed: _selectPhoto,
							),
				// メモ欄
				SizedBox(
					width: 300,
					child: TextFormField(
						controller: _textEditingController,
						enabled: true,
						style: const TextStyle(color: Colors.black),
						maxLines: 5,
						onChanged: (text) {
							setState(() {
								_text = text;
							});
						},
					),
				),
				const Spacer(),
				ElevatedButton(onPressed: _save, child: const Text('保存')),
				const Spacer(),
			],
		),
	);
}
```

![Simulator Screen Shot - iPhone 12 - 2022-03-10 at 14.01.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/949bf64e-e6b2-7f5e-f0e9-fc08e25fa5e9.png)


## 写真選択

アイコンや写真をタップすると、image_pickerを使って写真選択を行います。選択した写真は `readAsBytes` でバイト文字列化します。さらに拡張子だけ `_extension` に入れておきます。

```dart
// 写真選択時の処理
Future<void> _selectPhoto() async {
	try {
		final pickedFile = await picker.pickImage(
			source: ImageSource.gallery,
			imageQuality: 80,
		);
		if (pickedFile == null) return;
		var image = await pickedFile.readAsBytes();
		setState(() {
			_extension = p.extension(pickedFile.name);
			_image = image;
		});
	} catch (err) {}
}
```

## 保存処理

写真を選択して、メモを入力したら保存処理を実行します。まず `NCMBFile` の `upload` メソッドを使ってファイルをアップロードします。この時、 `NCMBAcl` を使ってアクセス権限を指定します。さらにアップロード時に使ったファイル名（ランダム + 拡張子）をデータストアのMemoクラスに対して設定しておきます。最後に `save` メソッドで保存します。

```dart
// 保存ボタンを押した時の処理
Future<void> _save() async {
	// ファイル名はランダムに生成
	var fileName = "${const Uuid().v4()}.$_extension";
	// ACL（アクセス権限）
	var acl = NCMBAcl();
	// 現在ログインしているユーザー情報
	var user = await NCMBUser.currentUser();
	// ユーザーのみ読み書き可能
	acl.setUserReadAccess(user!, true);
	acl.setUserWriteAccess(user, true);
	// 写真のアップロード
	await NCMBFile.upload(fileName, _image, acl: acl);
	// オブジェクトの作成
	var obj = NCMBObject('Memo');
	// setsを使ってまとめて指定
	// obj.set で1つずつ指定も可能
	obj.sets({'text': _text, 'fileName': fileName, 'acl': acl});
	// 保存
	await obj.save();
	done();
}

// 保存完了時の処理（アラートを表示するだけ）
void done() {
	showDialog(
		context: context,
		barrierDismissible: false,
		builder: (_) {
			return AlertDialog(
				title: const Text('保存完了'),
				content: const Text('メモを保存しました'),
				actions: [
					TextButton(
							onPressed: () {
								// 入力値を初期化
								setState(() {
									_text = '';
									_image = null;
									_textEditingController!.text = _text;
								});
								Navigator.pop(context);
							},
							child: const Text('OK'))
				],
			);
		},
	);
}
```

![Simulator Screen Shot - iPhone 12 - 2022-03-10 at 14.05.54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/47f9cb48-7bb4-b9f0-a050-794aef678177.png)


## 全体のコード

`FormPage` 、`_FormPageState` クラスの内容は次のようになります。

```dart
// 入力フォーム用
class FormPage extends StatefulWidget {
  const FormPage({
    Key? key,
  }) : super(key: key);
  @override
  State<FormPage> createState() => _FormPageState();
}

// 入力フォーム
class _FormPageState extends State<FormPage> {
  // 選択した画像
  Uint8List? _image;
  // 選択したファイルの拡張子
  String? _extension;
  // 画像ピッカー
  final picker = ImagePicker();
  // 入力したメモ
  String _text = '';
  // 入力したメモの操作用
  TextEditingController? _textEditingController;

  // 初期化
  @override
  void initState() {
    super.initState();
    _textEditingController = TextEditingController(text: _text);
  }

  // 画面描画用
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          const Spacer(),
          const Text(
            '写真を選択して、メモを入力してください',
          ),
          // 画像が指定された場合は画像を表示
          // なければアイコンを表示（初期表示）
          _image != null
              ? GestureDetector(
                  child: SizedBox(
                    child: Image.memory(_image!),
                    height: 200,
                  ),
                  onTap: _selectPhoto,
                )
              : IconButton(
                  iconSize: 200,
                  icon: const Icon(
                    Icons.photo,
                    color: Colors.blue,
                  ),
                  onPressed: _selectPhoto,
                ),
          // メモ欄
          SizedBox(
            width: 300,
            child: TextFormField(
              controller: _textEditingController,
              enabled: true,
              style: const TextStyle(color: Colors.black),
              maxLines: 5,
              onChanged: (text) {
                setState(() {
                  _text = text;
                });
              },
            ),
          ),
          const Spacer(),
          ElevatedButton(onPressed: _save, child: const Text('保存')),
          const Spacer(),
        ],
      ),
    );
  }

  // 保存ボタンを押した時の処理
  Future<void> _save() async {
    // ファイル名はランダムに生成
    var fileName = "${const Uuid().v4()}.$_extension";
    // ACL（アクセス権限）
    var acl = NCMBAcl();
    // 現在ログインしているユーザー情報
    var user = await NCMBUser.currentUser();
    // ユーザーのみ読み書き可能
    acl.setUserReadAccess(user!, true);
    acl.setUserWriteAccess(user, true);
    // 写真のアップロード
    await NCMBFile.upload(fileName, _image, acl: acl);
    // オブジェクトの作成
    var obj = NCMBObject('Memo');
    // setsを使ってまとめて指定
    // obj.set で1つずつ指定も可能
    obj.sets({'text': _text, 'fileName': fileName, 'acl': acl});
    // 保存
    await obj.save();
    done();
  }

  // 保存完了時の処理（アラートを表示するだけ）
  void done() {
    showDialog(
      context: context,
      barrierDismissible: false,
      builder: (_) {
        return AlertDialog(
          title: const Text('保存完了'),
          content: const Text('メモを保存しました'),
          actions: [
            TextButton(
                onPressed: () {
                  // 入力値を初期化
                  setState(() {
                    _text = '';
                    _image = null;
                    _textEditingController!.text = _text;
                  });
                  Navigator.pop(context);
                },
                child: const Text('OK'))
          ],
        );
      },
    );
  }

  // 写真選択時の処理
  Future<void> _selectPhoto() async {
    try {
      final pickedFile = await picker.pickImage(
        source: ImageSource.gallery,
        imageQuality: 80,
      );
      if (pickedFile == null) return;
      var image = await pickedFile.readAsBytes();
      setState(() {
        _extension = p.extension(pickedFile.name);
        _image = image;
      });
    } catch (err) {}
  }
}
```

## まとめ

今回はファイルストアへ写真のアップロード、さらにデータストアにメモデータを保存しました。[次回はメモデータと取得と写真のダウンロード]()を実装します。