こんにちは。広瀬マサルです。
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.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.value
がnull
になるのでそのときのためにUniversalScaffold
をそのまま返すようにしています。
デバッグするとフォームを編集できるようになりました。
フォームを編集して保存すると
データの変更を検知してページも更新されます。
このようにStatefulWidget
やRiverpod
などで実装していた状態管理と画面の更新も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)),
// )
// },
// )
// ],
// );
アプリを立ち上げるとデータが空になっており
データを新規の保存すると
データ追加されます。
Firestoreにもデータが入っていることがわかります。
おわりに
アプリの中でも良く利用されるフォームもMasamuneフレームワークでは効率よく作成することができます。
前回からメモ帳アプリを作成しましたが、Masamuneフレームワークを利用することでかなり実装を短縮することができることが変わると思います。
次回はこのメモ帳アプリに「テキスト検索
」と「画像アップロード
」機能を追加してブラッシュアップしてみましょう。
Masamuneフレームワークはこちらにソースを公開しています。issueやPullRequestをお待ちしてます!
また仕事の依頼等ございましたら、私のTwitterやWebサイトで直接ご連絡をお願いいたします!
GitHub Sponsors
スポンサーを随時募集してます。ご支援お待ちしております!