【Flutter Dart】Firebase FireStoreでデータを管理しよう!HooksとRiverpodとfreezedを使う!

Firestoreを使ってみよう!
  • URLをコピーしました!

今回は、FlutterとFirebase Firestoreを使ってデータを管理していこうと思います。

前回の「【Flutter Dart】Firebase Authenticationを使って匿名認証を実装!」の続きなので、一応関係ないように作っていきますが、もし可能であれば先に見てもらえるとすんなり入れると思います!

クマじい

データ管理できるんじゃな!!

学習に使った本はこちら!

クマじい

Amazon Kindle Unlimited でもFlutter、Dartの本はいっぱいあるぞ!

  • 技術書をたくさん読みたい!
  • 色々な技術を学びたい!
初回30日間無料体験があるので、どのような参考書があるか確認してみよう!

Kindle Unlimitedで技術書を読むならタブレットがあるとさらに便利ですよ!
10インチあるとより見やすいと思いますが、7インチでも十分見やすくなります!

目次

Firebase Firestoreの設定をする!

Firebaseの管理画面でFirestoreの設定をしていきましょう!

Firestoreの設定

  • Firebaseの管理画面を開く
  • Firestore Databaseを押下
  • コレクションにlistsを追加

Firebase Firestoreを使っていく!

では、実装していきましょう!

providersの作成

  • lib>general_providers.dartを作成
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';

// firebaseAuthのインスタンスを提供
final firebaseAuthProvider = Provider<FirebaseAuth>((ref) => FirebaseAuth.instance);

// firebaseStoreのインスタンスの提供
final firebaseFirestoreProvider = Provider<FirebaseFirestore>((ref) => FirebaseFirestore.instance);

modelの作成

  • lib>modelsを作成
  • models>item_model.dartを作成
  • item_model.dartを実装(詳細はコードを確認)
  • コマンドを実行(コード生成)
    flutter packages pub run build_runner watch –delete-conflicting-outputs
  • 【参考】Freezed:immutableなクラスを自動生成する
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'item_model.freezed.dart';
part 'item_model.g.dart';

@freezed
abstract class Item implements _$Item {
  const Item._();

  const factory Item({
    String? id,
    required String name,
    @Default(false) bool obtained,
  }) = _Item;

  factory Item.empty() => Item(name: '');

  factory Item.fromJson(Map<String, dynamic> json) => _$ItemFromJson(json);

  // ドキュメントのスナップショットを変換するために利用
  factory Item.fromDocument(DocumentSnapshot<Map<String, dynamic>> doc) {
    final data = doc.data()!;
    // doc.idがitemIDのため、copyWithでIDをモデルにコピーする
    return Item.fromJson(data).copyWith(id: doc.id);
  }

  // アイテムモデルをMap<String, dynamic>に変換するメソッド
  Map<String, dynamic> toDocument() => toJson()..remove('id');
}
  • item_model.freezed.dartとitem_model.g.dartが作成されていることを確認

extensionを作成

modelで共通的に使う部分をextensionで切り出しています。

  • lib>extensionsを作成
  • extensions>firebase_firestore_extension.dartを作成
  • firebase_firestore_extension.dartを実装(詳細はコードを確認)
  • 【補足】collection:コレクションIDを指定(今回で言うと先ほど作ったlistsが該当)
  • 【補足】doc:ドキュメントをして(今回はユーザIDをドキュメントにする)
  • 【参考】今回はドキュメントにさらにユーザごとのコレクションを追加している
import 'package:cloud_firestore/cloud_firestore.dart';

// ユーザIDを引数にして、CollectionReferenceを返却
extension FirebaseFirestoreX on FirebaseFirestore {
  CollectionReference<Map<String, dynamic>> userListRef(String userId) =>
      collection('lists').doc(userId).collection('userList');
}

repositoriesの作成

  • repositories>item_repository.dartを作成
  • item_repository.dartを実装(詳細はコードのコメントを参照)
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:shopping_list/extensions/firebase_firestore_extension.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shopping_list/general_providers.dart';
import 'package:shopping_list/models/item_model.dart';
import 'package:shopping_list/repositories/custom_exception.dart';

abstract class BaseItemRepository {
  // アイテムのリストを返す
  Future<List<Item>> retrieveItems({required String userId});
  // アイテムを保存して、作成されたアイテムIDを返す
  Future<String> createItem({required String userId, required Item item});
  // アイテムを更新する
  Future<void> updateItem({required String userId, required Item item});
  // アイテムを削除する
  Future<void> deleteItem({required String userId, required String itemId});
}

