🦥

Riverpod2.0でCRUD

2022/12/22に公開

追加・表示・更新・削除をやってみる

Qiitaの記事を書いていた頃に作っていたFlutterで、CRUDするDemoアプリを作っていたのですが、その時は、知識が足りなくて、Riverpodにリファクタリングができませんでした😇
今回は、Outputのために最近破壊的な変更がされたRiverpod2.0で、FireStoreにデータを追加・表示・編集・削除をできるdemoアプリを作成いたしました。

完成品のソースコード

https://github.com/sakurakotubaki/Riverpod2.0CRUD

  • アプリの構成
    • 詳細はpubspec.yamlに情報を記載
      • Flutter 3.3.1
      • Dart 2.18.0
pubspec.yaml
name: crud_app
description: A new Flutter project.

# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1

environment:
  sdk: '>=2.18.0 <3.0.0'

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter


  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2
  firebase_core: ^2.4.0
  cloud_firestore: ^4.2.0
  flutter_riverpod: ^2.1.1

dev_dependencies:
  flutter_test:
    sdk: flutter

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^2.0.0

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter packages.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/assets-and-images/#resolution-aware

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/assets-and-images/#from-packages

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/custom-fonts/#from-packages
  • アプリ開発の準備
  1. Firebaseにプロジェクトを作る.
  2. FireStoreを使えるようにしておく.
  3. Firebase CLIを使って、iOS、Android、Flutter Web、使いたい開発環境の構築をする.

iOSの設定

Firebaseへの接続を高速化してくれるフレームワークがあるので、接続が遅いなと思う人は使ってみてください。
iosディレクトリのPodfileに追加

公式のGithubのリンク

ios/Podfile
# Uncomment this line to define a global platform for your project
# platform :ios, '11.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
  'Debug' => :debug,
  'Profile' => :release,
  'Release' => :release,
}

def flutter_root
  generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
  unless File.exist?(generated_xcode_build_settings_path)
    raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
  end

  File.foreach(generated_xcode_build_settings_path) do |line|
    matches = line.match(/FLUTTER_ROOT\=(.*)/)
    return matches[1].strip if matches
  end
  raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
  use_frameworks!
  use_modular_headers!
  # 追加
  pod 'FirebaseFirestore', :git => 'https://github.com/invertase/firestore-ios-sdk-frameworks.git', :tag => '10.3.0'
  flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
end

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
  end
end

Androidの設定

buildしたら、赤いエラーがいっぱい出て、buildできなかったので、エラーを解消した設定を記載して起きます。
android/app/build.gradleの設定メモ

こちらの設定も追加するリンクが出たので追加しました!
https://developer.android.com/studio/build/multidex

def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
    localPropertiesFile.withReader('UTF-8') { reader ->
        localProperties.load(reader)
    }
}

def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
    throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}

def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
    flutterVersionCode = '1'
}

def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
    flutterVersionName = '1.0'
}

apply plugin: 'com.android.application'
// START: FlutterFire Configuration
apply plugin: 'com.google.gms.google-services'
// END: FlutterFire Configuration
apply plugin: 'kotlin-android'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

android {
    compileSdkVersion 33 // 33指定
    ndkVersion flutter.ndkVersion

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = '1.8'
    }

    sourceSets {
        main.java.srcDirs += 'src/main/kotlin'
    }

    defaultConfig {
        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
        applicationId "com.example.crud_app"
        // You can update the following values to match your application needs.
        // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
        minSdkVersion 19 // 19を指定
        targetSdkVersion 33 // 33を指定
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
        multiDexEnabled true // 追加
    }

    buildTypes {
        release {
            // TODO: Add your own signing config for the release build.
            // Signing with the debug keys for now, so `flutter run --release` works.
            signingConfig signingConfigs.debug
        }
    }
}

flutter {
    source '../..'
}

dependencies {
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation "androidx.multidex:multidex:2.0.1"// 追加
}

ディレクトリの構成
色々機能をつけたかったのですが、CRUDするのが、目的なのでやめときました😇

  • 解説
    • extensionsは、画面遷移で使いまわせる関数を定義.
    • uiは、画面のコードを配置。pagesにHome画面、表示画面と追加画面のコードを記載.
    • utilsは、Firebaseを使用するためのロジックを定義.
lib
├── extensions
│   └── extension.dart
├── firebase_options.dart
├── main.dart
├── ui
│   └── pages
│       ├── add_page.dart
│       ├── home_page.dart
│       └── read_page.dart
└── utils
    ├── app_state.dart
    └── firebase_provider.dart

アプリを実行するファイル

main.dart
import 'package:crud_app/firebase_options.dart';
import 'package:crud_app/ui/pages/home_page.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(
    ProviderScope(child: MyApp()),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const HomePage(),
    );
  }
}

