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

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

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

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

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

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

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

https://qiita.com/mathru/items/0a43fdc56ca70a953b1e

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

# テキスト検索

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

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

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

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

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

```dart
katana code watch
```

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

```dart
// 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._();
```

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

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

`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();
  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`を利用すると`ValueNotifier`や`ChangeNotifier`の状態をそのページ内だけで管理することができ、変更があった場合画面が更新されます。

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

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

```dart
memoList.search(value);
```

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

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

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

![](https://storage.googleapis.com/zenn-user-upload/760fed7d7238-20230706.png)

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

![](https://storage.googleapis.com/zenn-user-upload/2097a5e5a75a-20230706.png)

# 画像のアップロード

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

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

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

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

`app`→`picker`の項目の`enable`を`true`にし、`permission`を自身の言語コード（enやjaなど）をキーにした上でどのような目的で画像ライブラリを使用するかを記載してください。

```yaml
# 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.
  # If you want to use the camera, set [camera]->[enable] to true and specify the permission message to use the camera in [permission].
  # ファイルピッカーを利用するための設定を記述します。
  # [permission]にIOSでライブラリを利用するための権限許可メッセージを指定します。
  # `en`や`ja`などを記載しそこにその言語でのメッセージを記述してください。
  # カメラを利用する場合は[camera]->[enable]をtrueにして、[permission]にカメラを利用するための権限許可メッセージを指定して下さい。
  picker:
    enable: true # false -> true
    permission:
      ja: メモ画像用にライブラリを利用します。
    camera:
      enable: false
      permission:
        en: Use the camera for profile images.
```

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

```bash
katana apply
```

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

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

`masamuneAdapters`へ`PickerMasamuneAdapter`の追加を行います。

```dart
// adapter.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(),
];
```

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

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

```dart
// 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`に画像アップロード用のフォームを作成しましょう。

```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.app.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 picker = appRef.controller(Picker.query());
          final media = await picker.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`にタップしたときの動作を実装します。

まず`appRef.controller`と`Picker.query()`を利用しファイルピッカー用のコントローラーを呼び出します。

コントローラーから`pickSingle`を実行することでOSのファイルピッカーを起動します。

そこから取得できた`media`を`upload`メソッドを用いてアップロードし、`onUpdate`を利用して更新します。

`initialValue`に`ModelImageUri`を`toFormMediaValue`を用いながら変換し初期値として与えています。

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

`onSaved`で`MemoModel`の`image`にデータを渡すかを記載します。

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

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

```dart
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(),
        ),
      ],
    ],
  ),
);
```

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

![](https://storage.googleapis.com/zenn-user-upload/83fd33ab1b23-20230706.png)

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

![](https://storage.googleapis.com/zenn-user-upload/9a20bb83f197-20230706.png)

入力し保存すると

![](https://storage.googleapis.com/zenn-user-upload/32004707ebf8-20230706.png)

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

![](https://storage.googleapis.com/zenn-user-upload/8c3996b8d31a-20230706.png)


# 番外編：Firebase Storageへの切り替え

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

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

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

![](https://storage.googleapis.com/zenn-user-upload/636444882ced-20230706.png)

Storageを作成します。

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

![](https://storage.googleapis.com/zenn-user-upload/71ba33c5f1ee-20230706.png)

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

![](https://storage.googleapis.com/zenn-user-upload/9725f8c473e5-20230706.png)

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

`firebase` → `storage` → `enable`を`true`に変更します。

```yaml
# katana.yaml

# This section contains information related to Firebase.
# Firebase関連の情報を記載します。
firebase:
  # Set the Firebase project ID.
  # FirebaseのプロジェクトIDを設定します。
  project_id: masamune-test

  # Enable Firebase Firestore.
  # Set [generate_rules_and_indexes] to `true` to automatically generate Firestore security rules and indexes.
  # If [primary_remote_index] is set to `true`, indexes on the console are prioritized and automatic index import is enabled.
  # Firebase Firestoreを有効にします。
  # [generate_rules_and_indexes]を`true`にするとFirestoreのセキュリティルールとインデックスを自動生成します。
  # [primary_remote_index]を`true`にするとコンソール上のインデックスが優先されるため、インデックスの自動インポートが有効になります。
  firestore:
    enable: true
    generate_rules_and_indexes: false
    primary_remote_index: false

~~~~~~~~~~~

  # Enable Cloud Storage for Firebase.
  # If you want to use CORS to retrieve images, etc., set [cors] to `true`.
  # Cloud Storage for Firebaseを有効にします。
  # CORSで画像等を取得する場合は[cors]を`true`にしてください。
  storage:
    enable: true # false -> true
    cors: false
```

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

```bash
katana apply
```

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

`adapter.dart`を開きます。

```dart
// adapter.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 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();
```

これで完了です。

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

![](https://storage.googleapis.com/zenn-user-upload/4d8e0101b01a-20230706.png)

画像が表示されました！

![](https://storage.googleapis.com/zenn-user-upload/0d75933d0813-20230706.png)

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

![](https://storage.googleapis.com/zenn-user-upload/16128e94a270-20230706.png)

# おわりに

実装が大変な「`テキスト検索`」や「`画像アップロード`」もMasamuneフレームワークであればいとも簡単に実装することができました。

このようにMasamuneフレームワークではアプリを作る上での様々な機能実装をサポートします。まだまだ深掘りできるところは沢山あるので楽しみにしてください！

次回からこのメモ帳アプリに「`ユーザー管理`」「`ユーザー登録・認証`」機能を加えて「`マルチユーザーブログ`」アプリに進化させていきましょう。

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