データの旅

ホーム画面のタップから DB・クラウド保存まで、すべての工程を省略なく図解

UI層(画面) 状態管理層(Provider) データ変換層(Repository) ローカルDB層(SQLite / drift) クラウド層(Firebase Data Connect) HomeScreen ルーティン一覧 タップで開始! startWorkout(data) WorkoutScreen 種目カード一覧 進捗バー表示 Navigator.push WorkoutExerciseScreen 重量・回数を入力 「完了」ボタン Navigator.push updateWeight / updateReps completeWorkout() ActiveWorkoutNotifier state = ActiveWorkoutState (routineName, exercises, sets, ...) state更新メソッド群 updateWeight / updateReps addSet / applyPreviousRecord completeWorkout() 内部処理 1. PR判定(推定1RM比較) 2. WorkoutSummary作成 → Repository.saveWorkout() _repository.saveWorkout(state) WorkoutRepository.saveWorkout() 1. UUID生成 workoutId = uuid.v4() exerciseId = uuid.v4() 2. ドメイン→DB変換 ActiveWorkoutState → WorkoutsCompanion + ... 3. トランザクション _db.transaction(() async { INSERT × 3テーブル }) WorkoutsCompanion id, routineName, date... WorkoutExercisesCompanion[] id, workoutId, name, sortOrder WorkoutSetsCompanion[] id, weight, reps, 1RM, isPR workouts id(PK), routineName, date, userId(FK)... workout_exercises id(PK), workoutId(FK), exerciseName... workout_sets id(PK), workoutExerciseId(FK), weight... 1:N 1:N バックグラウンド同期 (UIはブロックしない) SyncService syncUser() → syncWorkout() WorkoutlogConnector syncSession / syncSessionExercise / syncSetRecord Firebase Data Connect PostgreSQL (Google Cloud SQL) Sessions / SessionExercises / SetRecords テーブル

1 ホーム画面でルーティンをタップ

📱
HomeScreen ─ ルーティンカードのタップ
UI層 home_screen.dart : 114-124行目
お弁当屋さんで「Aセット」と注文するようなもの。メニュー(ルーティンカード)をタップすると、そのルーティンに含まれる種目が全部セットで注文される。

やっていること:

  1. routineListProvider でルーティン一覧を表示(21行目)
  2. タップ時に _onRoutineTap() が呼ばれる(100行目)
  3. ref.read(activeWorkoutProvider.notifier).startWorkout(data) を実行(119行目)
  4. Navigator.push で WorkoutScreen に画面遷移(121行目)
渡されるデータ: RoutineWithExercises
┣ routine: Routine (id, name, color)
┗ exercises: List<Exercise> (id, name, bodyPart, recordType)
// home_screen.dart 114-124行目 Future<void> _onRoutineTap( BuildContext context, WidgetRef ref, RoutineWithExercises data, ) async { // Provider に「このルーティンで開始して!」と指示 await ref.read(activeWorkoutProvider.notifier) .startWorkout(data); // 画面をワークアウト画面に切り替え Navigator.of(context).push( MaterialPageRoute(builder: (_) => const WorkoutScreen()), ); }

フリートレーニングの場合(127-141行目):

  1. showExerciseSelectSheet() で種目選択シートを表示
  2. 選んだ種目リストを startFreeWorkout(selected) に渡す
  3. 同じく WorkoutScreen に遷移

2 状態の初期化(メモリ上にワークアウトを構築)

🧠
ActiveWorkoutNotifier.startWorkout()
状態管理層 workout_provider.dart : 173-207行目
注文を受けたキッチンが「伝票」を作るようなもの。ルーティンの種目リストを見て、各種目に3セット分の空白欄(= デフォルトセット)を用意する。さらに前回の記録を引っ張り出してきて「前はこの重さだったよ」と書き添える。

処理の流れ:

  1. 基本の state をセット(178-189行目)
    ActiveWorkoutState を作り、各種目に3セットの空の SetData を用意
  2. 各種目ごとに前回記録と過去最高1RMを取得(191-206行目)
    _repository.getLastWorkoutSets(exerciseId) → 前回のセット
    _repository.getPersonalBest1RM(exerciseId) → 過去最高の推定1RM
  3. 取得した情報を exercises に反映して state を更新
