Open4

Firestoreでフォルダとファイルを作る

JboyHashimotoJboyHashimoto

Taskで考えてみた

Firestoreで特定のフォルダごとにデータを管理しているようなデータ設計をしたい。
イメージは、別々のフォルダがありそのの中にファイルが複数ある。

Firestoreの設計:

Firestore
|
└─── tasks (コレクション)
     |
     └─── task1 (ドキュメント)
     |    |
     |    ├─── id (フィールド)
     |    ├─── title (フィールド)
     |    └─── contents (サブコレクション)
     |         |
     |         └─── content1 (ドキュメント)
     |         └─── content2 (ドキュメント)
     |         └─── content3 (ドキュメント)
     |
     └─── task2 (ドキュメント)
          |
          ├─── id (フィールド)
          ├─── title (フィールド)
          └─── contents (サブコレクション)
               |
               └─── content1 (ドキュメント)
               └─── content2 (ドキュメント)
               └─── content3 (ドキュメント)



データのモデルはこんな感じか?

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

import 'package:todo_fire/core/timestamp_converter.dart';

part 'task_state.freezed.dart';
part 'task_state.g.dart';


class TaskState with _$TaskState {
  const factory TaskState({
    ('') String id,// 保存したdocuentIdを指定する
    ('') String title,
    ([]) List<TaskStateContent> contents,
  }) = _TaskState;

  factory TaskState.fromJson(Map<String, dynamic> json) => _$TaskStateFromJson(json);
}


class TaskStateContent with _$TaskStateContent {
  const factory TaskStateContent({
    ('') String id,
    ('') String content,
    () DateTime? createdAt,
  }) = _TaskStateContent;

  factory TaskStateContent.fromJson(Map<String, dynamic> json) => _$TaskStateContentFromJson(json);
}

List型は使ってなかった💦

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter/foundation.dart';

import 'package:todo_fire/core/timestamp_converter.dart';

part 'task_state.freezed.dart';
part 'task_state.g.dart';


class TaskState with _$TaskState {
  const factory TaskState({
    ('') String id,// 保存したdocuentIdを指定する
    ('') String title,
  }) = _TaskState;

  factory TaskState.fromJson(Map<String, dynamic> json) => _$TaskStateFromJson(json);
}


class TaskStateContent with _$TaskStateContent {
  const factory TaskStateContent({
    ('') String id,
    ('') String content,
    () DateTime? createdAt,
  }) = _TaskStateContent;

  factory TaskStateContent.fromJson(Map<String, dynamic> json) => _$TaskStateContentFromJson(json);
}
JboyHashimotoJboyHashimoto

どのようなプロバイダーを作成する???

フォルダを作るのは簡単だろう。特定するためのidは保存しておいた方が良い。問題は、📁フォルダ1, 📁フォルダ2を作ったときだ。
📁フォルダ1, に情報が📄ファイル1, 📄ファイル2って感じで入ってる。ファイルにもidは必要だろうと思う。

Providerでどのフォルダか特定するには、次のページへ画面遷移したときに、.familyでdocumentIdを渡す必要があった。

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:todo_fire/model/task_state.dart';

final firebaseProvider =
    Provider<FirebaseFirestore>((ref) => FirebaseFirestore.instance);

// タスク用のwithConverterを作成
final taskConverterProvider = Provider((ref) {
  final firebase = ref.watch(firebaseProvider);
  return firebase.collection('tasks').withConverter<TaskState>(
        fromFirestore: (snapshot, _) {
          final data = snapshot.data()!;
          data['id'] = snapshot.id;
          return TaskState.fromJson(data).copyWith(id: snapshot.id);
        },
        toFirestore: (value, _) => value.toJson(),
      );
});

final taskContentConverterProvider = Provider.family((ref, String taskId) {
  final firebase = ref.watch(firebaseProvider);
  return firebase.collection('tasks').doc(taskId).collection('content').withConverter<TaskStateContent>(
        fromFirestore: (snapshot, _) {
          final data = snapshot.data()!;
          data['id'] = snapshot.id;
          return TaskStateContent.fromJson(data);
        },
        toFirestore: (value, _) => value.toJson(),
      );
});

// タスク用のStreamProviderを作成
final taskStreamProvider = StreamProvider.autoDispose<List<TaskState>>((ref) {
  final todoConverter = ref.watch(taskConverterProvider);
  return todoConverter
      .snapshots()
      .map((e) => e.docs.map((e) => e.data()).toList());
});

// サブコレクションのcontent用のwithConverterを作成
final contentConverterProvider = Provider.family((ref, String taskId) {
  final firebase = ref.watch(firebaseProvider);
  return firebase.collection('tasks').doc(taskId).collection('contents').withConverter<TaskStateContent>(
        fromFirestore: (snapshot, _) => TaskStateContent.fromJson(snapshot.data()!),
        toFirestore: (value, _) => value.toJson(),
      );
});

