【Flutter】Masamuneで超速アプリ開発④「メモ帳アプリ作成 - メモの作成・編集」

こんにちは。広瀬マサルです。

Flutterでアプリケーションを安定して高速に開発することのできるMasamuneフレームワークを紹介しました。

https://zenn.dev/mathru/articles/dcd9a52bcf3e94

その魅力と使い方を知っていただくためにこれから数回に分けて紹介記事を書きたいと思います。

Masamuneフレームワークを使いこなすことで超高速・安定・高品質なアプリを開発することができるでしょう。

前回から「メモ帳アプリ」の作成を開始しまして、メモの一覧と詳細の表示まで実装しました。

https://qiita.com/mathru/items/7845249de007b9e4727c

今回はメモの作成と更新、削除を行えるようにしCRUDアプリとしての完成を目指します。

# メモの作成・更新

モバイルアプリやWebアプリ内でデータを作成や更新を行うには`フォーム`を作成します。

基本的には同じ内容を編集するわけなので作成時と更新時は画面的にはほぼ変わりないと思います。

ただ、**作成時は元のデータがなく**、**更新時は元のデータが必要**になります。

Masamuneフレームワークでは画面を可能な限り使いまわし、データを分けるように実装を行うことが可能です。

[前回](https://qiita.com/mathru/items/7845249de007b9e4727c)からのプロジェクトを開いて、別ターミナルで下記コマンドを実行しコードの変更を監視してください。

```bash
katana code watch
```

下記のコマンドでフォーム用のテンプレートページを作成します。

```bash
katana code tmp form memo_edit
```

`pages/memo_edit.dart`に下記のクラスが作成されます。

`MemoEditAddPage`がメモを作成する時に使うページ、`MemoEditEditPage`がメモを更新するときに使うページです。`MemoEditForm`がそれぞれのフォームを記載するためのウィジェットクラスになります。

```dart
// memo_edit.dart

/// Page for forms to add data.
@immutable
@PagePath("memo_edit/add")
class MemoEditAddPage extends FormAddPageScopedWidget {
  const MemoEditAddPage({
    super.key,
  });

  /// Used to transition to the MemoEditAddPage screen.
  ///
  /// ```dart
  /// router.push(MemoEditAddPage.query(parameters));    // Push page to MemoEditAddPage.
  /// router.replace(MemoEditAddPage.query(parameters)); // Replace page to MemoEditAddPage.
  /// ```
  @pageRouteQuery
  static const query = _$MemoEditAddPageQuery();

  @override
  FormScopedWidget build(BuildContext context, PageRef ref) =>
      const MemoEditForm();
}

/// Page for forms to edit data.
@immutable
@PagePath("memo_edit/:edit_id/edit")
class MemoEditEditPage extends FormEditPageScopedWidget {
  const MemoEditEditPage({
    super.key,
    @PageParam("edit_id") required super.editId,
  });

  /// Used to transition to the MemoEditEditPage screen.
  ///
  /// ```dart
  /// router.push(MemoEditEditPage.query(parameters));    // Push page to MemoEditEditPage.
  /// router.replace(MemoEditEditPage.query(parameters)); // Replace page to MemoEditEditPage.
  /// ```
  @pageRouteQuery
  static const query = _$MemoEditEditPageQuery();

  @override
  FormScopedWidget build(BuildContext context, PageRef ref) =>
      const MemoEditForm();
}

/// Widgets for form views.
@immutable
class MemoEditForm extends FormScopedWidget {
  const MemoEditForm({super.key});

  /// Used to transition to the MemoEditAddPage screen.
  ///
  /// ```dart
  /// router.push(MemoEditForm.addQuery(parameters));    // Push page to MemoEditAddPage.
  /// router.replace(MemoEditForm.addQuery(parameters)); // Replace page to MemoEditAddPage.
  /// ```
  static const addQuery = MemoEditAddPage.query;

