Flutter & Firebaseでリングフィットアドベンチャーの記録アプリを作ってみた
はじめに
はじめまして。今年度入社したハルトマンといいます。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型に変換するユーティリティも実装しています。
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()
でアクセスし、fromJson
でFitnessData
に整形します。データを更新するときに必要なので、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というパッケージを使います。
利用できるグラフがいろいろありますが、今回は棒グラフで表示することにします。
そのためには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()
を使い管理します。
ドロップダウンで項目が選択されたときは、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日ずつデータが存在するかをチェックします。
データがある場合はドロップダウンで選択した種別に応じて値をセットし、リストに追加していきます。
最後にbarGroups
でgetBarChartGroupList()
を呼び出すことで棒グラフを表示することができます(実際はBarChartDataの他パラメータもいろいろ設定しています。fl_chartは設定できる項目が多いのでここでは割愛しています)。
BarChart(
BarChartData(
barGroups: getBarChartGroupList(chartType, year, month),
),
),
おわりに
いかがだったでしょうか。
エラーハンドリングや入力のバリデーションチェックなど、まだ実装仕切れてない部分は多くあるのですが、当初の「日々の記録をわかりやすく表示する」という目標に近いものができたのではないかと感じています。
今後はデータの自動入力だったり、UI/UXを意識したデザインにしていきたいと考えています。
ここまで読んでいただきありがとうございました!
Discussion