final itemRepositoryProvider = Provider<ItemRepository>((ref) => ItemRepository(ref.read));

class ItemRepository implements BaseItemRepository {
  final Reader _read;

  const ItemRepository(this._read);

  @override
  Future<List<Item>> retrieveItems({required String userId}) async {
    try {
      // UserIdに紐づく、アイテムを取得
      final snap = await _read(firebaseFirestoreProvider)
          .userListRef(userId)
          .get();
      return snap.docs.map((doc) => Item.fromDocument(doc)).toList();
    } on FirebaseException catch (e) {
      throw CustomException(message: e.message);
    }
  }

  @override
  Future<String> createItem({required String userId, required Item item}) async {
    try {
      // UserIdに紐付けてアイテムを登録
      final docRef = await _read(firebaseFirestoreProvider)
          .userListRef(userId)
          .add(item.toDocument());
      return docRef.id;
    } on FirebaseException catch (e) {
      throw CustomException(message: e.message);
    }
  }
  @override
  Future<void> updateItem({required String userId, required Item item}) async {
    try {
      // UserId>ItemIdに紐づくアイテムを更新
      await _read(firebaseFirestoreProvider)
          .userListRef(userId)
          .doc(item.id)
          .update(item.toDocument());
    } on FirebaseException catch (e) {
      throw CustomException(message: e.message);
    }
  }

  @override
  Future<void> deleteItem({required String userId, required String itemId}) async {
    try {
      // UserId>ItemIdに紐づくアイテムを削除
      await _read(firebaseFirestoreProvider)
          .userListRef(userId)
          .doc(itemId)
          .delete();
    } on FirebaseException catch (e) {
      throw CustomException(message: e.message);
    }
  }
}

controllerの作成

  • controllers>item_list_controller.dartを作成
  • item_list_controller.dartを実装
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shopping_list/controllers/auth_controller.dart';
import 'package:shopping_list/models/item_model.dart';
import 'package:shopping_list/repositories/custom_exception.dart';
import 'package:shopping_list/repositories/item_repository.dart';


// チェックがついているものだけ絞り込む
enum ItemListFilter {
  all,
  obtained
}

final itemListFilterProvider = StateProvider<ItemListFilter>((_) => ItemListFilter.all);

final filteredItemListProvider = Provider<List<Item>>((ref) {
  final itemListFilterState = ref.watch(itemListFilterProvider).state;
  final itemListState = ref.watch(itemListControllerProvider);
  return itemListState.maybeWhen(
      data: (items) {
        switch (itemListFilterState) {
          case ItemListFilter.obtained: return items.where((item) => item.obtained).toList();
          default: return items;
        }
      },
    orElse: () => [],
  );
});


final itemListExceptionProvider = StateProvider<CustomException?>((_) => null);

final itemListControllerProvider = StateNotifierProvider<ItemListController, AsyncValue<List<Item>>>(
    (ref) {
      final user = ref.watch(authControllerProvider);
      return ItemListController(ref.read, user?.uid);
    }
);

// 非同期でラップ
class ItemListController extends StateNotifier<AsyncValue<List<Item>>> {
  final Reader _read;
  final String? _userId;

  // Readerとnull許可ユーザIDを受け取る
  ItemListController(this._read, this._userId) : super(AsyncValue.loading()) {
    // ユーザIDがNULLでない場合、アイテムの取得をする
    if (_userId != null) {
      retrieveItems();
    }
  }