  /// Used to transition to the MemoEditEditPage screen.
  ///
  /// ```dart
  /// router.push(MemoEditForm.editQuery(parameters));    // Push page to MemoEditEditPage.
  /// router.replace(MemoEditForm.editQuery(parameters)); // Replace page to MemoEditEditPage.
  /// ```
  static const editQuery = MemoEditEditPage.query;

  @override
  Widget build(BuildContext context, FormRef ref) {
    // Describes the process of loading
    // and defining variables required for the page.
    // 
    // You can use [ref.isAdding] or [ref.isEditing] to determine if the form is currently adding new data or editing data.
    //
    // If editing is in progress, it is possible to get the ID of the item being edited with [ref.editId].
    // TODO: Implement the variable loading process.
    

    // Describes the structure of the page.
    // TODO: Implement the view.
    return UniversalScaffold();
  }
}
```

`MemoEditForm`の`build`メソッドに渡される`ref`にフォームを作成するための様々な機能が提供されてます。

`ref.isEditing`が`true`の場合はデータが更新されているためにウィジェットが呼ばれていることがわかります。

その場合は編集用のドキュメントIDが`ref.editId`に渡されます。

また、`ref.select`メソッドを利用することでデータ追加時と編集時でデータを分けることが可能です。

これを利用して更新時に元のドキュメントデータをロードします。

また、作成されたデータスキームの`form`メソッドを利用することによりフォームで利用するためのコントローラーを取得することができます。

```dart
// memo_edit.dart

@override
Widget build(BuildContext context, FormRef ref) {
  // Describes the process of loading
  // and defining variables required for the page.
  //
  // You can use [ref.isAdding] or [ref.isEditing] to determine if the form is currently adding new data or editing data.
  //
  // If editing is in progress, it is possible to get the ID of the item being edited with [ref.editId].
  // TODO: Implement the variable loading process.
  final memoData = ref.select(
    onEdit: (editId) => ref.model(MemoModel.document(editId))..load(),
    onAdd: () => null,
  );
  final controller = ref.page.form(
    MemoModel.form(memoData?.value ?? const MemoModel(title: "", text: "")),
  );

  // Describes the structure of the page.
  // TODO: Implement the view.
  return UniversalScaffold();
}
```

フォームを作っていきましょう。

Masamuneフレームワーク用のフォームウィジェットが用意されているのでそれを利用していきます。

https://zenn.dev/mathru/articles/81dc538c1bb1a4

```dart
// memo_edit.dart

return UniversalScaffold(
  appBar: UniversalAppBar(
    title: Text("Editing memo"),
    actions: [
      IconButton(
        icon: const Icon(Icons.check),
        onPressed: () async {
          final value = controller.validate();
          if (value == null) {
            return;
          }
          
          if (ref.isEditing) {
            await memoData?.save(value).showIndicator(context);
          } else {
            final memoData = ref.app.model(MemoModel.collection()).create();
            await memoData.save(value).showIndicator(context);
          }
          router.pop();
        },
      ),
    ],
  ),
  body: UniversalColumn(
    children: [
      FormLabel("Title"),
      FormTextField(
        form: controller,
        hintText: "Title",
        initialValue: controller.value.title,
        onSaved: (value) => controller.value.copyWith(
          title: value,
        ),
      ),
      FormLabel("Text"),
      Expanded(
        child: FormTextField(
          form: controller,
          hintText: "Text",
          expands: true,
          initialValue: controller.value.text,
          style: const FormStyle(textAlignVertical: TextAlignVertical.top),
          onSaved: (value) => controller.value.copyWith(
            text: value,
          ),
        ),
      )
    ],
  ),
);
```

`FormLabel`で、フォームの項目ラベルを作成することができます。区切り線も兼ねているので`Divider`も合わせて利用するとまとまりのよいデザインを作成することができます。

`FormTextField`でテキストフィールドを作成することができます。

`form`にフォームコントローラー、`onSaved`にvalueを元に更新したフォームコントローラーを返すコールバックを指定します。

`initialValue`にフォームコントローラーの値を渡すことで更新時の初期値を与えることができます。

保存ボタンが押されたときに`controller.validate()`を実行することでフォームの検証と`onSaved`の実行を行います。検証に失敗した場合は`null`が返されるのでその後の処理を中断することも可能です。

`controller.validate()`で返された値をドキュメントの`save`に渡すことでフォームで入力された値が保存されます。

新規作成時はコレクションから`create`を実行して空のドキュメントを取得し、それを利用します。

`showIndicator(context)`はすべての`Future`に付与することが可能で、`Future`が完了するまでインジケーターを表示してユーザーがコントロールできないようにしています。

新規作成ページへ遷移するボタンを`MemoListPage`に作成します。

```dart
// memo_list.dart

