【Flutter】Googleスプレッドシートをデータソースとして使えるようにした

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

Flutter上で動作する統合フレームワーク**Masamune**を随時更新しています。

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

今回はデータモデル周りに新しいアップデートがさらにあったので紹介します。

# masamune

https://pub.dev/packages/masamune

https://pub.dev/packages/masamune_builder

# はじめに

[Masamuneフレームワーク](https://pub.dev/packages/masamune)は「**CLIによるコード生成**」と「**build_runnnerによるコードの自動生成**」を用いて可能な限りコードの記述量を減らし、アプリ開発の安定性と高速性を高めるFlutter上のフレームワークです。

データベースに関しても**Firestore**をベースにしたNoSQLデータベースを利用し、わずか１行の更新で`ランタイムDB`と`端末ローカルDB`、`Firestore`を切り替えることが可能な仕組みを提供しています。

以前下記の記事で`ModelAdapter`を**各データモデルごとに設定可能**になったと書きました。

https://zenn.dev/mathru/articles/48e5febda53454

今回はさらに`Googleスプレッドシートから取得したデータを利用可能なModelAdapter`が追加されました。

アプリの設定をアプリ内だけでなくGoogleスプレッドシートに記述することができ、**FirestoreやローカルDBとリレーションを繋げながら同じようなデータとして扱うことができる**ようになります。

## Googleスプレッドシートでデータを記載する利点

アプリの設定をソースコード内に書かずにGoogleスプレッドシートに記載する利点は主に下記三点です。

1. ソースコードの記述を減らせる
2. オンラインで編集できる
3. **非エンジニアでも編集できる**

特に３が重要です。アプリの発注者や運営者といった社外の方やPdMやPjMといった社内の人間も非エンジニアであることが多くソースコードを直に弄ることができません。

その点Googleスプレッドシートなどの表計算ソフトはビジネスマンであれば誰でも触ったことがあるソフトウェアなので扱いやすく作業を依頼しやすいです。

例えば、ECアプリの必要カテゴリーについてはマーケティングとの依存性が強くそちらの業務を行っているメンバーが直に記載するのが理にかなっています。

Googleスプレッドシートに直接データを記載してもらうことで他メンバーとエンジニアとのコミュニケーションコストを減らしエンジニアの工数も減らしてくれるでしょう。

[Masamuneフレームワーク](https://zenn.dev/mathru/articles/dcd9a52bcf3e94)ではすでに翻訳をGoogleスプレッドシートで管理するようにしています。

これは翻訳作業をエンジニアではなく翻訳家が行いやすい形で依頼できるようにします。

# 事前準備

事前にGoogleスプレッドシートを利用可能にします。

1. [こちらのテンプレート](https://docs.google.com/spreadsheets/d/1bfNX8clPH9PFOcfFIStNCGNGjeCKwGv-24iChSJn8yM/edit#gid=0)からスプレッドシートを自分のGoogleドライブにコピーします。
    - `CollectionModelPath`を利用する場合はコレクション用のシートを利用し、`DocumentModelPath`を利用する場合はドキュメント用のシートを利用します。
    - `ファイル` -> `コピーの作成`からコピーが可能です。
2. コピーしたスプレッドシート内で`ファイル` -> `共有` -> `他のユーザーと共有`をクリックします。
3. `（作成したスプレッドシート名）を共有`ウィンドウ内で、`一般的なアクセス`を`**リンクを知っている全員**`に変更します。

# 実際にやってみよう

それでは実際の実装を行っていきたいと思います。

今回は簡易的にECショップの商品やカテゴリーをスプレッドシートに定義し、それを購入して履歴に残すデモアプリを作成していきます。

## プロジェクトの作成

まず`katana create`でプロジェクトを作成します。

```bash
katana create net.mathru.csvtest
```

少し待てばプロジェクトファイルが作成されます。

## スプレッドシートデータの作成

まずはスプレッドシートでデータを作成していきましょう。

シート名を`Product`に変え下記のようにデータを記述していきます。

翻訳を分けたい場合は`name:en`のように`キー:言語コード`で分けてデータを登録してください。

![](https://storage.googleapis.com/zenn-user-upload/647f9e6628f9-20230815.png)

## モデルの作成

Flutterプロジェクト内にもどり続いて各種データモデルを作成していきましょう。

まずCLIでコードを生成します。

```bash
# 商品データ
katana code collection product
```

生成されたDartファイルを編集します。

`GoogleSpreadSheetDataSource`の中にはGoogleスプレッドシートの`Product`シートを開いているときのURLを記述します。

スプレッドシートに翻訳を登録した場合は、`ModelLocalizedValue`を使って値をまとめることができます。

```dart
// models/product.dart

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath("product")
@GoogleSpreadSheetDataSource(
  "https://docs.google.com/spreadsheets/d/1HojG5QzScb8b0EB1HvHHN11yaTEf2hwwtrrUwBI5U38/edit#gid=0",
  version: 1,
)
abstract class ProductModel with _$ProductModel {
  const factory ProductModel({
    // TODO: Set the data schema.
    required int id,
    @Default(ModelLocalizedValue()) ModelLocalizedValue name,
    @Default(ModelLocalizedValue()) ModelLocalizedValue description,
    @Default(0.0) double price,
  }) = _ProductModel;
  const ProductModel._();

  ~~~~
}
```

下記コマンドで`build_runner`による自動生成を行います。

```bash
katana code generate
```

これでCSV内のデータがソースコードに適用されます。

## 商品一覧ページの作成

下記コマンドで商品一覧ページを作成します。

```bash
katana code page product
```

商品一覧を表示するように書き換えます。

```dart
// pages/product.dart

@immutable
// TODO: Set the path for the page.
@PagePath("product")
class ProductPage extends PageScopedWidget {
  const ProductPage({
    super.key,
    // TODO: Set parameters for the page.
  });

  // TODO: Set parameters for the page in the form [final String xxx].

  /// Used to transition to the ProductPage screen.
  ///
  /// ```dart
  /// router.push(ProductPage.query(parameters));    // Push page to ProductPage.
  /// router.replace(ProductPage.query(parameters)); // Push page to ProductPage.
  /// ```
  @pageRouteQuery
  static const query = _$ProductPageQuery();

  @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 product = ref.model(ProductModel.collection())..load();

    // Describes the structure of the page.
    // TODO: Implement the view.
    return UniversalScaffold(
      appBar: const UniversalAppBar(
        title: Text("Product List"),
      ),
      body: UniversalListView(
        children: [
          ...product.mapListenable((item) {
            return ListTile(
              title: Text(item.value?.name.value.value(l.locale) ?? ""),
              subtitle:
                  Text(item.value?.description.value.value(l.locale) ?? ""),
              trailing:
                  Text("￥${item.value?.price.toStringAsFixed(0) ?? "0"}"),
            );
          }),
        ],
      ),
    );
  }
}
```

main.dartの`initialQuery`を変更します。

```dart
// main.dart

/// Initial page query.
// TODO: Define the initial page query of the application.
final initialQuery = ProductPage.query();
```

このままビルドして確認してみます。

一覧が表示されました！

![](https://storage.googleapis.com/zenn-user-upload/7143dff0bcc5-20230815.png)

## カテゴリーの追加

まずGoogleスプレッドシートに`Category`のシートを作成し、下記のように編集します。

![](https://storage.googleapis.com/zenn-user-upload/9094537bc8c7-20230815.png)

また、`Product`のシートに`category`の項目を追加しリレーションデータを記載します。

リレーションデータは下記のフォーマットで書くことができます。

```
ref://[コレクション名※アプリ内で指定]/[対象のID]
```

今回のデータだと下記のようになります。

| リレーションデータ | 英語名 | 日本語名 |
| --- | --- | --- |
| ref://category/1 | Katana | 刀 |
| ref://category/2 | Gun | 銃 |

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

Flutterプロジェクトに戻りCLIでコードを生成します。

```bash
katana code collection category
```

生成されたDartファイルを編集します。

`GoogleSpreadSheetDataSource`の中にはGoogleスプレッドシートの`Category`シートを開いているときのURLを記述します。

```dart
// models/category.dart

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath("category")
@GoogleSpreadSheetDataSource(
  "https://docs.google.com/spreadsheets/d/1HojG5QzScb8b0EB1HvHHN11yaTEf2hwwtrrUwBI5U38/edit#gid=653415878",
  version: 1,
)
abstract class CategoryModel with _$CategoryModel {
  const factory CategoryModel({
    // TODO: Set the data schema.
    required int id,
    @Default(ModelLocalizedValue()) ModelLocalizedValue name,
  }) = _CategoryModel;
  const CategoryModel._();

  ~~~~
}
```

また、`models/product.dart`に`CategoryModel`へのリレーションの追加とデータを更新するための`GoogleSpreadSheetDataSource`の`version`を増やします。

他のデータのリレーションを作成するためには`@refParam`のアノテーションを付与して`ModelRefBase`を継承したクラス（今回は`CategoryModelRef`）の型を指定します。

```dart
// models/product.dart

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath("product")
@GoogleSpreadSheetDataSource(
  "https://docs.google.com/spreadsheets/d/1HojG5QzScb8b0EB1HvHHN11yaTEf2hwwtrrUwBI5U38/edit#gid=0",
  version: 2,
)
abstract class ProductModel with _$ProductModel {
  const factory ProductModel({
    // TODO: Set the data schema.
    required int id,
    @Default(ModelLocalizedValue()) ModelLocalizedValue name,
    @Default(ModelLocalizedValue()) ModelLocalizedValue description,
    @Default(0.0) double price,
    @refParam CategoryModelRef category,
  }) = _ProductModel;
  const ProductModel._();

  ~~~~
}
```

下記コマンドで`build_runner`による自動生成を行います。

```bash
katana code generate
```

`pages/product.dart`でカテゴリーをあわせて表示しましょう。

```dart
// pages/product.dart

@immutable
// TODO: Set the path for the page.
@PagePath("product")
class ProductPage extends PageScopedWidget {
  const ProductPage({
    super.key,
    // TODO: Set parameters for the page.
  });

  // TODO: Set parameters for the page in the form [final String xxx].

  /// Used to transition to the ProductPage screen.
  ///
  /// ```dart
  /// router.push(ProductPage.query(parameters));    // Push page to ProductPage.
  /// router.replace(ProductPage.query(parameters)); // Push page to ProductPage.
  /// ```
  @pageRouteQuery
  static const query = _$ProductPageQuery();

  @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 product = ref.model(ProductModel.collection())..load();

    // Describes the structure of the page.
    // TODO: Implement the view.
    return UniversalScaffold(
      appBar: const UniversalAppBar(
        title: Text("Product List"),
      ),
      body: UniversalListView(
        children: [
          ...product.mapListenable((item) {
            return ListTile(
              title: Text(item.value?.name.value.value(l.locale) ?? ""),
              subtitle: Text(
                  "${item.value?.category?.value?.name.value.value(l.locale) ?? ""}\n${item.value?.description.value.value(l.locale) ?? ""}"),
              trailing: Text("￥${item.value?.price.toStringAsFixed(0) ?? "0"}"),
            );
          }),
        ],
      ),
    );
  }
}
```

これでビルドしてみると無事カテゴリーが表示されていることがわかります。

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

## カテゴリー別のページを表示

現在のページをカテゴリー別に表示するように改造しましょう。

まずは`pages/product.dart`を編集します。

`categoryId`を引数で受け取るようにし、`ProductModel`をコレクションを`category`でフィルタリングするようにします。

```dart
@immutable
// TODO: Set the path for the page.
@PagePath("product/:category_id")
class ProductPage extends PageScopedWidget {
  const ProductPage({
    super.key,
    // TODO: Set parameters for the page.
    @pageParam required this.categoryId,
  });

  // TODO: Set parameters for the page in the form [final String xxx].
  final String categoryId;

  /// Used to transition to the ProductPage screen.
  ///
  /// ```dart
  /// router.push(ProductPage.query(parameters));    // Push page to ProductPage.
  /// router.replace(ProductPage.query(parameters)); // Push page to ProductPage.
  /// ```
  @pageRouteQuery
  static const query = _$ProductPageQuery();

  @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 category = ref.model(CategoryModel.document(categoryId))..load();
    final product = ref.model(
      ProductModel.collection().category.equal(category),
    )..load();

    // Describes the structure of the page.
    // TODO: Implement the view.
    return UniversalScaffold(
      appBar: const UniversalAppBar(
        title: Text("Product List"),
      ),
      body: UniversalListView(
        children: [
          ...product.mapListenable((item) {
            return ListTile(
              title: Text(item.value?.name.value.value(l.locale) ?? ""),
              subtitle: Text(
                  "${item.value?.category?.value?.name.value.value(l.locale) ?? ""}\n${item.value?.description.value.value(l.locale) ?? ""}"),
              trailing: Text("￥${item.value?.price.toStringAsFixed(0) ?? "0"}"),
            );
          }),
        ],
      ),
    );
  }
}
```

このようにタイプセーフにフィルター条件を指定することが可能です。

```dart
ProductModel.collection().category.equal(category)
```

一度ここで`build_runner`による自動生成を行います。

```bash
katana code generate
```

続いてカテゴリーの一覧ページを作成します。

```bash
katana code page category
```

作成されたページを編集します。

```dart
// pages/category.dart

