【ハンズオン】NCMBのFlutter SDKを使ってカメラメモアプリを作る

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

今回はコミュニティSDKの一つ、Flutter SDKを使ってカメラメモアプリを作ってみます。まず画面の仕様とSDKの初期化について解説します。

## ベースのコード

ハンズオンのベース用として[NCMBMania/flutter_camera_memo_handson](https://github.com/NCMBMania/flutter_camera_memo_handson)にNCMB実装を除いたコードをアップ済みです。こちらを利用してハンズオンを行います。

### 完成版のコード

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

## lib/main.dartについて

今回のコードはすべて main.dart に記述しています。クラスは次の7つです。

- class CameraMemoApp extends StatelessWidget
- class MainPage extends StatefulWidget
- class _MainPageState extends State<MainPage>
- class FormPage extends StatefulWidget
- class _FormPageState extends State<FormPage>
- class ListPage extends StatefulWidget
- class _ListPageState extends State<ListPage>
- class ListItem extends StatefulWidget
- class _ListItemState extends State<ListItem>
- class ImagePage extends StatelessWidget

## CameraMemoApp

カメラメモアプリ全体の読み込みを行うウィジェットです。

## MainPageと_MainPageState

![Simulator Screen Shot - iPhone 12 - 2022-03-10 at 13.48.38のコピー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/9864b9eb-6ca3-4ada-11d7-4d629fe92c3d.png)


タブバーを使ってFormPageとListPageを読み込むウィジェットです。

## FormPageと_FormPageState

![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/897730af-ac60-ff5c-4fea-503742c817d3.png)

メモ入力画面用のウィジェットです。

## ListPageと_ListPageState

![Simulator Screen Shot - iPhone 12 - 2022-03-10 at 14.06.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/d5a87394-b7ae-7338-f8ae-dc7b538835ee.png)
Simulator Screen Shot - iPhone 12 - 2022-03-10 at 14.06.01.png

登録したメモを一覧表示するウィジェットです。

## ListItemと_ListItemState

![Simulator Screen Shot - iPhone 12 - 2022-03-10 at 14.06.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/4973b689-375c-400d-a5d2-24709b8996a7.png)
Simulator Screen Shot - iPhone 12 - 2022-03-10 at 14.06.01.png

一覧表示時の、各行を描画するためのウィジェットです。

## ImagePage

![Simulator Screen Shot - iPhone 12 - 2022-03-10 at 14.06.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/25c41a06-4899-1fc4-5b3c-efa294a85268.png)
Simulator Screen Shot - iPhone 12 - 2022-03-10 at 14.06.06.png

一覧をタップした時に画像を拡大表示するウィジェットです。

## 匿名認証の有効化

![FireShot_Capture_161_-_20220310141510_ニフクラ_mobile_backend_-_console_mbaas_nifcloud_com.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/7ffcbaac-b5fa-9472-aa06-a97a21ea716a.jpeg)


今回はNCMBの匿名認証（パスワード不要な認証機能）を利用します。管理画面のアプリ設定にて、匿名認証を有効にします。

## NCMB SDKのインストール

NCMB SDKは `flutter pub add ncmb` でインストールできます。

```
flutter pub add ncmb
```

記事執筆時点での最新版は `2.3.2` です。

## NCMB SDKの初期化

まず main.dart にてNCMB SDKをインポートします。

```dart
// 1. NCMB SDKを読み込みます
import 'package:ncmb/ncmb.dart';
```

main関数にて初期化します。アプリケーションキーとクライアントキーは `assets/.env` 内に記述しています。今回はdotenvライブラリ `flutter_dotenv` を使っています。

`.env` の内容は以下の通りです。各キーはニフクラ mobile backendで取得してください。

```
APPLICATION_KEY=YOUR_APPLICATION_KEY
CLIENT_KEY=YOUR_CLIENT_KEY
```

main.dart のmain関数でNCMBの初期化をします。

```dart
Future<void> main() async {
  // キーは assets/.env にあります
  await dotenv.load(fileName: "assets/.env");
  // 2. NCMBの初期化
  NCMB(dotenv.env['APPLICATION_KEY'] as String,
      dotenv.env['CLIENT_KEY'] as String);
  // 3. ログインユーザーの取得（書き換えてください）
  var user = await NCMBUser.currentUser();
  // ログインしていない場合は匿名認証を実行します
  if (user == null) {
		// 4. 未ログインだっら匿名認証実行
    NCMBUser.loginAsAnonymous();
  }
  runApp(const CameraMemoApp());
}
```

これでNCMB SDKの利用準備が整います。

## iOSシミュレーターで動かす場合

今回は `image_picker` を使っているので、カメラやフォトライブラリへのアクセス権限が必要です。 `ios/Runner/Info.plist` を開いて設定を追加します。

```xml
<key>NSPhotoLibraryUsageDescription</key>
<string>写真ライブラリにアクセスします</string>
<key>NSCameraUsageDescription</key>
<string>カメラにアクセスします</string>
```

## 入力画面について

入力画面はタブを使って読み込まれます。これは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";
	// 5. ACL（アクセス権限）作成
	// ACL（アクセス権限）
	var acl = NCMBAcl();
	// 現在ログインしているユーザー情報
	var user = await NCMBUser.currentUser();
	// ユーザーのみ読み書き可能
	acl.setUserReadAccess(user!, true);
	acl.setUserWriteAccess(user, true);
	// 6. ファイルアップロード
	await NCMBFile.upload(fileName, _image, acl: acl);
	// 7. メモデータを作成して保存
	// オブジェクトの作成
	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)

## 一覧画面について