@override
Widget build(BuildContext context, PageRef ref) {
  // Describes the process of loading
  // and defining variables required for the page.
  // TODO: Implement the variable loading process.
  final memoList = ref.app.model(MemoModel.collection())..load();

  // Describes the structure of the page.
  // TODO: Implement the view.
  return UniversalScaffold(
    appBar: UniversalAppBar(
      title: Text("Memo List"),
    ),
    body: UniversalListView(
      children: memoList.mapListenable((e) {
        return ListTile(
          title: Text(e.value?.title ?? ""),
          subtitle: Text(e.value?.createdAt.value.yyyyMMddHHmm() ?? ""),
          onTap: () {
            router.push(MemoDetailPage.query(memoId: e.uid));
          },
        );
      }),
    ),
    floatingActionButton: FloatingActionButton(
      onPressed: () {
        router.push(MemoEditAddPage.query());
      },
      child: const Icon(Icons.add),
    ),
  );
}
```

メモ内容編集ページへ遷移するボタンを`MemoDetailPage`に作成します。

```dart
// memo_detail.dart

@override
Widget build(BuildContext context, PageRef ref) {
  // Describes the process of loading
  // and defining variables required for the page.
  // TODO: Implement the variable loading process.
  final memoData = ref.app.model(MemoModel.document(memoId))..load();

  // Describes the structure of the page.
  // TODO: Implement the view.
  return UniversalScaffold(
    appBar: UniversalAppBar(
      title: Text(memoData.value?.title ?? ""),
      actions: [
        IconButton(
          icon: const Icon(Icons.save),
          onPressed: () {
            router.push(MemoEditEditPage.query(editId: memoId));
          },
        ),
      ],
    ),
    body: UniversalListView(
      padding: 16.p,
      children: [
        Text("投稿日時：${memoData.value?.createdAt.value.yyyyMMddHHmm() ?? ""}"),
        16.sy,
        Text(memoData.value?.text ?? ""),
      ],
    ),
  );
}
```

# メモの削除

データの削除はドキュメントの`delete`メソッドを実行します。

そのためのボタンを`MemoDetailPage`に作成していきましょう。

```dart
// memo_detail.dart