@immutable
// TODO: Set the path for the page.
@PagePath("category")
class CategoryPage extends PageScopedWidget {
  const CategoryPage({
    super.key,
    // TODO: Set parameters for the page.
  });

  // TODO: Set parameters for the page in the form [final String xxx].

  /// Used to transition to the CategoryPage screen.
  ///
  /// ```dart
  /// router.push(CategoryPage.query(parameters));    // Push page to CategoryPage.
  /// router.replace(CategoryPage.query(parameters)); // Push page to CategoryPage.
  /// ```
  @pageRouteQuery
  static const query = _$CategoryPageQuery();

  @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 category = ref.model(CategoryModel.collection())..load();

    // Describes the structure of the page.
    // TODO: Implement the view.
    return UniversalScaffold(
      appBar: const UniversalAppBar(
        title: Text("Category List"),
      ),
      body: UniversalListView(
        children: [
          ...category.mapListenable((item) {
            return ListTile(
              title: Text(item.value?.name.value.value(l.locale) ?? ""),
              onTap: () {
                router.push(ProductPage.query(categoryId: item.uid));
              },
            );
          })
        ],
      ),
    );
  }
}
```

main.dartで初期ページをカテゴリー一覧に入れ替えます。

```dart
// main.dart

/// Initial page query.
// TODO: Define the initial page query of the application.
final initialQuery = CategoryPage.query();
```

下記コマンドで`build_runner`による自動生成を行います。

```bash
katana code generate
```

ビルドしてみましょう。

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

![](https://storage.googleapis.com/zenn-user-upload/97bde3aa2fff-20230815.png)

カテゴリー一覧から項目をタップするとその項目でフィルタリングされていることがわかります。

## 購入履歴の実装

商品をタップすると購入し１度しか購入できないようにしてみましょう。

まずは購入履歴のモデルを作成します。

```bash
katana code collection history
```

生成されたコードを書き換えます。

```dart
// models/history.dart

