💱

FirestoreでSNSぽい設計をしてみる②

2023/11/20に公開

前回作ったのが良くなかったので作り直した。

前回、Firestoreを使用して、SNSぽい設計をしたが、日付とIDを使ってなかったのと、いい感じのコードではなかったので、改良して設計を変更してみました。

https://zenn.dev/joo_hashi/articles/4c19283a63b913

こちらに完成品があります
https://github.com/sakurakotubaki/SNSLike/tree/feat-task

今回は、Timestampを使用して作成日時を保存しています。アプリ側で表示するには、DateTimeに変換する必要があります。
多言語対応パッケージのintlを使うと、年・月・時間の日本語表示ができます。
https://pub.dev/packages/intl

コードを書いてみる

Freezedは、Timestampを扱うことができないので、DateTimeに変換するコンバーターを作成する必要がある。

タイムスタンプのコンバーター
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

class TimestampConverter implements JsonConverter<DateTime?, Timestamp?> {
  const TimestampConverter();

  
  DateTime? fromJson(Timestamp? json) => json?.toDate();

  
  Timestamp? toJson(DateTime? object) =>
      object == null ? null : Timestamp.fromDate(object);
}

ユーザーデータに、idと作成時刻を追加

ユーザモデル
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

import '../common/timestamp_converter.dart';

part 'user.freezed.dart';
part 'user.g.dart';


class User with _$User {
  const factory User({
    () DateTime? createdAt,
    ('') String id,
    ('') String name,
  }) = _User;

  factory User.fromJson(Map<String, Object?> json)
      => _$UserFromJson(json);
}

タスクのモデルに、idと作成時刻を追加。UIに表示されるデータは、taskコレクションIDのidフィールドが、新しい順に表示されます。

タスクモデル
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';
import 'package:task_app/common/timestamp_converter.dart';

part 'task.freezed.dart';
part 'task.g.dart';


class Task with _$Task {
  const factory Task({
    () DateTime? createdAt,
    ('') String id,
    ('') String task,
    ([]) List<String> likes,
    ([]) List<String> comments,
  }) = _Task;

  factory Task.fromJson(Map<String, Object?> json)
      => _$TaskFromJson(json);
}

userコレクションIDにもwhere文を使って、新しい順にデータを表示してみようと思いましたが、今回は使うことがなかったので、コード書いてますが、気にしなくて良いです。

Firestoreを使うプロバイダー
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:task_app/model/user.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../model/task.dart';
part 'firebase_provider.g.dart';


FirebaseFirestore firebaseFirestore(FirebaseFirestoreRef ref) {
  return FirebaseFirestore.instance;
}


Stream<List<User>> userStream(UserStreamRef ref) {
  final firestore = ref.watch(firebaseFirestoreProvider);
  return firestore
      .collection('user')
      .orderBy('createdAt', descending: true)
      .withConverter<User?>(fromFirestore: (ds, _) {
        final data = ds.data();
        final id = ds.id;

        if (data == null) {
          return null;
        }
        data['id'] = id;
        return User.fromJson(data);
      }, toFirestore: (user, _) {
        return user?.toJson() ?? {};
      })
      .snapshots()
      .map((snapshot) =>
          snapshot.docs.map((doc) => doc.data()).whereType<User>().toList());
}


Stream<List<Task>> taskStream(TaskStreamRef ref) {
  final firestore = ref.watch(firebaseFirestoreProvider);
  return firestore
      .collection('task')
      .orderBy('createdAt', descending: true)
      .withConverter<Task?>(fromFirestore: (ds, _) {
        final data = ds.data();
        final id = ds.id;

        if (data == null) {
          return null;
        }
        data['id'] = id;
        return Task.fromJson(data);
      }, toFirestore: (user, _) {
        return user?.toJson() ?? {};
      })
      .snapshots()
      .map((snapshot) =>
          snapshot.docs.map((doc) => doc.data()).whereType<Task>().toList());
}

UIにSNSぽいデータ構造で、2つのコレクションから取得したデータをカードに表示するコードです。sort、compareToを使って、List型のデータの並び替えと、firstWhereで、条件に一致するデータを表示するようにしています。

sortするコード

// タスクを作成日が新しい順に並び替える
            allTasks.sort((a, b) => (b.createdAt ?? DateTime.now())
                .compareTo(a.createdAt ?? DateTime.now()));

firstWhereを使うコード

final user = users.firstWhere((user) => user.id == task.id);
UIの全体のコード
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:intl/intl.dart';
import 'package:task_app/provider/firebase_provider.dart';

import 'firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
  FirebaseFirestore.instance.settings = const Settings(
    persistenceEnabled: true,
  );

  await initializeDateFormatting('ja_JP', null);

  runApp(const ProviderScope(child: MyApp()));
}

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

  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const HomePage(),
    );
  }
}

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    final users = ref.watch(userStreamProvider);
    final tasks = ref.watch(taskStreamProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task App'),
      ),
      body: users.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stackTrace) => Center(child: Text(error.toString())),
        data: (users) => tasks.when(
          data: (tasks) {
            // 全てのタスクを一つのリストにまとめる
            final allTasks = tasks.toList();
            // タスクを作成日が新しい順に並び替える
            allTasks.sort((a, b) => (b.createdAt ?? DateTime.now())
                .compareTo(a.createdAt ?? DateTime.now()));
            return ListView.builder(
              itemCount: allTasks.length,
              itemBuilder: (context, index) {
                final task = allTasks[index];
                final user = users.firstWhere((user) => user.id == task.id);
                return SizedBox(
                  width: 200,
                  height: 200,
                  child: Card(
                    child: Column(
                      children: [
                        Text('タスクID: ${task.id}'),
                        Text('ユーザーID: ${user.id}'),
                        Text(
                            'task作成日: ${DateFormat('yyyy年MM月dd日HH時mm分', 'ja_JP').format(task.createdAt?.toLocal() ?? DateTime.now())}'),
                        Align(
                            alignment: Alignment.centerLeft,
                            child: Text(user.name)),
                        const SizedBox(height: 10),
                        Align(
                            alignment: Alignment.centerLeft,
                            child: Text(task.task)),
                        const SizedBox(height: 10),
                        Row(
                          children: [
                            Text('いいね ${task.likes.length.toString()}'),
                            Text('コメント ${task.comments.length.toString()}'),
                          ],
                        ),
                      ],
                    ),
                  ),
                );
              },
            );
          },
          loading: () => const Center(child: CircularProgressIndicator()),
          error: (error, stackTrace) => Center(child: Text(error.toString())),
        ),
      ),
    );
  }
}

Flutter Webで使ってみた

Firestoreにダミーのデータを用意しておいてください。今回はこんな感じです。

{
  "id": "ユーザーID",
  "name": "ユーザー名",
  "createdAt": "作成日時(ISO 8601形式の日付文字列)"
}

{
  "id": "タスクID",
  "task": "タスク内容",
  "createdAt": "作成日時(ISO 8601形式の日付文字列)",
  "likes": ["いいねしたユーザーID1", "いいねしたユーザーID2", ...],
  "comments": ["コメント1", "コメント2", ...]
}

UIにデータを表示すると、日本語表示の年・日付・時間が表示されており、taskコレクションのcreatedAtが新しい順にデータを表示しています。

最後に

Firestoreの設計を前回と変えて、時間を扱えるようにして、idも必要になるので追加し、データを取得後に、日付が新しい順に並び替えて、ユーザのタスクの作成びが新しい順にデータを取得できるようにしました。

Discussion