@override
Widget build(BuildContext context, PageRef ref) {
  // Describes the process of loading
  // and defining variables required for the page.
  // TODO: Implement the variable loading process.
  final memoData = ref.app.model(MemoModel.document(memoId))..load();

  if (memoData.value == null) {
    return UniversalScaffold();
  }

  // Describes the structure of the page.
  // TODO: Implement the view.
  return UniversalScaffold(
    appBar: UniversalAppBar(
      title: Text(memoData.value?.title ?? ""),
      actions: [
        IconButton(
          icon: const Icon(Icons.delete),
          onPressed: () {
            Modal.confirm(
              context,
              submitText: "Delete",
              cancelText: "Cancel",
              title: "Confirmation",
              text: "Delete the memo. Are you sure?",
              onSubmit: () async {
                await memoData.delete().showIndicator(context);
                router.pop();
              },
            );
          },
        ),
        IconButton(
          icon: const Icon(Icons.save),
          onPressed: () {
            router.push(MemoEditEditPage.query(editId: memoId));
          },
        ),
      ],
    ),
    body: UniversalListView(
      padding: 16.p,
      children: [
        Text("投稿日時：${memoData.value?.createdAt.value.yyyyMMddHHmm() ?? ""}"),
        16.sy,
        Text(memoData.value?.text ?? ""),
      ],
    ),
  );
}
```

削除ボタンを押すと、`Model.confirm`で確認ダイアログを表示します。その後、削除が確定した場合`memoData.delete()`でドキュメント自体を削除します。

データを削除すると`memoData.value`が`null`になるのでそのときのために`UniversalScaffold`をそのまま返すようにしています。

デバッグするとフォームを編集できるようになりました。

![](https://storage.googleapis.com/zenn-user-upload/1b5828830c16-20230702.png)

フォームを編集して保存すると

![](https://storage.googleapis.com/zenn-user-upload/bd5a458c8b77-20230703.png)

データの変更を検知してページも更新されます。

![](https://storage.googleapis.com/zenn-user-upload/930880e9db23-20230703.png)

このように`StatefulWidget`や`Riverpod`などで実装していた状態管理と画面の更新もMasamuneフレームワーク内で提供されるので画面の更新に悩む必要はありません。

# 番外編：Firestoreへの切り替え

現在はデータモックでアプリの確認を行うために`RuntimeModelAdapter`を利用しています。

これはアプリを起動し直すと元に戻る`ModelAdapter`です。

ちなみにこれで完成としてFirestoreで実運用を始めてみましょう。

その場合以前のように`modelAdapter`を切り替えるだけでOKです。

```dart
// adapter.dart

final modelAdapter = FirestoreModelAdapter(
  options: DefaultFirebaseOptions.currentPlatform,
);
// final modelAdapter = RuntimeModelAdapter(
//   initialValue: [
//     MemoModelInitialCollection(
//       {
//         for (var i = 0; i < 10; i++)
//           generateCode(32, seed: i): MemoModel(
//             title: "メモタイトル$i",
//             text: "メモテキスト$i",
//             createdAt: ModelTimestamp(DateTime.now().subtract(i.h)),
//           )
//       },
//     )
//   ],
// );
```

アプリを立ち上げるとデータが空になっており

![](https://storage.googleapis.com/zenn-user-upload/004db2843b4a-20230703.png)

データを新規の保存すると

![](https://storage.googleapis.com/zenn-user-upload/599c9db34af0-20230703.png)

データ追加されます。

![](https://storage.googleapis.com/zenn-user-upload/d3873f173de6-20230703.png)

Firestoreにもデータが入っていることがわかります。

![](https://storage.googleapis.com/zenn-user-upload/f83ab8f17aa7-20230703.png)

# おわりに

アプリの中でも良く利用されるフォームもMasamuneフレームワークでは効率よく作成することができます。

前回からメモ帳アプリを作成しましたが、Masamuneフレームワークを利用することでかなり実装を短縮することができることが変わると思います。

次回はこのメモ帳アプリに「`テキスト検索`」と「`画像アップロード`」機能を追加してブラッシュアップしてみましょう。

https://qiita.com/mathru/items/5b609bb5427ca2f72065

Masamuneフレームワークは[こちら](https://github.com/mathrunet/flutter_masamune)にソースを公開しています。issueやPullRequestをお待ちしてます！

また仕事の依頼等ございましたら、私の[Twitter](https://twitter.com/mathru)や[Webサイト](https://mathru.net)で直接ご連絡をお願いいたします！

https://mathru.net/ja/contact

# GitHub Sponsors

スポンサーを随時募集してます。ご支援お待ちしております！

https://github.com/sponsors/mathrunet