// サブコレクションのcontent用のStreamProviderを作成
final contentStreamProvider = StreamProvider.autoDispose.family<List<TaskStateContent>, String>((ref, String taskId) {
  final contentConverter = ref.watch(contentConverterProvider(taskId));
  return contentConverter
      .snapshots()
      .map((e) => e.docs.map((e) => e.data()).toList());
});

📁フォルダが2個ある:

task1の場合はサブコレクションにデータがあるので、ファイルが2個表示されるイメージ

task2は、サブコレクションにデータがないので、何も表示されない。フォルダごとにデータを分けることができている。

JboyHashimotoJboyHashimoto

UIのコード

View側のコードを書いてなかった💦

フォルダのページ:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo_fire/provider/firebase_provider.dart';
import 'package:todo_fire/view/conten_view.dart';

class TaskView extends ConsumerWidget {
  const TaskView({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final taskState = ref.watch(taskStreamProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task'),
      ),
      body: taskState.when(
        data: (tasks) {
          return ListView.builder(
            itemCount: tasks.length,
            itemBuilder: (context, index) {
              final task = tasks[index];
              return ListTile(
                // 画面左にフォルダのアイコンを表示
                leading: const Icon(Icons.folder),
                title: Text(task.title),
                onTap: () {
                  Navigator.of(context).push(
                    MaterialPageRoute(
                      builder: (context) => ContentView(task: task),
                    ),
                  );
                },
              );
            },
          );
        },
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, s) => Center(child: Text(e.toString())),
      ),
    );
  }
}

ファイルのページ:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo_fire/model/task_state.dart';
import 'package:todo_fire/provider/firebase_provider.dart';

class ContentView extends ConsumerWidget {
  const ContentView({super.key, required this.task});

  final TaskState task;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final content = ref.watch(contentStreamProvider(task.id));
    return Scaffold(
      appBar: AppBar(
        title: Text(task.title),
      ),
      body: content.when(
        data: (contents) {
          return ListView.builder(
            itemCount: contents.length,
            itemBuilder: (context, index) {
              final content = contents[index];
              return ListTile(
                // 左にファイルのiconを表示
                leading: const Icon(Icons.file_copy),
                title: Text(content.content),
              );
            },
          );
        },
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, s) => Center(child: Text(e.toString())),
      ),
    );
  }
}
JboyHashimotoJboyHashimoto

フォルダを作るコード

メソッドじゃないから、かっこよくないがメモ用ならこれでいいか。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo_fire/model/task_state.dart';
import 'package:todo_fire/provider/firebase_provider.dart';

class CreateTaskView extends ConsumerWidget {
  const CreateTaskView({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final titleController = TextEditingController();
    return Scaffold(
      appBar: AppBar(
        title: const Text('Create Task'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          children: [
            TextField(
              controller: titleController,
              decoration: const InputDecoration(
                labelText: 'Title',
                hintText: 'Input title',
              ),
            ),
            const SizedBox(height: 8),
            ElevatedButton(
                onPressed: () async {
                  // doumentIDを取得する
                  final id = ref.read(taskConverterProvider).doc().id;
                  final task = TaskState(
                    id: id,
                    title: titleController.text,
                  );
                  // taskを作成する
                  await ref.read(taskConverterProvider).add(task);
                  if (context.mounted) {
                    Navigator.of(context).pop();
                  }
                },
                child: const Text('task作成')),
          ],
        ),
      ),
    );
  }
}

フォルダにファイルを作る

指定したフォルダの中にファイルを作るコード。ここは詳細ページですね。

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo_fire/model/task_state.dart';
import 'package:todo_fire/provider/firebase_provider.dart';

class ContentView extends ConsumerWidget {
  const ContentView({super.key, required this.task});

  final TaskState task;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final content = ref.watch(contentStreamProvider(task.id));
    final contentController = TextEditingController();

    return Scaffold(
      appBar: AppBar(
        title: Text(task.title),
      ),
      body: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: TextField(
              controller: contentController,
              decoration: const InputDecoration(
                labelText: 'Content',
                hintText: 'Input content',
              ),
            ),
          ),
          Expanded(
            child: content.when(
              data: (contents) {
                return ListView.builder(
                  itemCount: contents.length,
                  itemBuilder: (context, index) {
                    final content = contents[index];
                    return ListTile(
                      // 左にファイルのiconを表示
                      leading: const Icon(Icons.file_copy),
                      title: Text(content.content),
                    );
                  },
                );
              },
              loading: () => const Center(child: CircularProgressIndicator()),
              error: (e, s) => Center(child: Text(e.toString())),
            ),
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          // doumentIDを取得する
          final id = ref.read(contentConverterProvider(task.id)).doc().id;
          final content = TaskStateContent(
            id: id,
            content: contentController.text,
            createdAt: Timestamp.now().toDate(),
          );
          // contentを作成する
          ref.read(contentConverterProvider(task.id)).add(content);
        },
        child: const Icon(Icons.add),
      ),
    );
  }
}