💪

Flutter & Firebaseでリングフィットアドベンチャーの記録アプリを作ってみた

2024/12/06に公開

はじめに

はじめまして。今年度入社したハルトマンといいます。jig.jp Advent Calender 2024の6日目の記事です。

1ヶ月ほど前から運動不足解消のために任天堂が販売するリングフィットアドベンチャーで遊んでいるのですが、このゲーム、ちょっと使いづらいところがあるんですよね...。
それが画像のように日々の記録がリストで表示されることです。これだと、自分がどれだけ頑張っているかとか、直近の推移がどうなっているのか把握できません😥

そこで今回は自分が普段業務で使っているFlutterと、Firebaseを使って日々の記録をわかりやすく表示するアプリを作ってみたので紹介します。

作成したアプリ

アプリを起動してログインすると、はじめに左側の画面が表示されます。
この画面ではその日の記録を表示します。データを登録・更新したいときは右下の+ボタンをタップしてモーダルから入力します。
右側の画面は月ごとのデータを表示しています。ヘッダー下のドロップダウンで表示する内容を切り替えることができます。ヘッダー左右の矢印アイコンをタップすることで前の月、次の月のデータに切り替えられます。

実装のポイント

Firestoreにデータを保存するときは以下のようにユーザーID直下に名前を「yyyy_mm」にしたコレクションを作り、そこに日ごとのデータを保存するようにします。こうすることでデータ取得の際に、月ごとの取得がしやすくなります。

.
└── data/
    └── hoge/
        ├── 2024_04/
        │   ├── document_1 (実際は日ごとのドキュメントIDはランダムです)
        │   ├── document_2
        │   └── document_3
        └── 2024_05/
            ├── document_1
            └── document_2

プロバイダーは、管理する状態の型を実装したFitnessDataにします。こうすることでウィジェット側で使用する際に取り回しをしやすくしています。
今回実装したコードはまだ公開できていませんが、紹介しているコードを参考にして貰えば似たようなものが作れると思います。

データの保存

まず初めにFlutterのパッケージである「Freezed」と「build_runner」を使ってデータクラスを作成します。保存したいのは「活動時間」「消費カロリー」「走行距離」と、作成日時の計4つです。
DartのDateTime型はそのままFirestoreに保存することができないので、こちらを参考にTimestamp型に変換するユーティリティも実装しています。

https://gist.github.com/slightfoot/c008a8f083b46f89bef9987ab2bd34fa


class FitnessData with _$FitnessData {
  const factory FitnessData({
    () DateTime? createdAt,
    required String time,
    required double calorie,
    required double running,
  }) = _FitnessData;

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

次にモーダルの登録ボタンがタップされたタイミングで、以下の処理を実行してデータを保存します。

return ElevatedButton(
      onPressed: () async {
        final db = FirebaseFirestore.instance; // Firestoreのインスタンスを初期化
        final auth = FirebaseAuth.instance; // Firebase Authのインスタンスを初期化
        final userId = auth.currentUser?.uid; // 現在ログインしているユーザーのIDを取得
        final now = DateTime.now();
        final subCollectionName = '${now.year}_${now.month}'; // ユーザー直下のコレクション名
        final newData = FitnessData( // 保存するデータを新たに作成
          createdAt: now,
          time: '${hour.padLeft(2, '0')}:${minutes.padLeft(2, '0')}:${seconds.padLeft(2, '0')}',
          calorie: double.parse(carories),
          running: double.parse(distance),
        );

        // Firestoreに保存
        await db.collection('data').doc(userId ).collection(subCollectionName).add(newData.toJson());
	
        // プロバイダーをリフレッシュしてUIを更新 
        ref.invalidate(dashboardDataProvider);

        if (context.mounted) {
          Navigator.of(context).pop();
        }
      },
      child: const Text('登録する'),
    );

作成日時はDateTime.nowでボタンがタップされた時刻をセットします。
活動時間はHH:MM:SS形式の文字列をセットします。1文字だけ入力されたときのことを想定して、念の為padLeft()で0埋めを行っています。
そのほかの値はDouble型に変換してセットしています。

最後にadd(newData.toJson())を呼ぶことでFirestoreにデータを保存しています。

データの取得

データ取得では状態管理パッケージであるRiverpodを使用します。当日分と月毎の分でそれぞれプロバイダーを分けて実装しています。

当日分のデータを取得するプロバイダー

typedef Document = ({String documentId, String subCollectionName, FitnessData fitnessData});


class DashboardData extends _$DashboardData {
  