/// Value for model.
@freezed
@formValue
@immutable
// TODO: Set the path for the collection.
@CollectionModelPath("history")
abstract class HistoryModel with _$HistoryModel {
  const factory HistoryModel({
    // TODO: Set the data schema.
    @RefParam(ProductModelDocument) ProductModelRef product,
  }) = _HistoryModel;
  const HistoryModel._();

  ~~~~
}
```

下記コマンドで`build_runner`による自動生成を行います。

```bash
katana code generate
```

`pages/product.dart`を編集します。

`history`のコレクションにプロダクト自身の`uid`が含まれるかどうかを検証します。

含まれない場合は購入可能としタップするとhisotryのコレクションにプロダクト自身を含めたドキュメントを保存します。

```dart
// pages/product.dart

@immutable
// TODO: Set the path for the page.
@PagePath("product/:category_id")
class ProductPage extends PageScopedWidget {
  const ProductPage({
    super.key,
    // TODO: Set parameters for the page.
    @pageParam required this.categoryId,
  });

  // TODO: Set parameters for the page in the form [final String xxx].
  final String categoryId;

  /// Used to transition to the ProductPage screen.
  ///
  /// ```dart
  /// router.push(ProductPage.query(parameters));    // Push page to ProductPage.
  /// router.replace(ProductPage.query(parameters)); // Push page to ProductPage.
  /// ```
  @pageRouteQuery
  static const query = _$ProductPageQuery();