state の中身(メモリ上):
ActiveWorkoutState {
  routineId: "abc-123"
  routineName: "胸の日"
  startedAt: 2026-03-28 10:00:00
  exercises: [
    ActiveExerciseData {
      exerciseId: "ex-001"
      name: "ベンチプレス"
      sets: [SetData(0,0), SetData(0,0), SetData(0,0)] ← 空
      previousSets: [SetData(80,8), SetData(80,6)] ← 前回
      personalBest1rm: 106.7 ← 過去最高
    }, ...
  ]
}
// workout_provider.dart 178-206行目 state = ActiveWorkoutState( routineId: data.routine.id, routineName: data.routine.name, startedAt: DateTime.now(), exercises: data.exercises.map((e) { return ActiveExerciseData( exerciseId: e.id, name: e.name, sets: List.generate( defaultSetCount, // = 3 (_) => const SetData(), // 空セット ), ); }).toList(), ); // 各種目の前回記録を取得 for (final ex in state!.exercises) { final lastSets = await _repository .getLastWorkoutSets(ex.exerciseId); final best1rm = await _repository .getPersonalBest1RM(ex.exerciseId); // ... exercises に反映 }
📄
データクラス(freezed による不変オブジェクト)
状態管理層 workout_provider.dart : 19-115行目
「鍵のかかった封筒」のようなもの。中のデータを直接書き換えることはできない。変更したいときは新しい封筒を作り直す(copyWith)。これにより「いつの間にか値が変わっていた」というバグを防ぐ。

3つの入れ子構造:

クラス名役割含むデータ
SetData1セット分weight, reps, isPR + computed: hasData, estimated1rm
ActiveExerciseData1種目分exerciseId, name, sets[], previousSets[], personalBest1rm
ActiveWorkoutStateワークアウト全体routineId, routineName, startedAt, exercises[], currentExerciseIndex
WorkoutSummary完了時サマリーroutineName, date, durationSeconds, totalVolume, exerciseCount, totalSets, prCount

computed プロパティ(自動計算):

// SetData の estimated1rm(Epley式) double? get estimated1rm => weight > 0 && reps > 0 ? weight * (1 + reps / 30) : null; // 例: 80kg × 8回 → 80 * (1 + 8/30) = 101.3kg // ActiveWorkoutState の totalVolume double get totalVolume { // 全種目の全セットの weight × reps を合計 // 例: 80×8 + 80×6 + 60×10 = 640+480+600 = 1720 }

3 前回記録の取得(DB → メモリ)

🔍
WorkoutRepository.getLastWorkoutSets()
Repository層 workout_repository.dart : 138-164行目
図書館で「この人が前に借りた本の記録を見せて」とお願いするようなもの。ベンチプレスの前回記録を探すために、workout_exercises テーブルと workouts テーブルを組み合わせて(JOIN)、一番新しいものを1件取得する。

SQL的にやっていること:

-- ステップ1: 最新のワークアウト種目を探す SELECT workout_exercises.* FROM workout_exercises INNER JOIN workouts ON workouts.id = workout_exercises.workoutId WHERE workout_exercises.exerciseId = 'ex-001' ORDER BY workouts.date DESC LIMIT 1; -- ステップ2: そのセット記録を取得 SELECT * FROM workout_sets WHERE workoutExerciseId = '上で見つかったID' ORDER BY setNumber ASC;
戻り値: List<WorkoutSet>
[WorkoutSet(weight:80, reps:8), WorkoutSet(weight:80, reps:6)]
🏆
WorkoutRepository.getPersonalBest1RM()
Repository層 workout_repository.dart : 274-293行目
「この種目のこれまでの最高スコアは?」と聞くようなもの。全てのセット記録から推定1RMが一番大きいものを1つだけ取り出す。
-- SQL的にやっていること SELECT workout_sets.estimated1rm FROM workout_sets INNER JOIN workout_exercises ON workout_exercises.id = workout_sets.workoutExerciseId WHERE workout_exercises.exerciseId = 'ex-001' AND workout_sets.estimated1rm IS NOT NULL ORDER BY workout_sets.estimated1rm DESC LIMIT 1;
戻り値: double? = 106.7 (過去最高の推定1RM)
null の場合 = その種目の記録がまだない

4 ワークアウト画面で種目を確認