  FutureOr<Document> build() async {
    final db = FirebaseFirestore.instance;
    final auth = FirebaseAuth.instance;
    final uid = auth.currentUser?.uid;
    final now = DateTime.now();
    final startDate = DateTime(now.year, now.month, now.day, 0, 0);
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(
      () async {
        final snapshots = await db
            .collection('data')
            .doc(uid)
            .collection('${now.year}_${now.month}')
            .where('createdAt', isGreaterThanOrEqualTo: Timestamp.fromDate(startDate))
            .get();
        return (
          documentId: snapshots.docs.first.id,
          subCollectionName: '${now.year}_${now.month}',
          fitnessData: FitnessData.fromJson(snapshots.docs.first.data())
        );
      },
    );
    return state.maybeWhen(
      data: (data) => data,
      orElse: () => throw Exception(),
    );
  }
}

where()を使って作成日時のフィールドを対象に、当日の日付(startDate)を含むそれよりも後のデータを抽出します。snapshots.docsが抽出されたドキュメントのリストになります。ここで関心があるのは当日のデータだけなので、first.data()でアクセスし、fromJsonFitnessDataに整形します。データを更新するときに必要なので、typedefを使って一つの型にまとめて管理するようにしています。

月ごとのデータを取得するプロバイダー


class ChartData extends _$ChartData {
  
  FutureOr<List<FitnessData>> build(String arg) async {
    final db = FirebaseFirestore.instance;
    final auth = FirebaseAuth.instance;
    final uid = auth.currentUser?.uid;
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() async {
      final snapshots = await db.collection('data').doc(uid).collection(arg).get();
      return snapshots.docs.map((e) => FitnessData.fromJson(e.data())).toList();
    });
    return state.maybeWhen(
      data: (data) => data,
      orElse: () => throw Exception(),
    );
  }

月ごとのプロバイダーではパラメータとして渡されたコレクション名を使い、get()を実行することで1月分のデータを取得します。

データの修正

一日の中で間を開けてゲームをプレイしたり、うっかり間違った情報を登録してしまったときのために、データを修正できるようにします。この更新ボタンはモーダル内で当日分のデータを取得済みのときに表示するようにしています。

final state = ref.watch(dashboardDataProvider);
return ElevatedButton(
      onPressed: () async {
        if (!state.hasValue) {
          return;
        }

        final db = FirebaseFirestore.instance;
        final auth = FirebaseAuth.instance;
        final uid = auth.currentUser?.uid;
        final subCollectionName = state.requireValue.subCollectionName;
        final documentId = state.requireValue.documentId;
        final newData = FitnessData(
          createdAt: DateTime.now(),
          time: '${hour.padLeft(2, '0')}:${minutes.padLeft(2, '0')}:${seconds.padLeft(2, '0')}',
          calorie: double.parse(carories),
          running: double.parse(distance),
        );

        await db.collection('data').doc(uid).collection(subCollectionName).doc(documentId).update(newData.toJson());
	// プロバイダーをリフレッシュしてUIを更新 
        ref.invalidate(dashboardDataProvider);

        if (context.mounted) {
          Navigator.of(context).pop();
        }
      },
      child: const Text('更新する'),
    );

基本は登録時の処理と同じですが、update()を実行するためにコレクション名とログインしているユーザーのIDが必要です。ref.watchでプロバイダーを監視し、state.requireValueでアクセスできるようにします。

データの表示

当日分のデータはref.watchでプロバイダーを監視して、データの取得が完了するまでは別のウィジェットを表示するようにしています。

// プロバイダーの監視
final state = ref.watch(dashboardDataProvider);

// データの取得が完了するまで表示する
if (!state.hasValue) {
    return const DashboardNoneView();
}

// データの参照
state.requireValue.fitnessData

グラフの表示はfl_chartというパッケージを使います。

https://pub.dev/packages/fl_chart

利用できるグラフがいろいろありますが、今回は棒グラフで表示することにします。
そのためにはbarGroupsに1日ごとのBarChartGroupDataをリストにして渡す必要があります。

BarChart(
  BarChartData(
    barGroups: <BarChartGroupData>[], // ここに渡す
  ),
),

表示するデータを切り替えるためのドロップダウンを実装します。

enum ChartType {
  time,
  calorie,
  running,
}

class _Dropdown extends StatelessWidget {
  const _Dropdown({
    required this.onSelected,
  });