一覧画面は `ListPage` と `_ListPageState` を使っています。`ListPage` は `_ListPageState` を読み込んでいるだけです。

```dart
// 記述済み
class ListPage extends StatefulWidget {
  const ListPage({
    Key? key,
  }) : super(key: key);
  @override
  State<ListPage> createState() => _ListPageState();
}
```

## 一覧画面の構築

![](https://camo.qiitausercontent.com/af9e7ea9508e5badffdb39c1b747c290074d4560/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3139373032362f64356138373339342d623761652d373333382d663861652d6463376235333838333565652e706e67)

`_ListPageState` で使っている変数は次の通りです。

```dart
// 記述済み
// 一覧画面
class _ListPageState extends State<ListPage> {
  // メモ一覧が入る配列
  var _memos = [];
```

### 初期化

初期化時にNCMBのデータストアからメモを取得します。データを取得する際には `NCMBQuery` を利用します。そして `fetchAll` にてデータを取得します。

```dart
@override
void initState() {
	super.initState();
	getMemos();
}

// 登録されているメモを取得する
Future<void> getMemos() async {
	// 9. メモデータを検索
	// 検索対象のクラス
	var query = NCMBQuery('Memo');
	// 取得件数
	query.limit(100);
	// 検索実行
	var ary = await query.fetchAll();
	// 結果をセット
	setState(() {
		// 10. 取得したら _memos に入れる
		_memos = ary;
	});
}
```

### 画面構築

メモの一覧を取得していれば、それを `ListView` で一覧表示します。1件ずつのデータは `ListItem` （後述）にて描画します。

```dart
// 記述済み
@override
Widget build(BuildContext context) {
	return ListView.builder(
		itemBuilder: (BuildContext context, int index) {
			var memo = _memos[index];
			return Container(
					decoration: const BoxDecoration(
						border: Border(
							bottom: BorderSide(color: Colors.black38),
						),
					),
					child: ListItem(memo));
		},
		itemCount: _memos.length,
	);
}
```

## 1件ずつの表示

一覧で1件ずつの表示を行うのは `ListItem` と `_ListItemState` です。 `ListItem` は受け取ったメモデータを保持しています。

```dart
class ListItem extends StatefulWidget {
	// 11. メモを渡す（Objectから書き換えてください）
  final NCMBObject memo;
  const ListItem(this.memo, {Key? key}) : super(key: key);

  @override
  State<ListItem> createState() => _ListItemState();
}
```

### 画面構築

`_ListItemState` は以下の変数を保持しています。

```dart
// 一覧時の行データ
class _ListItemState extends State<ListItem> {
  // 画像データが入るバイト列
  Uint8List? _image;
```

### 初期化

`_ListItemState` の初期化時に `getPhoto` を使ってNCMBのファイルストアから写真データをダウンロードします。写真データのダウンロードは `NCMBFile.download` にて可能です。

```dart
@override
void initState() {
	super.initState();
	getPhoto();
}

// 写真をNCMBから取得する
Future<void> getPhoto() async {
	// 13. 写真のダウンロード
	var file = await NCMBFile.download(widget.memo.getString('fileName'));
	// セット（dataにデータが入っている）
	setState(() {
		// 14. 写真データのセット
		_image = file.data as Uint8List;
	});
}
```

### 画面描画

![](https://camo.qiitausercontent.com/7a22c1c0578829486539d48290afcd6f487b28e0/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3139373032362f34393733623638392d333735632d343030642d613564322d3234373039623839393661372e706e67)

画面描画は `ListTile` にて行っています。最初はアイコンを表示し、画像がダウンロードできれば差し替えています。 `viewDate` は日付をフォーマットして返却する関数です。

```dart
// 画面描画
@override
Widget build(BuildContext context) {
	return ListTile(
		leading: _image != null
				? Image.memory(
						_image!,
						width: 50,
					)
				: const Icon(
						Icons.photo,
						color: Colors.blue,
						size: 50,
					),
		title: Text(viewDate(widget.memo.getDateTime('createDate'))), // 15．メモの作成日時
		subtitle: Text(
			widget.memo.getString('text', defaultValue: ''),  // 16. メモのタイトル
			overflow: TextOverflow.ellipsis,
			maxLines: 2,
		),
		onTap: () {
			Navigator.push(context, MaterialPageRoute(builder: (_) {
				return ImagePage(_image!);
			}));
		},
	);
}

// 日付はフォーマットして表示
String viewDate(DateTime date) {
	var format = DateFormat('MM月dd日 HH:mm');
	return format.format(date);
}
```

## 一覧をタップした時の処理

一覧をタップした際には `ImagePage` を呼び出しています。

```dart
// 抜粋
Navigator.push(context, MaterialPageRoute(builder: (_) {
	return ImagePage(_image!);
}));
```

ImagePageは受け取った画像データを全体表示するだけのウィジェットです。後はスワイプ操作で一覧画面に戻れるようにしています。

```dart
// 一覧をタップ時に画像を拡大表示する
class ImagePage extends StatelessWidget {
  // 画像データ
  final Uint8List _image;
  const ImagePage(this._image, {Key? key}) : super(key: key);

  // 描画用
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: GestureDetector(
        child: Center(
          child: Hero(
            tag: 'imageHero',
            child: Image.memory(_image),
          ),
        ),
        onPanUpdate: (details) {
          // Swiping in right direction.
          if (details.delta.dx > 0) {
            Navigator.pop(context);
          }
        },
      ),
    );
  }
}
```

![](https://camo.qiitausercontent.com/0e6dd88bc8463206f12b98fe3d71a4630fc651ae/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3139373032362f32356334316130362d343839392d316663342d356233632d6566613239346138353236382e706e67)