  // アイテムの取得
  Future<void> retrieveItems({bool isRefreshing = false}) async {
    if (isRefreshing) state = AsyncValue.loading();
    try {
      final items = await _read(itemRepositoryProvider).retrieveItems(userId: _userId!);
      if (mounted) {
        state = AsyncValue.data(items);
      }
    } on CustomException catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
  
  // アイテムの追加
  Future<void> addItem({required String name, bool obtained = false}) async {
    try {
      final item = Item(name: name, obtained: obtained);
      final itemId = await _read(itemRepositoryProvider).createItem(
          userId: _userId!,
          item: item,
      );
      state.whenData((items) => state = AsyncValue.data(items..add(item.copyWith(id: itemId))));
    } on CustomException catch (e) {
      _read(itemListExceptionProvider).state = e;
    }
  }
  
  // アイテムの更新
  Future<void> updateItem({required Item updatedItem}) async {
    try {
      await _read(itemRepositoryProvider).updateItem(userId: _userId!, item: updatedItem);
      state.whenData((items) {
        state = AsyncValue.data([
          for (final item in items)
            if (item.id == updatedItem.id) updatedItem else item
        ]);
      });
    } on CustomException catch (e) {
      _read(itemListExceptionProvider).state = e;
    }
  }
  
  // アイテムの削除
  Future<void> deleteItem({required String itemId}) async {
    try {
      await _read(itemRepositoryProvider).deleteItem(
          userId: _userId!,
          itemId: itemId,
      );
      state.whenData((items) => state = AsyncValue.data(items..removeWhere((item) => item.id == itemId)));
    } on CustomException catch (e) {
      _read(itemListExceptionProvider).state = e;
    }
  }
}

UIを変更

  • main.dartを修正(modulesなどに適宜切り分けてください。)
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shopping_list/controllers/auth_controller.dart';
import 'package:shopping_list/controllers/item_list_controller.dart';
import 'package:shopping_list/models/item_model.dart';
import 'package:shopping_list/repositories/custom_exception.dart';

// main()を非同期にする
void main() async {
  // アプリ起動時に処理したいので追記
  WidgetsFlutterBinding.ensureInitialized();
  // Firebaseの初期化
  await Firebase.initializeApp();
  // MyApp()をProviderScopeでラップして、アプリ内のどこからでも全てのプロバイダーにアクセスできるようにする。
  runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Teech Lab.',
      theme: ThemeData(
        primarySwatch: Colors.brown,
      ),
      home: HomeScreen(),
    );
  }
}

class HomeScreen extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final authControllerState = useProvider(authControllerProvider);
    final itemListFilter = useProvider(itemListFilterProvider);
    final isObtainedFilter = itemListFilter.state == ItemListFilter.obtained;
    return Scaffold(
      appBar: AppBar(
        title: Text('Shopping List'),
        leading: authControllerState != null
            ? IconButton(
                onPressed: () => context.read(authControllerProvider.notifier).signOut(),
                icon: Icon(Icons.logout)
              )
            : null,
        actions: [
          // チェックしたアイテムの絞り込み
          IconButton(
              onPressed: () => itemListFilter.state = isObtainedFilter ? ItemListFilter.all : ItemListFilter.obtained,
              icon: Icon(
                  isObtainedFilter
                    ? Icons.check_circle : Icons.check_circle_outline,
              )
          )
        ],
      ),
      body: ProviderListener(
        provider: itemListExceptionProvider,
        onChange: (
            BuildContext context,
            StateController<CustomException?> customException
        ) {
          ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                  backgroundColor: Colors.red,
                  content: Text(customException.state!.message!),
              )
          );
        },
        // アイテムリストの表示
        child: ItemList(),
      ),
      floatingActionButton: FloatingActionButton(
        // アイテム登録ダイアログを表示
        onPressed: () => AddItemDialog.show(context, Item.empty()),
        child: Icon(Icons.add),
      ),
    );
  }
}

// アイテム登録ダイアログ
class AddItemDialog extends HookWidget {
  // 表示用
  static void show(BuildContext context, Item item) {
    showDialog (
      context: context,
      builder: (context) => AddItemDialog(item: item),
    );
  }

  final Item item;

  const AddItemDialog({Key? key, required this.item}) : super(key: key);

  bool get isUpdating => item.id != null;

  @override
  Widget build(BuildContext context) {
    final textController = useTextEditingController(text: item.name);
    return Dialog(
      child: Padding(
        padding: EdgeInsets.all(20.0),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            TextField(
              controller: textController,
              autofocus: true,
              decoration: InputDecoration(hintText: 'Item Name'),
            ),
            SizedBox(height: 12.0,),
            SizedBox(
              width: double.infinity,
              child: ElevatedButton(
                  style: ElevatedButton.styleFrom(
                      primary: isUpdating ? Colors.orange : Theme.of(context).primaryColor
                  ),
                  onPressed: () {
                    isUpdating
                        ? context.read(itemListControllerProvider.notifier).updateItem(
                            updatedItem: item.copyWith(
                              name: textController.text.trim(),
                              obtained: item.obtained,
                            ),
                          )
                        : context
                          .read(itemListControllerProvider.notifier)
                          .addItem(name: textController.text.trim());
                    Navigator.of(context).pop();
                  },
                  child: Text(isUpdating ? 'Update' : 'Add'),
              ),
            )
          ],
        ),
      ),
    );
  }
}