  @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 category = ref.model(CategoryModel.document(categoryId))..load();
    final product = ref.model(
      ProductModel.collection().category.equal(category),
    )..load();
    final history = ref.model(HistoryModel.collection())..load();

    // Describes the structure of the page.
    // TODO: Implement the view.
    return UniversalScaffold(
      appBar: const UniversalAppBar(
        title: Text("Product List"),
      ),
      body: UniversalListView(
        children: [
          ...product.mapListenable((item) {
            final purchased =
                history.any((e) => e.value?.product?.uid == item.uid);
            return ListTile(
              title: Text(item.value?.name.value.value(l.locale) ?? ""),
              subtitle: Text(
                  "${item.value?.category?.value?.name.value.value(l.locale) ?? ""}\n${item.value?.description.value.value(l.locale) ?? ""}"),
              trailing: purchased
                  ? Icon(Icons.check_circle, color: theme.color.success)
                  : Text("￥${item.value?.price.toStringAsFixed(0) ?? "0"}"),
              onTap: purchased
                  ? null
                  : () async {
                      final doc = history.create();
                      await doc
                          .save(
                            HistoryModel(
                              product: item,
                            ),
                          )
                          .showIndicator(context);
                    },
            );
          }),
        ],
      ),
    );
  }
}
```

ビルドしてみましょう。

各プロダクトをタップすると購入済みマークが付くことがわかります。

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

今回は`RuntimeModelAdapter`を利用しているのでアプリを再起動すると履歴が失われますが`LocalModelAdapter`や`FirestoreModelAdapter`を利用することで購入履歴の永続化が可能です。

詳しくは下記を参照ください。

https://qiita.com/mathru/items/f66cfb7e52503de1d811

## 購入履歴一覧画面の作成

最後に購入履歴一覧画面の作成を行います。

まず購入履歴一覧画面を作成しましょう。

```bash
katana code page history
```

生成されたコードを編集します。

`HistoryModel`で定義された`product`には関連する`ProductModel`のデータが保存されそのまま取得することができます。

```dart
// pages/history.dart

