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

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

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

その場合は編集用のドキュメント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.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.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.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.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.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.valuenullになるのでそのときのためにUniversalScaffoldをそのまま返すようにしています。

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

image block

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

image block

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

image block

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

番外編:Firestoreへの切り替え

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

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

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

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

// main.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をお待ちしてます!

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

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

GitHub Sponsors

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

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

Development Flutter Dart pub Plugins Masamune

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

Development Flutter Dart pub Plugins Masamune

▶︎ Previous