画面遷移のコードを短くできる関数

使ってみたら便利で気に入って使ってます。
go_routerも使おうかなと思うんですけど、最近公式が推奨してるらしいからそのうち使って何か作ってみようかなと思ってたりします。

extensions/extension.dart
import 'package:flutter/material.dart';

/// [extensionで画面遷移のWidgetを作成する関数を作成]
extension BuildContextE on BuildContext {
  Future<void> to(Widget view) async {
    await Navigator.of(this).push(
      MaterialPageRoute(
        builder: (context) {
          return view;
        },
      ),
    );
  }
}

Firebaseの設定

RiverpodのProviderを使ってどこからでも呼び出せるように、ロジックを作ります。
Providerも種類がありますが、今回は3つしか使わないです。
https://riverpod.dev/ja/docs/concepts/providers/

  • Provider
  • StateProvider
  • StreamProvider
utils/firebase_provider
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Firebaseを使うためのProvider
final firebaseProvider =
    Provider<FirebaseFirestore>((ref) => FirebaseFirestore.instance);

// TextEditingControllerを使うためのProvider
final textProvider = StateProvider.autoDispose((ref) {
  // riverpodで使うには、('')が必要
  return TextEditingController(text: '');
});

// FireStoreの'memos'コレクションのすべてのドキュメントを取得するプロバイダー。初回に全件分、あとは変更があるたびStreamに通知される。
final firebaseMemosProvider = StreamProvider.autoDispose((_) {
  return FirebaseFirestore.instance.collection('memos').snapshots();
});

Firebaseを操作するためのメソッドが書かれたクラス

StateNotifierを使って、どこからでもFireStoreを操作できるメソッドを定義してクラスを作成。
今のFlutter開発ってこれがマストなんですかね?

2023/01/04に、コードを一部修正

utils/app_state.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'firebase_provider.dart';
// 外部からStateNotifierを呼び出せるようになるProvider
final appStateProvider = StateNotifierProvider<AppState, dynamic>((ref) {
  // Riverpod2.0はここの引数にrefを書かなければエラーになる!
  return AppState(ref);
});

class AppState extends StateNotifier<dynamic> {
  // Refを使うと他のファイルのProviderを呼び出せる
  final Ref _ref;
  // superは、親クラスのコンストクラスターを呼び出す
  AppState(this._ref) : super([]);
  // FireStoreにデータを追加するメソッド
  Future<void> textAdd(String text) async {
    // _ref.read()と書いて、firebaseProviderを呼び出す
    final ref = await _ref.read(firebaseProvider).collection('memos')
        // createdAtは、FireStoreに作成した時刻をTimestampで保存する
        .add({'text': text, 'createdAt': Timestamp.fromDate(DateTime.now())});
  }
  // FireStoreのデータを編集するメソッド
  //_refに変更.
  Future<void> textUpdate(dynamic document, String text) async {
    await _ref.read(firebaseProvider)
        .collection('memos')
        .doc(document.id)
        .update({'text': text});
  }
  // FireStoreのデータを削除するメソッド
  //_refに変更.
  Future<void> deleteMemo(dynamic document) async {
    await _ref.read(firebaseProvider)
        .collection('memos')
        .doc(document.id)
        .delete();
  }
}

アプリの画面

今回は、簡単にCRUDしてるだけなので、3ページしかないです。
最初はHome画面が表示されます。
データを追加のボタンを押すと新規登録のページへ移動して、入力フォームからデータをFireStoreに保存します。
データを表示のボタンを押すとFireStoreに保存したデータを確認することができます。
こちらのページで、ペンのiconを押すと編集ができて、ゴミ箱のiconを押すと削除ができます。

スクリーンショット
Home画面

追加画面

表示画面

編集Modal

編集後

削除の実行

FireStoreに保存されているデータの内容

Home画面のコード
画面遷移するボタンを配置

ui/pages/home_page.dart
import 'package:crud_app/ui/pages/add_page.dart';
import 'package:crud_app/extensions/extension.dart';
import 'package:crud_app/ui/pages/read_page.dart';
import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('HOME'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
                onPressed: () {
                  context.to(const ReadPage());
                },
                child: Text('データを表示')),
            SizedBox(height: 20),
            ElevatedButton(
                onPressed: () {
                  context.to(const AddPage());
                },
                child: Text('データを追加')),
          ],
        ),
      ),
    );
  }
}

追加画面のコード
入力フォームを配置
TextEditingControllerは、RiverpodのProviderで外部から呼び出して使ってます。
以前はhooks_riverpodとflutter_hooksを組み合わせて、useTextEditingController使ってたんですけど、hooks使わないですよと、友達が言ってたので最近抵抗が出てきて、個人開発でしか使わないですね。

ui/pages/add_page.dart
import 'package:crud_app/utils/app_state.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../utils/firebase_provider.dart';