  final Function(String? value) onSelected;

  
  Widget build(BuildContext context) {
    return DropdownMenu(
      onSelected: (value) => onSelected(value),
      width: MediaQuery.sizeOf(context).width * 0.5,
      initialSelection: 'time',
      dropdownMenuEntries: const [
        DropdownMenuEntry(value: 'time', label: '活動時間'),
        DropdownMenuEntry(value: 'calorie', label: '消費カロリー'),
        DropdownMenuEntry(value: 'running', label: '走行距離')
      ],
    );
  }
}

今どれが選択されているのかは親ウィジェットの方でflutter_hooksのuseState()を使い管理します。

https://pub.dev/packages/flutter_hooks

ドロップダウンで項目が選択されたときは、onSelectedを呼び出して親ウィジェット側で選択項目の更新を行います。

次にBarChartGroupDataのリストを作成する関数を実装します。

List<BarChartGroupData> getBarChartGroupList(ChartType chartType, int year, int month) {
  var result = <BarChartGroupData>[];
  final now = DateTime(year, month, 1);
  final next = DateTime(year, month + 1, 1);
  final dayCount = next.difference(now).inDays; // 1ヶ月の日数を計算

  for (int i = 1; i <= dayCount; i++) {
    var toY = 0.0;
    for (final e in data) {
      if (e.createdAt != null && i == e.createdAt!.day) {
        switch (chartType) {
          case ChartType.time:
            toY = double.parse((convertToSeconds(e.time) / 3600).toStringAsFixed(2)); // HH:MM:SS形式の文字列からDouble型の秒数に変換
          case ChartType.calorie:
            toY = e.calorie;
          case ChartType.running:
            toY = e.running;
          default:
            break;
        }
      }
    }
    result.add(
      BarChartGroupData(
        x: i,
        barRods: [
          BarChartRodData(
            toY: toY,
            width: 30,
            borderRadius: BorderRadius.circular(4),
            color: Colors.orange.shade600,
          ),
        ],
      ),
    );
  }
  return result;
}

はじめに一か月の日数を計算した後に、1日ずつデータが存在するかをチェックします。
データがある場合はドロップダウンで選択した種別に応じて値をセットし、リストに追加していきます。

最後にbarGroupsgetBarChartGroupList()を呼び出すことで棒グラフを表示することができます(実際はBarChartDataの他パラメータもいろいろ設定しています。fl_chartは設定できる項目が多いのでここでは割愛しています)。

BarChart(
  BarChartData(
    barGroups: getBarChartGroupList(chartType, year, month),
  ),
),

おわりに

いかがだったでしょうか。

エラーハンドリングや入力のバリデーションチェックなど、まだ実装仕切れてない部分は多くあるのですが、当初の「日々の記録をわかりやすく表示する」という目標に近いものができたのではないかと感じています。
今後はデータの自動入力だったり、UI/UXを意識したデザインにしていきたいと考えています。

ここまで読んでいただきありがとうございました!

jig.jp Engineers' Blog

Discussion