Zapp!でニフクラ mobile backendのデモ（カメラメモアプリ）を体験できます

[Zapp!](https://zapp.run/)はFlutterアプリをクラウド環境で開発できるサービスです。DartPadと違って、任意のFlutter/Dartパッケージをインストールできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/f2a436a4-679b-4a04-4a8b-bc938152fe96.png)

そんなZapp!を使って、ニフクラ mobile backendのFlutter SDKを利用したデモアプリであるカメラメモアプリを体験できるようにしました。

## アプリの概要

アプリは2画面で作られています。

### メモ投稿画面

![0128 z90063a9106.zapp.page - 0922152119.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/cf77b3f8-829e-5071-14b1-b07728fb59bf.jpeg)


メモをデータストアに、指定された写真をファイルストアにアップロードします。

### メモ一覧画面

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/69318e39-ce41-c456-6d63-5dba15de300e.png)


登録されているメモを一覧表示します。写真も読み込んでサムネイル表示します。

## アプリの機能

データストアの操作（データの取得、新規作成）が体験できます。ファイルストアもアップロード、ダウンロードが体験できます。

一覧でタップしたときには[fluttertoast](https://pub.dev/packages/fluttertoast)を使って画面を拡大表示します。スワイプ操作で戻れます。

![0128 z90063a9106.zapp.page - 0922152122.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/494142c3-dfd2-f4bd-4219-68b5cb6100a2.jpeg)

## アプリのURL

カメラメモアプリは下記のURLで試せます。

https://z90063a9106.zapp.page/#/

## アプリのコード

アプリのコードは下記になります。

https://zapp.run/edit/flutter-z90063a9106?entry=lib%2Fmain.dart&file=lib%2Fmain.dart

Zapp!は誰でも編集できるような気がするので、こちらに予備としてコードを全文残しておきます。

```dart
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:ncmb/ncmb.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:image_picker/image_picker.dart';
import 'package:uuid/uuid.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:intl/intl.dart';

Future<void> main() async {
  await dotenv.load(fileName: ".env");
  NCMB(dotenv.env['APPLICATION_KEY'] as String,
      dotenv.env['CLIENT_KEY'] as String);
  // ログイン状態の確認
  var user = await NCMBUser.currentUser();
  if (user == null) {
    // 匿名認証実行
    NCMBUser.loginAsAnonymous();
  }
  runApp(const CameraMemoApp());
}

class CameraMemoApp extends StatelessWidget {
  const CameraMemoApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'カメラメモアプリ',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MainPage(),
    );
  }
}

class MainPage extends StatefulWidget {
  @override
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  final _tab = [
    const Tab(text: 'アップロード', icon: Icon(Icons.photo)),
    const Tab(text: 'メモ', icon: Icon(Icons.list)),
  ];

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

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

class _FormPageState extends State<FormPage> {
  Uint8List? _image;
  final picker = ImagePicker();
  String _text = '';
  String? _extension;
  TextEditingController? _textEditingController;

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

  Future<void> _save() async {
    var fileName = "${const Uuid().v4()}.$_extension";
    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');
    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 {
    final pickedFile = await picker.pickImage(source: ImageSource.gallery);
    if (pickedFile == null) return;
    var image = await pickedFile.readAsBytes();
    setState(() {
      _extension = pickedFile.mimeType!.split('/')[1];
      _image = image;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          const Spacer(),
          const Text(
            '写真を選択して、メモを入力してください',
          ),
          _image != null
              ? GestureDetector(
                  child: Container(
                    child: Image.memory(_image!),
                    height: 200,
                  ),
                  onTap: _selectPhoto,
                )
              : IconButton(
                  iconSize: 200,
                  icon: Icon(
                    Icons.photo,
                    color: Colors.blue.shade500,
                  ),
                  onPressed: _selectPhoto,
                ),
          Container(
            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(),
        ],
      ),
    );
  }
}

class ListPage extends StatefulWidget {
  const ListPage({
    Key? key,
  }) : super(key: key);
  @override
  State<ListPage> createState() => _ListPageState();
}

class _ListPageState extends State<ListPage> {
  List<NCMBObject> _memos = [];
  @override
  void initState() {
    super.initState();
    getMemos();
  }

  Future<void> getMemos() async {
    var query = NCMBQuery('Memo');
    query.limit(100);
    var ary = await query.fetchAll();
    setState(() {
      _memos = ary.map((obj) => obj as NCMBObject).toList();
    });
  }

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

class ListItem extends StatefulWidget {
  final NCMBObject memo;

  ListItem({
    required this.memo,
    Key? key}) : super(key: key);
  @override
  State<ListItem> createState() => _ListItemState();
}

class _ListItemState extends State<ListItem> {
  // 画像データが入るバイト列
  Uint8List? _image;

  @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;
    });
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
        leading: _image != null
          ? Image.memory(
              _image!,
              width: 50,
            )
          : Icon(
              Icons.photo,
              color: Colors.blue.shade500,
              size: 50,
          ),
		    title: Text(viewDate(widget.memo.getDateTime('createDate'))),
        subtitle: Text(widget.memo.getString('text', defaultValue: "(メモなし)"), 
          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);
  }
}

// 一覧をタップ時に画像を拡大表示する
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);
          }
        },
      ),
    );
  }
}
```

## コードの解説

このカメラメモアプリは[【ハンズオン】NCMBのFlutter SDKを使ってカメラメモアプリを作る \- Qiita](https://qiita.com/goofmint/items/8eeb9c9e550023b4164b#%E7%94%BB%E9%9D%A2%E6%A7%8B%E7%AF%89-2)のコードを利用しています。Flutter 3.0.5に合わせて多少の修正が入っています。

## まとめ

FlutterアプリでNCMBを使う際の参考として、ぜひご覧ください。データストアやファイルストアの基本的な使い方が分かるはずです。

[mBaaSでサーバー開発不要！ | ニフクラ mobile backend](https://mbaas.nifcloud.com/)