【Flutter】Masamuneで超速アプリ開発④「メモ帳アプリ作成 - メモの作成・編集」 - mathru.net | Flutter/Unityによるアプリ開発/楽曲・映像制作/素材配布
2023-07-02

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

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

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

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

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

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

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

メモの作成・更新

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

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

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

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

前回 からのプロジェクトを開いて、別ターミナルで下記コマンドを実行しコードの変更を監視してください。

katana code watch

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

katana code tmp form memo_edit

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

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

// 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 メソッドを利用することによりフォームで利用するためのコントローラーを取得することができます。

// 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フレームワーク用のフォームウィジェットが用意されているのでそれを利用していきます。

// 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 に作成します。

// 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 に作成します。

// 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 に作成していきましょう。

// 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 をそのまま返すようにしています。

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

image block

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

image block

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

image block

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

番外編:Firestoreへの切り替え

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

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

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

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

// 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)),
//           )
//       },
//     )
//   ],
// );

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

image block

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

image block

データ追加されます。

image block

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

image block

おわりに

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

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

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

Masamuneフレームワークは こちら にソースを公開しています。issueやPullRequestをお待ちしてます!

また仕事の依頼等ございましたら、私の Twitter や Webサイト で直接ご連絡をお願いいたします!

FlutterやUnityを使ったアプリ開発やアプリの紹介。制作した楽曲・映像の紹介。画像や映像素材の配布。仕事の受注なども行っています。
https://mathru.net https://mathru.net
title

GitHub Sponsors

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

Developed the katana/masamune framework, which has dramatically improved the overall efficiency of Flutter-based application development.
https://github.com https://github.com
title
【Flutter】Masamuneで超速アプリ開発⑤「メモ帳アプリ作成 - テキスト検索と画像アップロード」

Development Flutter Dart pub Plugins Masamune

◀︎ Next
【Flutter】Masamuneで超速アプリ開発③「メモ帳アプリ作成 - メモの表示」

Development Flutter Dart pub Plugins Masamune

▶︎ Previous