筋トレメモの SetData クラスを0行から組み立てる
class SetData {
}
class はデータの「設計図」。この中にフィールド(データ項目)やメソッド(動作)を書いていく。
class SetData {
double weight; // 重量 (kg)
int reps; // 回数
bool isPR; // 自己ベスト?
}
フィールド = classが持つデータ項目。型を指定することで、間違った値を入れるとコンパイルエラーになる。
| 型 | 用途 | 例 |
|---|---|---|
double | 小数点あり | 60.5 (kg) |
int | 整数のみ | 10 (回) |
bool | true / false | PR達成? |
String | 文字列 | "ベンチプレス" |
String ではなく int? セット回数 "十回" と書いても計算できない。型で制約をかけてバグを防ぐ。class SetData {
final double weight;
final int reps;
final bool isPR;
}
| finalあり | finalなし | |
|---|---|---|
| 変更 | 一度セットしたら変更不可 | どこからでも書き換え可 |
| 安全性 | 予期しない変更を防止 | バグの温床になりやすい |
実際の筋トレメモのコード(freezedを使った書き方):
lib/features/workout/workout_provider.dart L19-35@freezed
abstract class SetData with _$SetData {
const factory SetData({
@Default(0) double weight,
@Default(0) int reps,
@Default(false) bool isPR,
}) = _SetData;
}
| @Default あり | required | |
|---|---|---|
| 呼び出し | SetData() だけでOK | SetData(weight: 0, reps: 0, isPR: false) 必須 |
| 使い時 | 「空のセット」を大量に作るとき | 省略されたくない値 |
実際にセッション開始時、空のセットを3つ生成している:
lib/features/workout/workout_provider.dart L186List.generate(defaultSetCount, (_) => const SetData())
weight: 0, reps: 0 と書くのは冗長だから。abstract class SetData with _$SetData {
const factory SetData({...}) = _SetData;
const SetData._();
/// 入力済みかどうか
bool get hasData => weight > 0 || reps > 0;
/// 推定1RM(Epley式: weight * (1 + reps / 30))
double? get estimated1rm =>
weight > 0 && reps > 0 ? weight * (1 + reps / 30) : null;
}
| getter | 毎回計算式を書く | |
|---|---|---|
| コード | s.hasData | s.weight > 0 || s.reps > 0 |
| 変更時 | 1箇所修正 | 使用箇所すべて修正 |
class = データの設計図。フィールドで「何を持つか」を定義するfinal でフィールドを不変にし、安全なデータ管理を実現するgetter で計算ロジックをclass内にまとめる(DRY原則)筋トレメモの全モデル(SetData, ActiveExerciseData, ActiveWorkoutState)がこのパターンで作られている。
final double weight; の final は何を意味する?double が担っている。null を禁止するのは null safety の仕組み(1-2 で学ぶ)。bool get hasData => weight > 0 || reps > 0; で、weight=0, reps=5 のとき hasData は?|| は「または」。reps > 0 が true なので全体は true。自重トレーニング(weight=0)でも回数があれば入力済み扱い。const SetData() と書けるのはなぜ?const は全フィールドがコンパイル時に確定するため使える。「値がないかもしれない」を型で表現する
? を付けるActiveWorkoutState の実コードを見てみよう:
lib/features/workout/workout_provider.dart L59-68const factory ActiveWorkoutState({
String? routineId,
required String routineName,
required DateTime startedAt,
required List<ActiveExerciseData> exercises,
@Default(0) int currentExerciseIndex,
@Default(false) bool isEditMode,
String? editingSessionId,
})
String? の ? は「この値は null(存在しない)かもしれない」という宣言。
| 方法 | 書き方 | 安全性 |
|---|---|---|
| if チェック | if (value != null) { ... } | 安全 |
?? 演算子 | value ?? 'デフォルト' | 安全(null時にデフォルト値) |
! 強制アンラップ | value! | nullならクラッシュ |
// 実コード: フリートレーニング開始
routineId: null, // ルーティンなし
routineName: 'フリートレーニング', // 名前はrequired(nullにできない)
lib/features/workout/workout_repository.dart L107
// nullチェックの実例(saveSession内)
if (syncService != null && userId != null) {
// 同期処理を実行
}
! なしで non-null として使おうとするとコンパイルエラー(ビルドが通らない)。! を付けて実際に null だった場合は実行時エラー(アプリがクラッシュ)。この違いは 1-6 で詳しく訓練する。! は危険? value! は「絶対にnullじゃないはず」と宣言するもの。もしnullだった場合、アプリがクラッシュする。?? や if チェックのほうが安全。abstract class ActiveWorkoutState with _$ActiveWorkoutState {
const factory ActiveWorkoutState({
String? routineId, // nullable: フリートレーニング時はnull
required String routineName, // non-null: 必ず名前がある
required DateTime startedAt, // non-null: 必ず開始時刻がある
required List<ActiveExerciseData> exercises,
@Default(0) int currentExerciseIndex,
@Default(false) bool isEditMode,
String? editingSessionId, // nullable: 新規作成時はnull
})
String? routineId — フリートレーニングなら「ない」→ nullablerequired String routineName — どんなセッションでも名前は「ある」→ non-null + requiredString? editingSessionId — 新規作成なら「ない」→ nullableルール: 「ないかもしれない」ものだけ ? を付ける。
テストコードの実例:
test/features/workout/workout_repository_test.dart L10-25group('WorkoutRepository', () {
late AppDatabase db;
late WorkoutRepository repository;
late String exerciseId;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
repository = WorkoutRepository(db);
// ... exerciseId も setUp 内で代入
});
});
late | ? (nullable) | |
|---|---|---|
| 意味 | 「後で必ず入れる」 | 「ないかもしれない」 |
| 初期化忘れ | 実行時エラー(LateInitializationError) | null として扱える |
| 使い時 | setUp で初期化するテスト変数 | 本当に「ないかもしれない」データ |
/// 推定1RM(Epley式)
double? get estimated1rm =>
weight > 0 && reps > 0 ? weight * (1 + reps / 30) : null;
戻り値の型が double?。weight=0 のとき 1RM を計算する意味がないので、null で「計算不能」を表現している。
Repository でも同じパターン:
lib/features/workout/workout_repository.dart L274/// 指定種目の過去最高推定1RMを取得
Future<double?> getPersonalBest1RM(String exerciseId) async {
// ... DB問い合わせ
if (rows.isEmpty) return null; // 記録がなければ null
return rows.first.readTable(_db.workoutSets).estimated1rm;
}
String? の ? = 「null かもしれない」。本当に「ないかもしれない」データにだけ付ける?? や if != null で安全に扱う。! はクラッシュの危険ありlate = 「後で必ず入れる」。初期化忘れは実行時エラー(コンパイルでは検知されない)late AppDatabase db; で、setUp を書き忘れて db を使ったらどうなる?estimated1rm で weight=80, reps=0 のときの戻り値は?weight > 0 && reps > 0。reps が 0 なので条件は false → null を返す。0回の挙上から1RMは推定できない。routineId: null と明示的に null をセットしている(workout_provider.dart L233)。空文字とnullは違う意味。「ルーティンが存在しない」= null。List, Map, Set — データの「入れ物」を使い分ける
const factory ActiveExerciseData({
required String exerciseId,
required String name,
required List<SetData> sets,
@Default([]) List<SetData> previousSets,
double? personalBest1rm,
})
List<SetData> の <SetData> がジェネリクス。「この List の中身は SetData だけ」と宣言している。
List だけだと何でも入ってしまう。List<SetData> にすれば、間違えて String を入れようとするとコンパイルエラーで教えてくれる。/// 入力済みセット数
int get filledSetCount => sets.where((s) => s.hasData).length;
/// 1つでも入力されていれば完了扱い
bool get isCompleted => sets.any((s) => s.hasData);
sets — 全セットのリストを取得.where((s) => s.hasData) — 入力済みのものだけ残す(フィルタ).length — 残った要素の個数を返すもう一つの実例。前回記録を SetData に変換する処理:
lib/features/workout/workout_provider.dart L197-199final previousSets = lastSets
.map((r) => SetData(weight: r.weight, reps: r.reps))
.toList();
lastSets — DB から取得した前回セット記録のリスト.map((r) => SetData(...)) — 各要素 r を SetData に変換.toList() — 結果を List に確定する| メソッド | 動作 | 筋トレメモでの例 |
|---|---|---|
.where() | 条件に合うものだけ残す | 入力済みセットだけ |
.map() | 各要素を変換する | DB記録 → SetData |
.any() | 1つでも条件に合えば true | 1セットでも入力あれば完了 |
.toList() | 結果をListに確定する | Iterable → List 変換 |
/// 新規セッション開始時のデフォルトセット数
const defaultSetCount = 3;
sets: List.generate(defaultSetCount, (_) => const SetData()),
(_) の _ は「引数を使わない」という意味。List.generate は各要素のインデックス(0,1,2)を渡すが、ここでは不要なので _ で無視している。
[SetData(), SetData(), SetData()] と書くとセット数を変えるとき修正が必要。defaultSetCount を変えるだけで自動対応。シードデータで種目名からIDを引く Map:
lib/database/app_database.dart L71-99final idByName = <String, String>{};
final seeds = [
('ベンチプレス', '胸', 'weight_reps'),
('インクラインダンベルプレス', '胸', 'weight_reps'),
// ...
];
for (final (name, bodyPart, recordType) in seeds) {
final id = uuid.v4();
idByName[name] = id; // 種目名 → ID の対応を記録
// DB に insert ...
}
| List | Map | |
|---|---|---|
| アクセス | list[0] (番号で) | map['ベンチプレス'] (名前で) |
| 用途 | 順番が重要なデータ | キーで素早く検索したいデータ |
| 例 | セットの順番 | 種目名 → ID の対応表 |
idByName['ベンチプレス'] で一発。カレンダーウィジェットの実コード:
lib/features/home/calendar_widget.dart L23-27// トレーニング実施日を Set<int> に変換して O(1) ルックアップ
final trainingDays = datesAsync.when(
data: (dates) => dates.map((d) => d.day).toSet(),
loading: () => <int>{},
error: (_, _) => <int>{},
);
// 使う側
final hasTraining = trainingDays.contains(day);
dates — トレーニング実施日の DateTime リスト.map((d) => d.day) — 各日付から「日」だけ取り出す(1, 5, 12...).toSet() — 重複を排除した Set に変換| List<int> | Set<int> | |
|---|---|---|
| 重複 | 許可(同じ日が複数入る可能性) | 自動的に排除 |
| contains の速度 | O(n) — 全要素を順番にチェック | O(1) — 一発で判定 |
| 用途 | 順番が必要なデータ | 「含まれるか」を高速に判定 |
List<T> = 順番付き。セットの順序管理に使うMap<K,V> = キーで引く辞書。名前→ID の対応表に使うSet<T> = 重複なし集合。「含まれるか」の高速判定に使うsets.where((s) => s.hasData).length は何を返す?.where() は条件を満たす要素だけ残す。hasData が true のもの = 入力済みのセットだけカウント。Set<int> を使う最大の理由は?lastSets.map((r) => SetData(weight: r.weight, reps: r.reps)).toList() の .toList() を省略するとどうなる?「何を受け取り → 何をし → 何を返す」の3部読解
bool get hasData => weight > 0 || reps > 0;
関数を読むときは常にこの3つを確認する:
bool(true / false)bool get hasData」→ 戻り値は bool、名前は hasData、引数なし(getter)// 名前付き引数(筋トレメモで使用)
const factory SetData({
@Default(0) double weight,
@Default(0) int reps,
@Default(false) bool isPR,
})
// 呼び出し
SetData(weight: 60, reps: 10)
// もし位置引数だったら...
SetData(60, 10, false) // 60が何で10が何か分かりにくい!
{} で囲まれている → 名前付き引数。呼び出し時に weight: reps: とラベルを付ける」名前付き引数 {} | 位置引数 | |
|---|---|---|
| 呼び出し | SetData(weight: 60) | SetData(60, 0, false) |
| 可読性 | 何の値か一目瞭然 | 順番を覚える必要あり |
| 省略 | デフォルト値があれば省略可 | 途中の引数は省略できない |
SetData(60, 10, false) だと「60は重量? 回数?」と迷う。weight: 60 なら間違えようがない。=> vs ブロック {}// アロー関数(1つの式だけ)
bool get hasData => weight > 0 || reps > 0;
// ブロック関数(複数の文)
double get totalVolume {
var total = 0.0;
for (final ex in exercises) {
for (final s in ex.sets) {
total += s.weight * s.reps;
}
}
return total;
}
hasData: 戻り値 bool、アロー => で1式。return 不要totalVolume: 戻り値 double、ブロック {} で複数文。return が必要=> アロー | {} ブロック | |
|---|---|---|
| 書ける内容 | 1つの式のみ | 何文でもOK |
| return | 不要(式の結果が自動で返る) | return を明示 |
| 用途 | シンプルな計算・判定 | ループ・条件分岐が複雑なとき |
void goToExercise(int index) {
if (!_isValidExerciseIndex(index)) return;
state = state!.copyWith(currentExerciseIndex: index);
}
int index(移動先の種目番号)void(何も返さない。状態を変えるだけ)void nextExercise() {
if (state == null || state!.isLastExercise) return;
goToExercise(state!.currentExerciseIndex + 1);
}
Future<void> saveSession(
ActiveWorkoutState recordState, {
String? userId,
SyncService? syncService,
}) async {
// DB保存処理...
}
ActiveWorkoutState(必須の位置引数)+ userId, syncService(任意の名前付き引数、どちらもnullable)Future<void>(「いつか完了する約束」で、完了しても値は返さない)次のトピック 1-5 で async/await を詳しく学ぶ。
{} で可読性を確保。=> はシンプルな1式、{} は複雑な処理にvoid = 戻り値なし。Future<void> = 非同期で戻り値なしvoid goToExercise(int index) のシグネチャを3部読解すると?int index で種目のインデックス番号。状態を変更するだけで、呼び出し元に値を返さない。double? get estimated1rm => ... をブロック構文で書き直すと?return を明示する必要がある。Aは return がないし条件分岐もない。Cは getter ではなくメソッドになっている(get がない)。Future<void> saveSession(ActiveWorkoutState recordState, {String? userId}) async で、userId を省略して呼ぶとどうなる?String? の名前付き引数はデフォルトで null。省略しても呼び出しは成功する。メソッド内で if (userId != null) で分岐している。Future, async/await, Stream — 時間がかかる処理を扱う
DB読み書き、ネットワーク通信は時間がかかる。同期的に待つとアプリが固まる(フリーズ)。
| 同期 | 非同期 | |
|---|---|---|
| 処理中のUI | フリーズ | スムーズに動く |
| Dartの表現 | String | Future<String> |
| 「結果」 | すぐ手に入る | 「いつか届く約束」 |
Future<List<WorkoutSet>> getLastSessionSets(String exerciseId) async {
// DB問い合わせ(時間がかかる)
final latestQuery = _db.select(...);
final latestRows = await latestQuery.get();
// ...
return setsQuery.get();
}
String exerciseId(検索する種目のID)Future<List<WorkoutSet>>(「WorkoutSet のリストがいつか届く約束」)Future<void> startSession(RoutineWithExercises data) async {
// 1. まず基本の state をセット(同期処理: 一瞬で完了)
state = ActiveWorkoutState(...);
// 2. 各種目の前回記録を取得(非同期: DB問い合わせ)
for (final ex in state!.exercises) {
final lastSets = await _repository.getLastSessionSets(ex.exerciseId);
final best1rm = await _repository.getPersonalBest1RM(ex.exerciseId);
// ...
}
}
async = 「この関数の中で await を使います」という宣言。
await = 「Future の結果が届くまで待つ」。await を付けないと、まだ届いていない「約束」を使おうとしてエラーになる。
セッション開始の3段階を追跡する:
lib/features/workout/workout_provider.dart L177-206// 段階1: 基本 state をセット(空のセット3つで初期化)
state = ActiveWorkoutState(
routineId: data.routine.id,
routineName: data.routine.name,
startedAt: targetDate ?? DateTime.now(),
exercises: data.exercises.map((e) {
return ActiveExerciseData(
exerciseId: e.id, name: e.name,
sets: List.generate(defaultSetCount, (_) => const SetData()),
);
}).toList(),
);
// 段階2: DB問い合わせ(await で1つずつ待つ)
for (final ex in state!.exercises) {
final lastSets = await _repository.getLastSessionSets(ex.exerciseId);
final best1rm = await _repository.getPersonalBest1RM(ex.exerciseId);
// ...
}
// 段階3: state を更新(前回記録を反映)
state = state!.copyWith(exercises: updatedExercises);
処理の流れ:
/// 全ワークアウトをリアルタイムで監視(DB変更を自動検知)
Stream<List<Workout>> watchAllSessions() {
return (_db.select(
_db.workouts,
)..orderBy([(t) => OrderingTerm.desc(t.date)])).watch();
}
| Future | Stream | |
|---|---|---|
| 届く回数 | 1回だけ | 何度でも(変更のたびに) |
| 用途 | 1回のDB問い合わせ | リアルタイム監視 |
| 例 | getLastSessionSets() | watchAllSessions() |
| アナロジー | 郵便(1通届いて終わり) | ニュース速報(更新のたびに通知) |
比較: getAllSessions は Future(1回だけ):
lib/features/history/history_repository.dart L13-16Future<List<Workout>> getAllSessions() {
return (_db.select(_db.workouts)
..orderBy([(t) => OrderingTerm.desc(t.date)])).get();
}
onCreate: (Migrator m) async {
await m.createAll();
final exerciseIdByName = await _seedExercises();
await _seedRoutines(exerciseIdByName);
},
この順序を逆にすると?
// NG: ルーティンを先に作ろうとする
await _seedRoutines(exerciseIdByName); // exerciseIdByNameが未定義!
final exerciseIdByName = await _seedExercises();
ルーティンは「ベンチプレスのID」を参照する。種目がまだDBにないのにルーティンを作ろうとすると失敗する。
Future<T> = 1回届く約束。async/await で結果を待つStream<T> = 何度も届くデータ。リアルタイム監視に使うFuture<List<WorkoutSet>> を日本語で説明すると?コンパイルエラーで守るか、実行時エラーに賭けるか
まず、2種類のエラーの違いを確認しよう:
| コンパイルエラー | 実行時エラー | |
|---|---|---|
| いつ発覚? | ビルド時(コード実行前) | アプリ実行中(ユーザーの手元で) |
| 影響 | ビルドが通らないだけ | アプリがクラッシュ |
| 修正コスト | 低い(開発者がすぐ気づく) | 高い(ユーザーが被害を受ける可能性) |
| 例 | 型の不一致、case漏れ | null参照、late未初期化 |
このトピックでは、enum と switch が「コンパイルエラーで守る」仕組みをどう作るかを学ぶ。
筋トレメモの種目には「部位」がある。もし enum で定義するなら:
enum BodyPart {
chest, // 胸
back, // 背中
legs, // 脚
shoulders,// 肩
arms, // 腕
abs, // 腹
other, // その他
}
しかし現在の筋トレメモでは String を使っている:
lib/database/tables/exercises.dart L9-12class Exercises extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
TextColumn get bodyPart => text()(); // String: "胸", "背中", ...
TextColumn get recordType => text()(); // String: "weight_reps", ...
DateTimeColumn get createdAt => dateTime()();
}
"胸" "むね" "Chest" などの表記揺れが起きる。enum なら BodyPart.chest しか書けない。switch (bodyPart) {
case BodyPart.chest:
return '胸のトレーニング';
case BodyPart.back:
return '背中のトレーニング';
case BodyPart.legs:
return '脚のトレーニング';
// shoulders, arms, abs, other を書き忘れると...
// → コンパイルエラー! ビルドが通らない!
}
Dart の switch は enum の全 case を網羅しないとコンパイルエラーになる。これが exhaustive check(網羅性チェック)。
将来 enum に cardio(有酸素)を追加したら?
enum BodyPart {
chest, back, legs, shoulders, arms, abs, other,
cardio, // 新規追加!
}
// → 全ての switch 文で「cardio の case がない」とコンパイルエラー!
// → 修正漏れがゼロになる
シードデータの実例:
lib/database/app_database.dart L76-86final seeds = [
('ベンチプレス', '胸', 'weight_reps'),
('ラットプルダウン', '背中', 'weight_reps'),
// ...
];
もし recordType で switch するなら:
switch (recordType) { // recordType は String
case 'weight_reps':
return WeightRepsInput();
case 'bodyweight_reps':
return BodyweightInput();
case 'time':
return TimeInput();
default:
return Text('不明な記録タイプ'); // typoはここに落ちる
}
typo があっても:
('ベンチプレス', '胸', 'weght_reps'), // typo: "weight" → "weght"
// コンパイルエラーにならない! 実行時に default に落ちて不正な表示!
| enum + switch | String + switch | |
|---|---|---|
| case 漏れ | コンパイルエラー(ビルド前に検知) | default に落ちるだけ(実行時に発覚) |
| typo | コンパイルエラー(BodyPart.chset は存在しない) | 気づかない("chset" も有効な String) |
| 新しい値の追加 | 全 switch の修正漏れをコンパイラが指摘 | 修正漏れに気づかない |
シードデータでタプル(組)の分解が使われている:
lib/database/app_database.dart L86final seeds = [
('ベンチプレス', '胸', 'weight_reps'),
('ラットプルダウン', '背中', 'weight_reps'),
// ...
];
for (final (name, bodyPart, recordType) in seeds) {
// name = 'ベンチプレス', bodyPart = '胸', recordType = 'weight_reps'
}
// パターンマッチングなし(従来の書き方)
for (final seed in seeds) {
final name = seed.$1; // 1番目の要素
final bodyPart = seed.$2; // 2番目の要素
final recordType = seed.$3; // 3番目の要素
}
// パターンマッチングあり
for (final (name, bodyPart, recordType) in seeds) {
// 直接使える!
}
| パターンマッチング | 従来の書き方 | |
|---|---|---|
| 可読性 | 変数名が明示的 | $1, $2 は意味不明 |
| 行数 | 1行 | 3行 |
ルーティンのシードでも同じパターン:
lib/database/app_database.dart L111-113final routineSeeds = [
('胸の日', '#EF4444', ['ベンチプレス', 'インクラインダンベルプレス', 'ケーブルフライ']),
// ...
];
for (var ri = 0; ri < routineSeeds.length; ri++) {
final (name, color, exerciseNames) = routineSeeds[ri];
// ...
}
enum = 選択肢を型で定義。typo や case 漏れをコンパイルエラーで防ぐrecordType が String で 'weght_reps'(typo)がシードに入ったらどうなる?! の危険性を思い出そう。for (final (name, bodyPart, recordType) in seeds) の書き方は何と呼ばれる?seed.$1 と書くより意図が明確。Dart の基本構文を筋トレメモのコードで学びました。
学習した実コードの場所:
workout_provider.dart / workout_repository.dart / history_repository.dart / app_database.dart / calendar_widget.dart / exercises.dart / workout_repository_test.dart