class AddPage extends ConsumerWidget {
  const AddPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    // textProviderを呼び出す定数を定義
    final controllerProvider = ref.watch(textProvider);

    return Scaffold(
      appBar: AppBar(
        title: Text('Add'),
      ),
      body: Container(
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              TextFormField(
                controller: controllerProvider, //providerに定義したコントローラーを使う
                decoration: const InputDecoration(
                  border: UnderlineInputBorder(),
                  labelText: '文字を入力してください',
                ),
              ),
              SizedBox(height: 20),
              ElevatedButton(
                  onPressed: () async {
                    // ref.readでStateNotifierを呼び出す.
                    // controllerProvider.textと書いて
                    ref
                        .read(appStateProvider.notifier)
                        .textAdd(controllerProvider.text);
                  },
                  child: Text('新規登録')),
            ],
          ),
        ),
      ),
    );
  }
}

表示・編集・追加画面のコード
編集と削除の操作は、同じページで行えばわりと簡単に設定できます。
編集画面は、FlutterのWidgetを使って、Modalで作成しました。
https://api.flutter.dev/flutter/material/showModalBottomSheet.html

ui/pages/read_page.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:crud_app/utils/app_state.dart';
import 'package:crud_app/utils/firebase_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ReadPage extends ConsumerWidget {
  const ReadPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, WidgetRef ref) {
    final AsyncValue<QuerySnapshot> firebaseMemos =
        ref.watch(firebaseMemosProvider);
    // textProviderを呼び出す定数を定義
    final controllerProvider = ref.watch(textProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Read'),
      ),
      body: SafeArea(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            SizedBox(height: 20),
            Text('FireStoreのデータを全件取得'),
            SizedBox(height: 20),
            firebaseMemos.when(
              // データがあった(データはqueryの中にある)
              data: (QuerySnapshot query) {
                // post内のドキュメントをリストで表示する
                return Expanded(
                  child: ListView(
                    // post内のドキュメント1件ずつをCard枠を付けたListTileのListとしてListViewのchildrenとする
                    children: query.docs.map((document) {
                      return Card(
                        child: ListTile(
                          // postで送った内容を表示する
                          title: Text(document['text']),
                          // CardWidgetにボタンを2個配置できるように、
                          // SizedBoxとRowでWrapする
                          trailing: SizedBox(
                            width: 100,
                            child: Row(
                              children: [
                                IconButton(
                                  onPressed: () async {
				  // 編集用Modal
                                    showModalBottomSheet<void>(
                                      context: context,
                                      builder: (BuildContext context) {
                                        return SizedBox(
                                          height: 400,
                                          child: Center(
                                            child: Column(
                                              mainAxisAlignment:
                                                  MainAxisAlignment.center,
                                              mainAxisSize: MainAxisSize.min,
                                              children: <Widget>[
                                                const Text('Modal BottomSheet'),
                                                TextFormField(
                                                  controller:
                                                      controllerProvider, //providerに定義したコントローラーを使う
                                                  decoration:
                                                      const InputDecoration(
                                                    border:
                                                        UnderlineInputBorder(),
                                                    labelText: '文字を入力してください',
                                                  ),
                                                ),
                                                SizedBox(height: 20),
                                                ElevatedButton(
                                                    onPressed: () async {
                                                      ref
                                                          .read(appStateProvider
                                                              .notifier)
                                                          .textUpdate(
                                                              document,
                                                              controllerProvider
                                                                  .text);
                                                    },
                                                    child: Text('編集')),
                                                SizedBox(height: 20),
                                                ElevatedButton(
                                                  child: const Text('閉じる'),
                                                  onPressed: () =>
                                                      Navigator.pop(context),
                                                ),
                                              ],
                                            ),
                                          ),
                                        );
                                      },
                                    );
                                  },
                                  icon: const Icon(Icons.edit),
                                ),
                                IconButton(
                                  onPressed: () async {
                                    ref
                                        .read(appStateProvider.notifier)
                                        .deleteMemo(document);
                                  },
                                  icon: const Icon(Icons.delete),
                                ),
                              ],
                            ),
                          ),
                        ),
                      );
                    }).toList(),
                  ),
                );
              },

              // データの読み込み中(FireStoreではあまり発生しない)
              loading: () {
                return const Text('Loading');
              },

              // エラー(例外発生)時
              error: (e, stackTrace) {
                return Text('error: $e');
              },
            )
          ],
        ),
      ),
    );
  }
}

最後に

以前は、RivepodでCRUDやってみると言いながら、数ヶ月も放置してたので、有給もらった日に朝からコード書いて作ってみました!
Flutter使ってみて思うんですけど、開発体験が良くて楽しいですね!
Swiftのstory boardでお絵描きするのは、嫌いでした😅
今度はもっとすごいものを作りたい。

Discussion