📋
WorkoutScreen ─ 種目カード一覧と進捗表示
UI層 workout_screen.dart
お弁当の中身一覧表。各おかず(種目)が何セット記入済みかが一目でわかる。おかずをタップすると詳細画面に飛ぶ。

画面の構成要素:

  1. _Header: 戻るボタン、ルーティン名、日付、履歴ボタン
  2. _ProgressSection: 進捗バー(完了種目数 / 全種目数)
  3. _ExerciseCard: 各種目の名前 + セットチップ(「80×8」など)
  4. _BottomButton: 「トレーニングを終了」ボタン

種目カードタップ時:

// 種目をタップ → 詳細画面へ ref.read(activeWorkoutProvider.notifier) .goToExercise(index); // currentExerciseIndex を更新 Navigator.of(context).push( MaterialPageRoute( builder: (_) => const WorkoutExerciseScreen(), ), );

5 種目詳細画面で重量・回数を入力

✍️
WorkoutExerciseScreen ─ 数値入力
UI層 workout_exercise_screen.dart
実際に鉛筆で記録用紙に数字を書き込む場面。1セットずつ重量と回数を記入する。前回の記録も横に表示されていて「前はこうだったよ」と参考にできる。

画面の構成要素:

  1. _Header: 種目名 + 種目番号(1/3 など)
  2. _PreviousRecordSection: 前回の記録表示 +「前回の値を入力」ボタン
  3. _SetRow: セットごとに重量入力・回数入力・推定1RM・PRバッジ
  4. _BottomNav: 「次の種目」or「トレーニング完了」

入力時のデータの流れ:

// ユーザーが重量フィールドに「80」と入力 // ↓ TextEditingController の onChanged が発火 // ↓ Provider のメソッドを呼ぶ ref.read(activeWorkoutProvider.notifier) .updateWeight(exerciseIndex, setIndex, 80.0); // workout_provider.dart 335-343行目 void updateWeight(int exerciseIndex, int setIndex, double weight) { final sets = state!.exercises[exerciseIndex].sets; final newSets = [ for (var i = 0; i < sets.length; i++) if (i == setIndex) sets[i].copyWith(weight: weight) // ← ここだけ変更 else sets[i], ]; state = _withUpdatedSets(exerciseIndex, newSets); // ↑ state が変わると画面が自動で再描画される! }

「前回の値を入力」ボタン:

// applyPreviousRecord() (357-375行目) // previousSets の値を現在の sets にコピー // 前回: [80×8, 80×6] → 現在: [80×8, 80×6, SetData()]

その他の操作:

6 「トレーニング完了」をタップ ─ PR判定

🏅
ActiveWorkoutNotifier.completeWorkout() ─ PR判定
状態管理層 workout_provider.dart : 421-438行目
採点官が全てのセットを見て「これは自己ベスト更新だ!」とマルをつける作業。各セットの推定1RM(Epley式)を計算して、過去最高と比較する。

PR判定の流れ:

  1. 各種目について _repository.getPersonalBest1RM(exerciseId) を呼ぶ
  2. 各セットの estimated1rm を計算
    計算式: weight * (1 + reps / 30)(Epley式)
  3. もし estimated1rm > personalBest1rm なら isPR = true
  4. PRの総数をカウント
// 424-438行目 for (final ex in state!.exercises) { final best1rm = await _repository .getPersonalBest1RM(ex.exerciseId); for (final s in ex.sets) { final e1rm = s.estimated1rm; // 過去最高を超えていたら PR! final isPR = e1rm != null && (best1rm == null || e1rm > best1rm); if (isPR) prCount++; updatedSets.add(s.copyWith(isPR: isPR)); } }
例:
過去最高1RM: 106.7kg
今回のセット: 85kg × 8回 → 推定1RM = 85 * (1 + 8/30) = 107.7kg
107.7 > 106.7 → PR達成! isPR = true

7 サマリーデータの組み立て

