2023-07-04

【Flutter】Masamuneで超速アプリ開発⑤「メモ帳アプリ作成 - テキスト検索と画像アップロード」

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

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

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

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

前々回から「メモ帳アプリ」の作成を開始しまして、メモの一覧・詳細表示、メモの追加・編集・削除までを行えるようになりました。

今回はこのアプリに「テキスト検索」と「画像のアップロード」機能をつけてより使いやすくしていきたいと思います。

テキスト検索

MasamuneフレームワークではNoSQLを使ってFirestoreやローカルDBを構築しています。

Firestoreなどではテキスト検索はあまり得意でなくAlgoliaなどを併用するように推奨されています。

MasamuneフレームワークではAlgoliaなどを利用せずとも簡易的なテキスト検索を行える機能を提供しています。

まずはデータを検索対象にしたいと思います。

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

katana code watch

MemoModelを開いて検索対象にしたいパラメーターに@searchParamのアノテーションを付けましょう。

// memo.dart

const factory MemoModel({
  // TODO: Set the data schema.
  @searchParam required String title,
  @searchParam required String text,
  @Default(ModelTimestamp()) ModelTimestamp createdAt,
}) = _MemoModel;
const MemoModel._();

これだけでtitletextはテキスト検索の対象になります。

それでは続いて検索フォームを作成しましょう。

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();
  final searchToggle = ref.page.watch((_) => ValueNotifier(false));

  // Describes the structure of the page.
  // TODO: Implement the view.
  return UniversalScaffold(
    appBar: UniversalAppBar(
      title: searchToggle.value
          ? FormTextField(
              autofocus: true,
              hintText: "Search",
              onChanged: (value) {
                if (value == null) {
                  return;
                }
                memoList.search(value);
              },
            )
          : Text("Memo List"),
      actions: [
        IconButton(
          color: searchToggle.value ? theme.color.primary : null,
          icon: const Icon(Icons.search),
          onPressed: () {
            searchToggle.value = !searchToggle.value;
          },
        ),
      ],
    ),
    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),
    ),
  );
}

UIとしては右上に検索ボタンが表示され、そこをタップすると検索ボックスが表示される仕様にしています。

下記のようにref.page.watchを利用するとValueNotifierChangeNotifierの状態をそのページ内だけで管理することができ、変更があった場合画面が更新されます。

final searchToggle = ref.page.watch((_) => ValueNotifier(false));

memoListsearchメソッドに検索したい文字列を渡すことによりmemoList内の要素がフィルタリングされ画面が更新されます。

memoList.search(value);

これで実装完了です。デバッグしてみましょう。

image block

検索ボタンを押すとテキストフィールドが出現します。

image block

テキストを入力するとそれに応じて要素が表示されます。

image block

画像のアップロード

それでは画像のアップロードを実装していきましょう。

モバイルで画像をアップロードしようとする場合、画像ライブラリへのアクセス権限が必要になります。

Masamuneフレームワークではそれらへのフォローも含めてまとめて設定可能です。

まずはkatana.yamlを開きます。

apppickerの項目のenabletrueにし、permissionを自身の言語コード(enやjaなど)をキーにした上でどのような目的で画像ライブラリを使用するかを記載してください。

# katana.yaml

app:

~~~~~~~~

	# Describe the settings for using the file picker.
  # Specify the permission message to use the library in IOS in [permission].
  # Please include `en`, `ja`, etc. and write the message in that language there.
  # ファイルピッカーを利用するための設定を記述します。
  # [permission]にIOSでライブラリを利用するための権限許可メッセージを指定します。
  # `en`や`ja`などを記載しそこにその言語でのメッセージを記述してください。
  picker:
    enable: true
    permission:
      ja: メモ画像用にライブラリを利用します。

記載後katana applyコマンドを実行します。

katana apply

IOSで設定必須なNSPhotoLibraryUsageDescriptionなどの設定も上記コマンドでまとめて行われます。

次にmain.dartを開きましょう。

Pickerオブジェクトの定義と、MasamuneAdapterPickerMasamuneAdapterの追加を行います。

// main.dart
import 'package:masamune_picker/masamune_picker.dart';

~~~~~~~

/// Masamune adapter.
///
/// The Masamune framework plugin functions can be defined together.
// TODO: Add the adapters.
final masamuneAdapters = <MasamuneAdapter>[
  const UniversalMasamuneAdapter(),
  const PickerMasamuneAdapter(),
];

~~~~~~~

/// App picker.
final appPicker = Picker();

これで下準備は完了です。

続いてMemoModelに画像のデータスキームを追加しましょう。

// memo.dart

const factory MemoModel({
  // TODO: Set the data schema.
  @searchParam required String title,
  @searchParam required String text,
  ModelImageUri? image,
  @Default(ModelTimestamp()) ModelTimestamp createdAt,
}) = _MemoModel;