@immutable
// TODO: Set the path for the page.
@PagePath("history")
class HistoryPage extends PageScopedWidget {
  const HistoryPage({
    super.key,
    // TODO: Set parameters for the page.
  });

  // TODO: Set parameters for the page in the form [final String xxx].

  /// Used to transition to the HistoryPage screen.
  ///
  /// ```dart
  /// router.push(HistoryPage.query(parameters));    // Push page to HistoryPage.
  /// router.replace(HistoryPage.query(parameters)); // Push page to HistoryPage.
  /// ```
  @pageRouteQuery
  static const query = _$HistoryPageQuery();

  @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 history = ref.model(HistoryModel.collection())..load();

    // Describes the structure of the page.
    // TODO: Implement the view.
    return UniversalScaffold(
      appBar: const UniversalAppBar(
        title: Text("History"),
      ),
      body: UniversalListView(
        children: [
          ...history.mapListenable((item) {
            final product = item.value?.product;
            return ListTile(
              title: Text(product?.value?.name.value.value(l.locale) ?? ""),
              subtitle: Text(
                  "${product?.value?.category?.value?.name.value.value(l.locale) ?? ""}\n${product?.value?.description.value.value(l.locale) ?? ""}"),
            );
          })
        ],
      ),
    );
  }
}
```

カテゴリー一覧と購入履歴を表示するメニューを作成しましょう。

メニュー用のページをコマンドから作成します。

```bash
katana code page menu
```

作成されたページを編集します。

```dart
// pages/menu.dart

@immutable
// TODO: Set the path for the page.
@PagePath("menu")
class MenuPage extends PageScopedWidget {
  const MenuPage({
    super.key,
    // TODO: Set parameters for the page.
  });

  // TODO: Set parameters for the page in the form [final String xxx].

  /// Used to transition to the MenuPage screen.
  ///
  /// ```dart
  /// router.push(MenuPage.query(parameters));    // Push page to MenuPage.
  /// router.replace(MenuPage.query(parameters)); // Push page to MenuPage.
  /// ```
  @pageRouteQuery
  static const query = _$MenuPageQuery();

  @override
  Widget build(BuildContext context, PageRef ref) {
    // Describes the process of loading
    // and defining variables required for the page.

    // Describes the structure of the page.
    // TODO: Implement the view.
    return UniversalScaffold(
      appBar: const UniversalAppBar(
        title: Text("Demo"),
      ),
      body: UniversalListView(
        children: [
          ListTile(
            title: const Text("Category"),
            onTap: () {
              router.push(CategoryPage.query());
            },
          ),
          ListTile(
            title: const Text("History"),
            onTap: () {
              router.push(HistoryPage.query());
            },
          )
        ],
      ),
    );
  }
}
```

`main.dart`を編集します。

```dart
// main.dart

/// Initial page query.
// TODO: Define the initial page query of the application.
final initialQuery = MenuPage.query();
```

下記コマンドで`build_runner`による自動生成を行います。

```bash
katana code generate
```

ビルドしてみましょう。

起動後、商品をタップしHistoryのページに戻ると購入した商品が表示されていることがわかります。

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

このように**データベースからGoogleスプレッドシートのデータソースへのリレーションを作成し内部を意識することなく利用する**ことができます。

# おわりに

Googleスプレッドシートを気軽にデータソースとして利用しながら他のデータベースへのリレーションを実現できるため非エンジニアとの連携も行いながらアプリを実装することができるようになりました。

自分で使う用途で作ったものですが実装の思想的に合ってそうならぜひぜひ使ってみてください！

また、[こちら](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