📊
WorkoutSummary の作成
状態管理層 workout_provider.dart : 440-459行目
トレーニングの「成績表」を作る作業。何分やったか、合計何kg持ち上げたか、何種目何セットやったか、PR何回かをまとめる。
final summary = WorkoutSummary( routineName: state!.routineName, // "胸の日" date: state!.startedAt, // 開始時刻 durationSeconds: isPastDate ? 0 : now.difference(state!.startedAt).inSeconds, // 例: 2700秒(45分) totalVolume: state!.totalVolume, // 例: 3520.0 (kg) exerciseCount: state!.exercises.length, // 例: 3種目 totalSets: state!.totalFilledSetCount, // 例: 9セット prCount: prCount, // 例: 2回 );
サマリーの例:
ルーティン: 胸の日
時間: 45分
総ボリューム: 3,520kg
種目数: 3 / セット数: 9 / PR: 2回

8 Repository でDB形式に変換

🔄
WorkoutRepository.saveWorkout() ─ ドメイン→DB変換
Repository層 workout_repository.dart : 19-93行目
注文伝票(ActiveWorkoutState)を、倉庫の棚(DB)に保管するための「ラベル」に貼り替える作業。メモリ上のデータ構造を、SQLiteのテーブルに合う形に変換する。

処理の流れ:

  1. UUID を生成(24-26行目)
    workoutId = uuid.v4() — ユニークなIDを発行
  2. 所要時間を計算(34-36行目)
    durationSeconds = now - startedAt
  3. WorkoutsCompanion を作成(47-58行目)
  4. 各種目ごとにループ(63-93行目)
    種目ごとに exerciseId を生成し、WorkoutExercisesCompanion を作成
    各セットは hasData チェック(空セットは保存しない!)

変換マッピング(メモリ → DB):

メモリ上(ドメイン)DB形式(Companion)
ActiveWorkoutStateWorkoutsCompanion
ActiveExerciseDataWorkoutExercisesCompanion
SetDataWorkoutSetsCompanion

フィールド対応の詳細:

メモリDBテーブル
(自動生成)id (UUID)workouts
state.routineNameroutineNameworkouts
state.routineIdroutineId (FK)workouts
userId (引数)userId (FK)workouts
state.startedAtdateworkouts
now - startedAtdurationSecondsworkouts
state.totalVolumetotalVolumeworkouts
state.totalFilledSetCounttotalSetsworkouts
prCount集計prCountworkouts
(自動生成)id (UUID)workout_exercises
exercise.nameexerciseNameworkout_exercises
exercise.exerciseIdexerciseId (FK)workout_exercises
ループ変数 isortOrderworkout_exercises
(自動生成)id (UUID)workout_sets
setData.weightweightworkout_sets
setData.repsrepsworkout_sets
setData.estimated1rmestimated1rmworkout_sets
setData.isPRisPRworkout_sets
// workout_repository.dart 47-92行目(一部省略) final workout = WorkoutsCompanion( id: Value(workoutId), // UUID routineName: Value(workoutState.routineName), routineId: Value(workoutState.routineId), userId: Value(userId), date: Value(workoutState.startedAt), durationSeconds: Value(durationSeconds), createdAt: Value(now), totalVolume: Value(workoutState.totalVolume), totalSets: Value(workoutState.totalFilledSetCount), prCount: Value(prCount), ); for (var i = 0; i < workoutState.exercises.length; i++) { final exercise = workoutState.exercises[i]; final exerciseId = uuid.v4(); // 種目ごとにUUID exercises.add(WorkoutExercisesCompanion( id: Value(exerciseId), workoutId: Value(workoutId), // 親のID exerciseName: Value(exercise.name), exerciseId: Value(exercise.exerciseId), sortOrder: Value(i), )); for (var j = 0; j < exercise.sets.length; j++) { final setData = exercise.sets[j]; if (!setData.hasData) continue; // 空セットはスキップ! sets.add(WorkoutSetsCompanion( id: Value(uuid.v4()), workoutExerciseId: Value(exerciseId), // 親のID setNumber: Value(j + 1), // 1始まり weight: Value(setData.weight), reps: Value(setData.reps), estimated1rm: Value(setData.estimated1rm), isPR: Value(setData.isPR), )); } }

9 SQLite にトランザクションで一括保存

💾
_db.transaction() ─ 3テーブルに一括 INSERT
ローカルDB層 workout_repository.dart : 96-104行目
銀行の振込と同じで「全部成功するか、全部取り消すか」のどちらか。もし途中でエラーが起きたら、途中まで保存したデータも巻き戻す。これが「トランザクション」。

3つのテーブルに順番に INSERT:

1. workouts テーブル(親)
id | routineName | routineId | userId | date | durationSeconds | createdAt | totalVolume | totalSets | prCount
2. workout_exercises テーブル(子)
id | workoutId | exerciseName | exerciseId | sortOrder
3. workout_sets テーブル(孫)
id | workoutExerciseId | setNumber | weight | reps | estimated1rm | isPR
// workout_repository.dart 96-104行目 await _db.transaction(() async { // 1. ワークアウト本体を INSERT await _db.into(_db.workouts).insert(workout); // 2. 各種目を INSERT for (final exercise in exercises) { await _db.into(_db.workoutExercises) .insert(exercise); } // 3. 各セットを INSERT for (final set in sets) { await _db.into(_db.workoutSets) .insert(set); } // 全部成功 → コミット / どれか失敗 → 全部取り消し });
INSERT の具体例(3種目 × 3セットの場合):
workouts: 1行 INSERT
workout_exercises: 3行 INSERT(ベンチプレス、インクラインDB、ケーブルフライ)
workout_sets: 最大9行 INSERT(空セットは除外されるので実際はそれ以下)

10 バックグラウンドでクラウド同期

☁️
リモート同期の起動(UIはブロックしない)
Repository層 workout_repository.dart : 107-134行目
郵便局に手紙を出すようなもの。手紙(データ)をポストに入れたら(unawaited)、届くのを待たずに自分は帰る。配達(同期)は郵便屋さん(SyncService)がバックグラウンドでやってくれる。

条件: userId != null かつ syncService != null の場合のみ実行

ログインしていないユーザーはローカル保存のみで同期しない。

処理の流れ:

  1. ローカルDBから保存済みデータを再取得(109-121行目)
  2. unawaited() でバックグラウンド実行(123行目)
  3. まず syncService.syncUser() でユーザーを登録
  4. 次に syncService.syncWorkout() でワークアウトを同期
// workout_repository.dart 107-134行目 if (syncService != null && userId != null) { // ローカル保存済みのデータを取得 final savedWorkout = await ...; final savedExercises = await ...; final savedSets = await ...; // unawaited = 完了を待たない(バックグラウンド) unawaited( syncService.syncUser() // まずユーザーを登録 .then((_) => syncService.syncWorkout( workout: savedWorkout, workoutExercises: savedExercises, workoutSets: savedSets, )), ); // ← ここで関数は終了。同期は裏で続く }
🔐
SyncService.syncUser() ─ ユーザー登録
クラウド層 sync_service.dart : 123-134行目
クラウド側に「この人のアカウントがあるか確認して、なければ作って」と頼む(upsert = update or insert)。ワークアウトの userId はユーザーテーブルへの外部キーなので、先にユーザーを登録しないとワークアウトを保存できない。
// sync_service.dart 123-134行目 Future<void> syncUser({String? displayName}) async { try { final builder = _connector.upsertUser(); if (displayName != null) { builder.displayName(displayName); } await builder.execute(); // → Firebase 認証のUID で自動識別 } catch (e) { debugPrint('Sync: ユーザー同期失敗 — $e'); // エラーでもアプリは止まらない } }
🚀
SyncService.syncWorkout() ─ クラウドに全データを送信
クラウド層 sync_service.dart : 32-84行目
荷物を3つの段ボール(テーブル)に分けて送る。まず大きな箱(Session)、次に中くらいの箱(SessionExercise)、最後に小さな箱(SetRecord)。順番が大事で、親→子→孫の順に送る。

3ステップの同期:

Step 1: Workout → Session(39-49行目)

ローカル (SQLite)リモート (PostgreSQL)
workout.idSession.id
workout.routineNameSession.routineName
workout.dateSession.date (Timestamp)
workout.durationSecondsSession.durationSeconds
workout.createdAtSession.createdAt (Timestamp)
workout.routineIdSession.routineId (optional)

Step 2: WorkoutExercise → SessionExercise(52-63行目)

ローカル (SQLite)リモート (PostgreSQL)
ex.idSessionExercise.id
ex.workoutIdSessionExercise.sessionId
ex.exerciseNameSessionExercise.exerciseName
ex.sortOrderSessionExercise.sortOrder
ex.exerciseIdSessionExercise.exerciseId (optional)

Step 3: WorkoutSet → SetRecord(66-78行目)

ローカル (SQLite)リモート (PostgreSQL)
set.idSetRecord.id
set.workoutExerciseIdSetRecord.sessionExerciseId
set.setNumberSetRecord.setNumber
set.weightSetRecord.weight
set.repsSetRecord.reps
set.estimated1rmSetRecord.estimated1rm (optional)
// sync_service.dart 32-84行目 Future<void> syncWorkout({...}) async { try { // 1. Workout → Session (upsert) final sessionBuilder = _connector.syncSession( id: workout.id, routineName: workout.routineName, date: _toTimestamp(workout.date), durationSeconds: workout.durationSeconds, createdAt: _toTimestamp(workout.createdAt), ); await sessionBuilder.execute(); // 2. WorkoutExercises → SessionExercises for (final ex in workoutExercises) { final builder = _connector.syncSessionExercise( id: ex.id, sessionId: ex.workoutId, exerciseName: ex.exerciseName, sortOrder: ex.sortOrder, ); await builder.execute(); } // 3. WorkoutSets → SetRecords for (final set in workoutSets) { final builder = _connector.syncSetRecord( id: set.id, sessionExerciseId: set.workoutExerciseId, setNumber: set.setNumber, weight: set.weight, reps: set.reps, ); await builder.execute(); } } catch (e) { // エラーでもアプリは止まらない(次回リトライ) _printSyncError('ワークアウト同期', e); } }
🔥
WorkoutlogConnector ─ Firebase Data Connect
クラウド層 dataconnect_generated/generated.dart(自動生成)
Firebase専用の「翻訳機」。Dartのデータを Firebase が理解できるJSON形式に変換して、インターネット経由で Google Cloud の PostgreSQL データベースに送る。このファイルは自動生成なので手で書く必要がない。

提供されるメソッド:

upsert とは: そのIDのデータがなければ INSERT、あれば UPDATE。つまり「あれば上書き、なければ新規作成」。同じワークアウトを2回同期しても重複しない。

最終的な保存先:

Firebase Data Connect (Google Cloud SQL - PostgreSQL)
Users テーブル
Sessions テーブル(= workouts)
SessionExercises テーブル(= workout_exercises)
SetRecords テーブル(= workout_sets)
Exercises テーブル
Routines テーブル

11 完了画面の表示とメモリクリア

🎉
WorkoutSummaryScreen + state = null
UI層 + 状態管理層
テストが終わって答案(データ)を先生(DB)に提出したあと、採点結果(サマリー)を見る画面。答案用紙のコピー(メモリ上のstate)はもう必要ないので破棄する。

completeWorkout() の最後:

// workout_provider.dart 474-477行目 state = null; // メモリをクリア(ワークアウト終了) return summary; // サマリーを画面に返す

戻り値の WorkoutSummaryWorkoutExerciseScreen で受け取り、WorkoutSummaryScreen に渡して完了画面を表示する。

データの最終状態:

+ Provider の依存関係

activeWorkoutProvider appDatabaseProvider (keepAlive: true) workoutRepositoryProvider currentUserIdProvider ref.watch AppDatabase SQLite (drift) シングルトン authStateProvider authRepositoryProvider Firebase Auth syncServiceProvider WorkoutlogConnector Firebase Data Connect ローカル クラウド 変換 状態管理

+ DB テーブル関係(ER図)

workouts PK id (TEXT, UUID) routineName (TEXT) FK routineId → routines FK userId → users date (DATETIME) durationSeconds (INT) totalVolume / totalSets / prCount createdAt (DATETIME) workout_exercises PK id (TEXT, UUID) FK workoutId → workouts exerciseName (TEXT) FK exerciseId → exercises sortOrder (INT) workout_sets PK id (TEXT, UUID) FK workoutExerciseId setNumber (INT) weight (REAL) reps (INT) estimated1rm / isPR 1:N 1:N exercises PK id (TEXT, UUID) name (TEXT) bodyPart (TEXT) recordType (TEXT) routines PK id (TEXT, UUID) name / color / sortOrder createdAt / updatedAt users PK id (Firebase UID) routine_exercises (中間テーブル)

+ 画面遷移フロー

HomeScreen ルーティン選択 push WorkoutScreen 種目一覧 push WorkoutExercise Screen 重量・回数入力 「完了」ボタン push WorkoutSummary Screen 完了! popUntil → ホームに戻る

以上が、タップから保存までの全工程です

各ステップをタップすると詳細が開きます

筋トレメモ データフロー完全図解 — 2026-03-28