ModelImageUriは画像用のURIを保存するための特殊クラスです。

Stringでも代用可能ですがModelImageUriは画像としての取扱が良くなるので積極的に採用しましょう。

続いてmemo_edit.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(controller.value).showIndicator(context);
          } else {
            final memoData = ref.model(MemoModel.collection()).create();
            await memoData.save(controller.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("Image"),
      FormMedia(
        form: controller,
        onTap: (ref) async {
          final media = await appPicker.pickSingle(
            type: PickerFileType.image,
          );
          final uploaded = await media.upload().showIndicator(context);
          if (uploaded == null) {
            return;
          }
          ref.update(uploaded, FormMediaType.image);
        },
        initialValue: controller.value.image?.toFormMediaValue(),
        style: FormStyle(padding: 16.p),
        builder: (context, media) {
          return Image(
            image: media.toImageProvider(),
            fit: BoxFit.cover,
          );
        },
        onSaved: (value) => controller.value.copyWith(
          image: value.toModelImageUri(),
        ),
      ),
    ],
  ),
);

FormMediaが画像などのメディアデータを入力させるためのフォームです。

formにフォームコントローラーを渡します。

onTapにタップしたときの動作を実装します。

今回は先程定義したappPickerを利用しpickSingleを実行することでOSのファイルピッカーを起動します。そこから取得できたmediauploadメソッドを用いてアップロードし、onUpdateを利用して更新します。

initialValueModelImageUritoFormMediaValueを用いながら変換し初期値として与えています。

builderにはどのようにメディアデータを表示するかを実装します。

onSavedMemoModelimageにデータを渡すかを記載します。

最後にmemo_detail.dartを開き保存した画像を表示する仕組みを作りましょう。

単純にMemoModelimageを利用すればよいです。toImageProvider()メソッドを利用するとそのままImageProviderとして利用可能なので積極的に使いましょう。

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 ?? ""),
      if (memoData.value?.image != null) ...[
        32.sy,
        Image(
          image: memoData.value!.image!.toImageProvider(),
        ),
      ],
    ],
  ),
);

デバッグしてみます。下記のようにフォームが表示されます。

image block

画像部分をタップすると権限許可のダイアログが表示され、このようにOSのファイルピッカーが起動します。

image block

入力し保存すると

image block

このように画像が文章と共に表示されます。

image block

番外編:Firebase Storageへの切り替え

画像アップロード機能を実装しました。

現在はローカル上に保存されているファイルをそのまま表示していますが、実際にCloud Storage for Firebaseを利用してアップロードを行いましょう。

まずは、すでに作成しているFirebaseのプロジェクトを開きます。

image block

Storageを作成します。

最初は「Test Mode」で進めましょう。

image block

RegionはFirestore作成時に設定したので変更できません。

image block

続いてFlutterプロジェクトに戻りkatana.yamlを書き換えます。

firebasestorageenabletrueに変更します。

# katana.yaml

firebase:
  # Set the Firebase project ID.
  # FirebaseのプロジェクトIDを設定します。
  project_id: masamune-test

  # Enable Firebase Firestore.
  # Firebase Firestoreを有効にします。
  firestore:
    enable: true
    overwrite_rule: false

  # Enable Firebase Authentication.
  # Firebase Authenticationを有効にします。
  authentication:
    enable: false



  # Enable Cloud Storage for Firebase.
  # Cloud Storage for Firebaseを有効にします。
  storage:
    enable: true # false -> true
    overwrite_rule: false

下記のコマンドを実行します。

katana apply

続いてアダプターを切り替えましょう。ついでにデータベースもFirestoreに切り替えます。

main.dartを開きます。

// main.dart
import 'package:katana_storage_firebase/katana_storage_firebase.dart';

~~~~~~~

/// App Model.
///
/// By replacing this with another adapter, the data storage location can be changed.
// TODO: Change the database.
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)),
//           )
//       },
//     )
//   ],
// );

/// App Auth.
///
/// Changing to another adapter allows you to change to another authentication mechanism.
// TODO: Change the authentication.
final authAdapter = RuntimeAuthAdapter();

/// App Storage.
///
/// Changing to another adapter allows you to change to another storage mechanism.
// TODO: Change the storage.
final storageAdapter = FirebaseStorageAdapter(
  options: DefaultFirebaseOptions.currentPlatform,
);
// final storageAdapter = LocalStorageAdapter();

これで完了です。

デバッグしてみましょう。画像付きでメモを更新してみます。

image block

画像が表示されました!

image block

Storageのバケットにもファイルがアップロードされているのがわかります。

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のDB連携機能強化

Development Flutter Masamune Dart pub

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

Development Flutter Dart pub Plugins Masamune

▶︎ Previous