final currentItem = ScopedProvider<Item>((_) => throw UnimplementedError());

// アイテムリスト
class ItemList extends HookWidget {
  const ItemList ({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final itemListState = useProvider(itemListControllerProvider);
    final filteredItemList = useProvider(filteredItemListProvider);
    return itemListState.when(
        data: (items) => items.isEmpty ? Center(
          child: Text(
            'Tap + to add an item',
            style: TextStyle(fontSize: 20.0),
          ),
        )
        : ListView.builder(
            itemCount: filteredItemList.length,
            itemBuilder: (BuildContext context, int index) {
              final item = filteredItemList[index];
              return ProviderScope(
                overrides: [currentItem.overrideWithValue(item)],
                // アイテムタイルの表示
                child: ItemTile(),
              );
            }),
        loading: () => Center(child: CircularProgressIndicator()),
        error: (error, _) => ItemListError(
          message: error is CustomException ? error.message! : 'Something went wrong',
        ),
    );
  }
}

// アイテムタイル
class ItemTile extends HookWidget {
  const ItemTile({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final item = useProvider(currentItem);
    return ListTile(
      key: ValueKey(item.id),
      title: Text(item.name),
      trailing: Checkbox(
        value: item.obtained,
        onChanged: (val) => context
            .read(itemListControllerProvider.notifier)
            .updateItem(updatedItem: item.copyWith(obtained: !item.obtained)),
      ),
      onTap: () => AddItemDialog.show(context, item),
      onLongPress: () => context
          .read(itemListControllerProvider.notifier)
          .deleteItem(itemId: item.id!),
    );
  }
}

class ItemListError extends StatelessWidget {
  final String message;

  const ItemListError({
    Key? key,
    required this.message,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            message,
            style: TextStyle(fontSize: 20.0),
          ),
          SizedBox(height: 20.0),
          ElevatedButton(
              onPressed: () => context
                  .read(itemListControllerProvider.notifier)
                  .retrieveItems(),
              child: Text('Retry')
          ),
        ],
      ),
    );
  }
}
クマじい

なかなか難しいのう。

動作確認をしよう!

シュミレータを起動して確認してみましょう!
今回はiOSで確認してます!

アプリを起動

  • シュミレータを起動して、デバッグ
  • まだデータはないので、リストは表示されていないと思います。

アイテムを追加

  • +ボタンを押下
  • アイテム名を入力し、Addボタンを押下
  • リストが表示されることを確認
  • アイテムがFirestoreに登録されていることを確認

アイテムを更新

  • 任意のアイテムを選択
  • アイテム名を変更し、Updateボタンを押下
  • リストの名前が変更されていることを確認
  • Firestoreのアイテムが更新されていることを確認
  • 今回は省きますがチェックをつけると、obtainedも更新されることを確認しておきましょう。

アイテムを削除

  • アイテムを長押し
  • アイテムが消えていることを確認

アイテムを登録した状態で、再起動してもアイテムが消えていないことも確認してもいいと思います!

まとめ

今回は、FlutterとFirebase Firestoreを使ってデータを管理をやっていきました。

また、今回Flutterを勉強するにあたり利用した教材をあげています。
主に本とUdemyの動画教材を利用しています。ここには私なりに理解した内容をもとに独自に内容を考えて共有しているので興味ある方は、本やUdemyを購入して勉強してみることをおすすめします。

学習に使った本はこちら!

クマじい

Amazon Kindle Unlimited でもFlutter、Dartの本はいっぱいあるぞ!

  • 技術書をたくさん読みたい!
  • 色々な技術を学びたい!
初回30日間無料体験があるので、どのような参考書があるか確認してみよう!

Kindle Unlimitedで技術書を読むならタブレットがあるとさらに便利ですよ!
10インチあるとより見やすいと思いますが、7インチでも十分見やすくなります!

Firestoreを使ってみよう!

この記事が気に入ったら
フォローしてね!

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

東京在住で20代のエンジニアです。
特に特技があるわけではありませんが、誰もが楽しくプログラミングができたらいいと思い、「teech lab.」を開設いたしました。

Enjoy Diaryという、ガジェットや雑貨を紹介しているブログもあります!
ぜひ、みてください!!

目次