Lv.1 Dart基礎
0 / 6
TOPIC 1-1

classとフィールド

筋トレメモの SetData クラスを0行から組み立てる

0%
STEP 1 / 5

空のclass宣言

筋トレの種目ごとに「記録用紙」があるように、classは「データの図面」を定義するもの。まずは枠だけ引こう。
日本語で読む: 「SetData という名前の クラスを 宣言する」
class SetData {
}

class はデータの「設計図」。この中にフィールド(データ項目)やメソッド(動作)を書いていく。

なぜclassを使う? 重量・回数・PR判定をバラバラの変数で持つと管理が大変。classでまとめれば「1セット分のデータ」として扱える。
STEP 2 / 5

フィールドを追加する

日本語で読む: 「SetData は double型の weight、int型の reps、bool型の isPR を持つ」
class SetData {
  double weight;   // 重量 (kg)
  int reps;        // 回数
  bool isPR;       // 自己ベスト?
}

フィールド = classが持つデータ項目。型を指定することで、間違った値を入れるとコンパイルエラーになる。

用途
double小数点あり60.5 (kg)
int整数のみ10 (回)
booltrue / falsePR達成?
String文字列"ベンチプレス"
日本語で読む: 「double weight は、小数を含む数値(double)型の weight という名前のフィールド」
なぜ回数に String ではなく int? セット回数 "十回" と書いても計算できない。型で制約をかけてバグを防ぐ。
STEP 3 / 5

finalで不変にする

日本語で読む: 「weight は 変更不可(final)の double型フィールド」
class SetData {
  final double weight;
  final int reps;
  final bool isPR;
}
finalありfinalなし
変更一度セットしたら変更不可どこからでも書き換え可
安全性予期しない変更を防止バグの温床になりやすい
なぜイミュータブル(不変)にする? 一度記録した「60kg x 10回」が勝手に変わったら困る。変更したいときは「新しいSetDataを作り直す」方式にする。これが筋トレメモ全体の設計方針。
紙の記録用紙と同じ。書き間違えたら修正液ではなく、新しい用紙に書き直す。
STEP 4 / 5

コンストラクタとデフォルト値

実際の筋トレメモのコード(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;
}
日本語で読む: 「SetData を作るとき、weight は省略すれば 0、reps は省略すれば 0、isPR は省略すれば false になる」
@Default ありrequired
呼び出しSetData() だけでOKSetData(weight: 0, reps: 0, isPR: false) 必須
使い時「空のセット」を大量に作るとき省略されたくない値

実際にセッション開始時、空のセットを3つ生成している:

lib/features/workout/workout_provider.dart L186
List.generate(defaultSetCount, (_) => const SetData())
なぜデフォルト値? 新規セッション開始時に3セット分の空データを作る。毎回 weight: 0, reps: 0 と書くのは冗長だから。
STEP 5 / 5

getterで計算ロジックをまとめる

lib/features/workout/workout_provider.dart L29-34
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;
}
日本語で読む: 「hasData は weight が 0 より大きい、または reps が 0 より大きいとき true を返す」
日本語で読む: 「estimated1rm は weight と reps が両方 0 より大きいとき Epley式で計算、そうでなければ null を返す」
getter毎回計算式を書く
コードs.hasDatas.weight > 0 || s.reps > 0
変更時1箇所修正使用箇所すべて修正
これがDRY原則(Don't Repeat Yourself)。計算ロジックを1箇所にまとめると、仕様変更時に修正漏れが起きない。

1-1 まとめ

  • class = データの設計図。フィールドで「何を持つか」を定義する
  • final でフィールドを不変にし、安全なデータ管理を実現する
  • getter で計算ロジックをclass内にまとめる(DRY原則)

筋トレメモの全モデル(SetData, ActiveExerciseData, ActiveWorkoutState)がこのパターンで作られている。

Q1. final double weight;final は何を意味する?
final は「一度セットしたら再代入不可」という制約。型指定は double が担っている。null を禁止するのは null safety の仕組み(1-2 で学ぶ)。
Q2. bool get hasData => weight > 0 || reps > 0; で、weight=0, reps=5 のとき hasData は?
|| は「または」。reps > 0 が true なので全体は true。自重トレーニング(weight=0)でも回数があれば入力済み扱い。
Q3. const SetData() と書けるのはなぜ?
weight=0, reps=0, isPR=false のデフォルト値があるので、引数なしで呼び出せる。const は全フィールドがコンパイル時に確定するため使える。
TOPIC 1-2

null safety

「値がないかもしれない」を型で表現する

0%
STEP 1 / 5

nullable型: ? を付ける

ActiveWorkoutState の実コードを見てみよう:

lib/features/workout/workout_provider.dart L59-68
const factory ActiveWorkoutState({
  String? routineId,
  required String routineName,
  required DateTime startedAt,
  required List<ActiveExerciseData> exercises,
  @Default(0) int currentExerciseIndex,
  @Default(false) bool isEditMode,
  String? editingSessionId,
})
日本語で読む: 「routineId は String型 だが null かもしれない(String?)」

String?? は「この値は null(存在しない)かもしれない」という宣言。

ジムで「今日のルーティンは?」と聞かれて「決めてない(フリートレーニング)」と答える場面。ルーティンが「ない」状態 = null。
なぜ routineId が nullable? フリートレーニング(ルーティンなし)のとき、IDが存在しない。「空文字 ""」で表現すると「空文字が有効なID」と区別できなくなる。
STEP 2 / 5

nullチェック3種

方法書き方安全性
if チェックif (value != null) { ... }安全
?? 演算子value ?? 'デフォルト'安全(null時にデフォルト値)
! 強制アンラップvalue!nullならクラッシュ
lib/features/workout/workout_provider.dart L233
// 実コード: フリートレーニング開始
routineId: null,   // ルーティンなし
routineName: 'フリートレーニング',  // 名前はrequired(nullにできない)
lib/features/workout/workout_repository.dart L107
// nullチェックの実例(saveSession内)
if (syncService != null && userId != null) {
  // 同期処理を実行
}
コンパイルエラー vs 実行時エラー(布石): nullable型を ! なしで non-null として使おうとするとコンパイルエラー(ビルドが通らない)。! を付けて実際に null だった場合は実行時エラー(アプリがクラッシュ)。この違いは 1-6 で詳しく訓練する。
なぜ ! は危険? value! は「絶対にnullじゃないはず」と宣言するもの。もしnullだった場合、アプリがクラッシュする。??if チェックのほうが安全。
STEP 3 / 5

実コードのnullableフィールドを読み解く

lib/features/workout/workout_provider.dart L59-68
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
  })
日本語で読む: 1行ずつ判断してみよう
  • String? routineId — フリートレーニングなら「ない」→ nullable
  • required String routineName — どんなセッションでも名前は「ある」→ non-null + required
  • String? editingSessionId — 新規作成なら「ない」→ nullable

ルール: 「ないかもしれない」ものだけ ? を付ける。

ジムの入館カード。会員番号(routineName)は全員持っている。でもパーソナルトレーナーのID(routineId)は付けてない人もいる。
STEP 4 / 5

late — 「後で必ず入れる」宣言

テストコードの実例:

test/features/workout/workout_repository_test.dart L10-25
group('WorkoutRepository', () {
  late AppDatabase db;
  late WorkoutRepository repository;
  late String exerciseId;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    repository = WorkoutRepository(db);
    // ... exerciseId も setUp 内で代入
  });
});
日本語で読む: 「db は AppDatabase型 で、宣言時には初期化しないが、使う前(setUp内で)必ずセットする」
late? (nullable)
意味「後で必ず入れる」「ないかもしれない」
初期化忘れ実行時エラー(LateInitializationError)null として扱える
使い時setUp で初期化するテスト変数本当に「ないかもしれない」データ
late の初期化忘れは実行時エラー。コンパイラは「いつ初期化されるか」を追跡できないので、コンパイルは通ってしまう。実行してみて初めて LateInitializationError で気づく。
なぜテストで late を使う? setUp はテストごとに呼ばれるので、宣言時には値が決まらない。でも各テストの実行時には必ず初期化されている。
STEP 5 / 5

nullで「不可能」を表現する

lib/features/workout/workout_provider.dart L33-34
/// 推定1RM(Epley式)
double? get estimated1rm =>
    weight > 0 && reps > 0 ? weight * (1 + reps / 30) : null;
日本語で読む: 「weight と reps が両方 0 より大きいなら Epley式で計算。そうでなければ 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;
}
日本語で読む: 「getPersonalBest1RM は double? を返す。過去の記録がなければ null、あれば最大の推定1RM値を返す」
なぜ 0 ではなく null を返す? 「0kg の 1RM」と「計算できない」は違う意味。0 を返すと「1RM が 0kg」と誤解される可能性がある。null なら「値が存在しない」と明確に区別できる。

1-2 まとめ

  • String?? = 「null かもしれない」。本当に「ないかもしれない」データにだけ付ける
  • ??if != null で安全に扱う。! はクラッシュの危険あり
  • late = 「後で必ず入れる」。初期化忘れは実行時エラー(コンパイルでは検知されない)
Q1. late AppDatabase db; で、setUp を書き忘れて db を使ったらどうなる?
late は「後で必ず入れる約束」。約束を破ると実行時にクラッシュする。コンパイラは「いつ初期化されるか」を追跡できないので、ビルドは通ってしまう。
Q2. estimated1rm で weight=80, reps=0 のときの戻り値は?
条件は weight > 0 && reps > 0。reps が 0 なので条件は false → null を返す。0回の挙上から1RMは推定できない。
Q3. フリートレーニング開始時、routineId にはどの値が入る?
実コードで routineId: null と明示的に null をセットしている(workout_provider.dart L233)。空文字とnullは違う意味。「ルーティンが存在しない」= null。
TOPIC 1-3

コレクションとジェネリクス

List, Map, Set — データの「入れ物」を使い分ける

0%
STEP 1 / 5

List<T> — 順番付きの入れ物

lib/features/workout/workout_provider.dart L39-46
const factory ActiveExerciseData({
  required String exerciseId,
  required String name,
  required List<SetData> sets,
  @Default([]) List<SetData> previousSets,
  double? personalBest1rm,
})
日本語で読む: 「sets は SetData のリスト(順番あり)。1番目のセット、2番目のセット... と並ぶ」

List<SetData><SetData> がジェネリクス。「この List の中身は SetData だけ」と宣言している。

ジムのトレーニングノート。1セット目、2セット目... と順番に記録する。それが List。
なぜジェネリクスで型を指定する? List だけだと何でも入ってしまう。List<SetData> にすれば、間違えて String を入れようとするとコンパイルエラーで教えてくれる。
STEP 2 / 5

メソッドチェーン: .where(), .map(), .toList()

lib/features/workout/workout_provider.dart L51-54
/// 入力済みセット数
int get filledSetCount => sets.where((s) => s.hasData).length;

/// 1つでも入力されていれば完了扱い
bool get isCompleted => sets.any((s) => s.hasData);
日本語で読む(メソッドチェーン分解):
  1. sets — 全セットのリストを取得
  2. .where((s) => s.hasData) — 入力済みのものだけ残す(フィルタ)
  3. .length — 残った要素の個数を返す

もう一つの実例。前回記録を SetData に変換する処理:

lib/features/workout/workout_provider.dart L197-199
final previousSets = lastSets
    .map((r) => SetData(weight: r.weight, reps: r.reps))
    .toList();
日本語で読む(メソッドチェーン分解):
  1. lastSets — DB から取得した前回セット記録のリスト
  2. .map((r) => SetData(...)) — 各要素 r を SetData に変換
  3. .toList() — 結果を List に確定する
メソッド動作筋トレメモでの例
.where()条件に合うものだけ残す入力済みセットだけ
.map()各要素を変換するDB記録 → SetData
.any()1つでも条件に合えば true1セットでも入力あれば完了
.toList()結果をListに確定するIterable → List 変換
STEP 3 / 5

List.generate — N個の要素を自動生成

lib/features/workout/workout_provider.dart L186
/// 新規セッション開始時のデフォルトセット数
const defaultSetCount = 3;

sets: List.generate(defaultSetCount, (_) => const SetData()),
日本語で読む: 「defaultSetCount(=3)個の SetData() を生成して List にする。(_) は使わない引数(インデックス)」

(_)_ は「引数を使わない」という意味。List.generate は各要素のインデックス(0,1,2)を渡すが、ここでは不要なので _ で無視している。

ジムで記録用紙を配られる場面。「3セット分の空欄」をまとめて作る。1セットずつ手書きで枠を描くより、まとめて生成したほうが効率的。
なぜ List.generate? 手動で [SetData(), SetData(), SetData()] と書くとセット数を変えるとき修正が必要。defaultSetCount を変えるだけで自動対応。
STEP 4 / 5

Map<K, V> — 名前で引ける辞書

シードデータで種目名からIDを引く Map:

lib/database/app_database.dart L71-99
final 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 ...
}
日本語で読む: 「idByName は String型のキーと String型の値を持つ空の Map を作成。ループ内で種目名をキー、UUIDを値としてセットしている」
ListMap
アクセスlist[0] (番号で)map['ベンチプレス'] (名前で)
用途順番が重要なデータキーで素早く検索したいデータ
セットの順番種目名 → ID の対応表
なぜ Map を使う? ルーティンのシードで「ベンチプレスのID」が必要なとき、List から線形探索するより idByName['ベンチプレス'] で一発。
STEP 5 / 5

Set<T> — 重複なしの集合

カレンダーウィジェットの実コード:

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);
日本語で読む(メソッドチェーン分解):
  1. dates — トレーニング実施日の DateTime リスト
  2. .map((d) => d.day) — 各日付から「日」だけ取り出す(1, 5, 12...)
  3. .toSet() — 重複を排除した Set に変換
List<int>Set<int>
重複許可(同じ日が複数入る可能性)自動的に排除
contains の速度O(n) — 全要素を順番にチェックO(1) — 一発で判定
用途順番が必要なデータ「含まれるか」を高速に判定
カレンダーに「トレーニングした日」のドットを表示する。31日分を毎日 List で探すと遅い。Set なら「5日はトレーニングした?」に一瞬で答えられる。
なぜ Set? カレンダーの描画では31日分のセルそれぞれに「この日トレーニングした?」を問い合わせる。List だと最悪31回 x n件のチェック。Set なら31回 x O(1)。

1-3 まとめ

  • List<T> = 順番付き。セットの順序管理に使う
  • Map<K,V> = キーで引く辞書。名前→ID の対応表に使う
  • Set<T> = 重複なし集合。「含まれるか」の高速判定に使う
  • メソッドチェーンは「左から右に1ステップずつ」分解して読む
Q1. sets.where((s) => s.hasData).length は何を返す?
.where() は条件を満たす要素だけ残す。hasData が true のもの = 入力済みのセットだけカウント。
Q2. カレンダーで trainingDays に Set<int> を使う最大の理由は?
31日分のセルそれぞれに「この日トレーニングした?」を問い合わせる。Set なら O(1)。calendar_widget.dart のコメントにも「O(1) ルックアップ」と明記されている。
Q3. lastSets.map((r) => SetData(weight: r.weight, reps: r.reps)).toList() の .toList() を省略するとどうなる?
.map() は Iterable(遅延評価)を返す。List<SetData> を期待するフィールドに代入するには .toList() で List に確定する必要がある。型が合わないとコンパイルエラーになる場合もある。
TOPIC 1-4

関数・メソッド

「何を受け取り → 何をし → 何を返す」の3部読解

0%
STEP 1 / 5

関数シグネチャを読む — 3部読解

lib/features/workout/workout_provider.dart L30
bool get hasData => weight > 0 || reps > 0;

関数を読むときは常にこの3つを確認する:

  1. 何を受け取る? → 引数なし(getter なので自身の weight, reps を使う)
  2. 何をする? → weight か reps が 0 より大きいか判定
  3. 何を返す?bool(true / false)
シグネチャ読解: 「bool get hasData」→ 戻り値は bool、名前は hasData、引数なし(getter)
料理のレシピ: 材料(引数)→ 手順(処理)→ 完成品(戻り値)。getter は「冷蔵庫の中身(自分のフィールド)だけで作れる料理」。
STEP 2 / 5

名前付き引数 vs 位置引数

lib/features/workout/workout_provider.dart L21-25
// 名前付き引数(筋トレメモで使用)
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 なら間違えようがない。
STEP 3 / 5

アロー関数 => vs ブロック {}

lib/features/workout/workout_provider.dart L30, L83-91
// アロー関数(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 を明示
用途シンプルな計算・判定ループ・条件分岐が複雑なとき
STEP 4 / 5

void — 何も返さないメソッド

lib/features/workout/workout_provider.dart L276-279
void goToExercise(int index) {
  if (!_isValidExerciseIndex(index)) return;
  state = state!.copyWith(currentExerciseIndex: index);
}
シグネチャ読解(3部):
  1. 受け取る: int index(移動先の種目番号)
  2. する: state の currentExerciseIndex を更新
  3. 返す: void(何も返さない。状態を変えるだけ)
lib/features/workout/workout_provider.dart L282-285
void nextExercise() {
  if (state == null || state!.isLastExercise) return;
  goToExercise(state!.currentExerciseIndex + 1);
}
シグネチャ読解: 「nextExercise は引数なし、戻り値なし(void)。現在のインデックス+1 で goToExercise を呼ぶ」
なぜ void? このメソッドの目的は「状態を変更すること」。計算結果を返す必要がないので void。
STEP 5 / 5

Future<void> — asyncメソッドのシグネチャ

lib/features/workout/workout_repository.dart L19-23
Future<void> saveSession(
  ActiveWorkoutState recordState, {
  String? userId,
  SyncService? syncService,
}) async {
  // DB保存処理...
}
シグネチャ読解(3部):
  1. 受け取る: ActiveWorkoutState(必須の位置引数)+ userId, syncService(任意の名前付き引数、どちらもnullable)
  2. する: DB にセッションデータを保存(時間がかかる非同期処理)
  3. 返す: Future<void>(「いつか完了する約束」で、完了しても値は返さない)
ジムで「記録用紙をロッカーにしまっておいて」と頼む場面。頼んだ瞬間は完了していない(Future)。しまい終わっても何も返ってこない(void)。

次のトピック 1-5 で async/await を詳しく学ぶ。

1-4 まとめ

  • 関数は「受け取る → する → 返す」の3部で読む
  • 名前付き引数 {} で可読性を確保。=> はシンプルな1式、{} は複雑な処理に
  • void = 戻り値なし。Future<void> = 非同期で戻り値なし
Q1. void goToExercise(int index) のシグネチャを3部読解すると?
void は「戻り値がない」。引数は int index で種目のインデックス番号。状態を変更するだけで、呼び出し元に値を返さない。
Q2. double? get estimated1rm => ... をブロック構文で書き直すと?
ブロック構文では return を明示する必要がある。Aは return がないし条件分岐もない。Cは getter ではなくメソッドになっている(get がない)。
Q3. Future<void> saveSession(ActiveWorkoutState recordState, {String? userId}) async で、userId を省略して呼ぶとどうなる?
String? の名前付き引数はデフォルトで null。省略しても呼び出しは成功する。メソッド内で if (userId != null) で分岐している。
TOPIC 1-5

非同期処理

Future, async/await, Stream — 時間がかかる処理を扱う

0%
STEP 1 / 6

同期 vs 非同期

レストランのアナロジー:
同期 = カウンターで注文して料理が出てくるまで立って待つ。他の客は後ろに並ぶ。
非同期 = 番号札をもらって席で待つ。出来上がったら呼ばれる。その間に他の注文も進む。

DB読み書き、ネットワーク通信は時間がかかる。同期的に待つとアプリが固まる(フリーズ)。

同期非同期
処理中のUIフリーズスムーズに動く
Dartの表現StringFuture<String>
「結果」すぐ手に入る「いつか届く約束」
STEP 2 / 6

Future — 「いつか届く約束」

lib/features/workout/workout_repository.dart L138-163
Future<List<WorkoutSet>> getLastSessionSets(String exerciseId) async {
  // DB問い合わせ(時間がかかる)
  final latestQuery = _db.select(...);
  final latestRows = await latestQuery.get();
  // ...
  return setsQuery.get();
}
シグネチャ読解(3部):
  1. 受け取る: String exerciseId(検索する種目のID)
  2. する: DBから直近のセットデータを検索(時間かかる)
  3. 返す: Future<List<WorkoutSet>>(「WorkoutSet のリストがいつか届く約束」)
ジムの受付に「前回のベンチプレスの記録を調べて」と頼む。すぐには出てこない(Future)。調べ終わったらセット記録のリストが届く(List<WorkoutSet>)。
STEP 3 / 6

async/await — 届くまで待つ

lib/features/workout/workout_provider.dart L173-207
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 を付けないと、まだ届いていない「約束」を使おうとしてエラーになる。

なぜ await が必要? DB問い合わせの結果がまだ届いていないのに次の処理を進めると、データが空のまま画面を表示してしまう。await で「届いてから次へ」を保証する。
STEP 4 / 6

startSession の処理順序を追跡する

セッション開始の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);

処理の流れ:

  1. 段階1: 空のセットで画面をすぐ表示(ユーザーを待たせない)
  2. 段階2: DBから前回記録を取得(await で完了を待つ)
  3. 段階3: 取得した前回記録で state を更新 → 画面に前回記録が表示される
なぜ2段階に分ける? DB問い合わせに時間がかかるので、まず空の画面を表示してからデータを取得する。ユーザー体験の向上(「最速記録」のコンセプト)。
STEP 5 / 6

Stream — 何度も届くデータ

lib/features/history/history_repository.dart L20-24
/// 全ワークアウトをリアルタイムで監視(DB変更を自動検知)
Stream<List<Workout>> watchAllSessions() {
  return (_db.select(
    _db.workouts,
  )..orderBy([(t) => OrderingTerm.desc(t.date)])).watch();
}
FutureStream
届く回数1回だけ何度でも(変更のたびに)
用途1回のDB問い合わせリアルタイム監視
getLastSessionSets()watchAllSessions()
アナロジー郵便(1通届いて終わり)ニュース速報(更新のたびに通知)
日本語で読む: 「watchAllSessions は Workout のリストが Stream で届く。新しいセッションが保存されるたびに、自動的に最新リストが届く」

比較: getAllSessions は Future(1回だけ):

lib/features/history/history_repository.dart L13-16
Future<List<Workout>> getAllSessions() {
  return (_db.select(_db.workouts)
    ..orderBy([(t) => OrderingTerm.desc(t.date)])).get();
}
なぜ Stream? 履歴画面を開いているときに新しいトレーニングを保存したら、画面が自動更新されてほしい。Future だと手動でリロードが必要。
STEP 6 / 6

初期化順序の罠

lib/database/app_database.dart L44-47
onCreate: (Migrator m) async {
  await m.createAll();
  final exerciseIdByName = await _seedExercises();
  await _seedRoutines(exerciseIdByName);
},
日本語で読む: 「まず全テーブルを作成(createAll)。次に種目データを投入(_seedExercises)。その戻り値(種目名→IDのMap)を使ってルーティンを投入(_seedRoutines)」

この順序を逆にすると?

// NG: ルーティンを先に作ろうとする
await _seedRoutines(exerciseIdByName);  // exerciseIdByNameが未定義!
final exerciseIdByName = await _seedExercises();

ルーティンは「ベンチプレスのID」を参照する。種目がまだDBにないのにルーティンを作ろうとすると失敗する。

なぜ順序が重要? _seedRoutines は _seedExercises の戻り値(exerciseIdByName)を使う。await で「完了を待ってから次へ」にすることで、依存関係を正しく処理している。非同期処理では「何が何に依存するか」を常に意識する。
筋トレのスーパーセット。「ベンチプレス → ケーブルフライ」の順番が大事。先にケーブルフライ用のマシンを予約しても、ベンチプレスで胸を追い込んでないと意味がない。データも同じ。

1-5 まとめ

  • Future<T> = 1回届く約束。async/await で結果を待つ
  • Stream<T> = 何度も届くデータ。リアルタイム監視に使う
  • 非同期処理では初期化順序(依存関係)に注意。await で順序を保証する
Q1. Future<List<WorkoutSet>> を日本語で説明すると?
Future は「いつか1回届く約束」。Stream が「何度も届く」。Future<List<WorkoutSet>> は「WorkoutSet のリストが1回届く」。
Q2. _seedExercises と _seedRoutines の実行順序を逆にするとどうなる?
_seedRoutines は exerciseIdByName を引数に取る。_seedExercises を先に実行しないとこの変数が存在しない。宣言前に変数を使おうとするとコンパイルエラーになる。
Q3. watchAllSessions() が Future ではなく Stream を返す理由は?
Stream は「データが変わるたびに新しい値を流す」。新しいトレーニングが保存されると、履歴画面が自動で更新される。Future は1回きりなので手動リロードが必要。
TOPIC 1-6

enumとswitch

コンパイルエラーで守るか、実行時エラーに賭けるか

0%
STEP 1 / 5

コンパイルエラー vs 実行時エラー

まず、2種類のエラーの違いを確認しよう:

コンパイルエラー実行時エラー
いつ発覚?ビルド時(コード実行前)アプリ実行中(ユーザーの手元で)
影響ビルドが通らないだけアプリがクラッシュ
修正コスト低い(開発者がすぐ気づく)高い(ユーザーが被害を受ける可能性)
型の不一致、case漏れnull参照、late未初期化
原則: エラーは「早く」見つけるほど良い。コンパイルエラーは最も早い段階でバグを教えてくれる「親切な門番」。実行時エラーは「お客さんの前で失敗する」パターン。
ジムの安全チェック。入館時に「靴を履いているか?」チェック(コンパイルエラー相当)で止められるのと、トレーニング中にダンベルを落とす(実行時エラー相当)では、被害が全然違う。

このトピックでは、enum と switch が「コンパイルエラーで守る」仕組みをどう作るかを学ぶ。

STEP 2 / 5

enum — 選択肢を型で定義する

筋トレメモの種目には「部位」がある。もし enum で定義するなら:

enum BodyPart {
  chest,    // 胸
  back,     // 背中
  legs,     // 脚
  shoulders,// 肩
  arms,     // 腕
  abs,      // 腹
  other,    // その他
}
日本語で読む: 「BodyPart は chest, back, legs, shoulders, arms, abs, other のいずれか。これ以外の値は存在できない」

しかし現在の筋トレメモでは String を使っている:

lib/database/tables/exercises.dart L9-12
class 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()();
}
ジムのマシンエリア分け。「胸エリア」「背中エリア」「脚エリア」... と決まった区画しかない。「お菓子エリア」は存在しない。enum も同じで、定義した値以外は使えない。
なぜ enum が安全? 部位を String で管理すると "胸" "むね" "Chest" などの表記揺れが起きる。enum なら BodyPart.chest しか書けない。
STEP 3 / 5

switch + enum — 書き忘れはコンパイルエラー

switch (bodyPart) {
  case BodyPart.chest:
    return '胸のトレーニング';
  case BodyPart.back:
    return '背中のトレーニング';
  case BodyPart.legs:
    return '脚のトレーニング';
  // shoulders, arms, abs, other を書き忘れると...
  // → コンパイルエラー! ビルドが通らない!
}

Dart の switch は enum の全 case を網羅しないとコンパイルエラーになる。これが exhaustive check(網羅性チェック)

これはコンパイルエラー。ビルドボタンを押した瞬間に「shoulders が足りません」と教えてくれる。ユーザーのスマホでは決して発生しない。

将来 enum に cardio(有酸素)を追加したら?

enum BodyPart {
  chest, back, legs, shoulders, arms, abs, other,
  cardio,  // 新規追加!
}
// → 全ての switch 文で「cardio の case がない」とコンパイルエラー!
// → 修正漏れがゼロになる
ジムの安全チェックリスト。「ピンの確認」「重量の確認」「フォームの確認」。1つでもチェック漏れがあると使用禁止(コンパイルエラー)。項目を追加したら、全員のチェックリストが自動更新される。
STEP 4 / 5

switch + String — typoは素通り

シードデータの実例:

lib/database/app_database.dart L76-86
final 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 + switchString + switch
case 漏れコンパイルエラー(ビルド前に検知)default に落ちるだけ(実行時に発覚)
typoコンパイルエラー(BodyPart.chset は存在しない)気づかない("chset" も有効な String)
新しい値の追加全 switch の修正漏れをコンパイラが指摘修正漏れに気づかない
なぜ筋トレメモは String を使っている? DB(drift)との連携では文字列のほうが扱いやすい場面がある。将来的に enum に移行する余地はある。現状のトレードオフとして理解しておく。
STEP 5 / 5

Dart 3 のパターンマッチングNEW

シードデータでタプル(組)の分解が使われている:

lib/database/app_database.dart L86
final seeds = [
  ('ベンチプレス', '胸', 'weight_reps'),
  ('ラットプルダウン', '背中', 'weight_reps'),
  // ...
];
for (final (name, bodyPart, recordType) in seeds) {
  // name = 'ベンチプレス', bodyPart = '胸', recordType = 'weight_reps'
}
日本語で読む: 「seeds の各要素を (name, bodyPart, recordType) の3つに分解して for ループで処理」
// パターンマッチングなし(従来の書き方)
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-113
final routineSeeds = [
  ('胸の日', '#EF4444', ['ベンチプレス', 'インクラインダンベルプレス', 'ケーブルフライ']),
  // ...
];
for (var ri = 0; ri < routineSeeds.length; ri++) {
  final (name, color, exerciseNames) = routineSeeds[ri];
  // ...
}

1-6 まとめ

  • enum = 選択肢を型で定義。typo や case 漏れをコンパイルエラーで防ぐ
  • String の switch は default に逃げるだけ。バグが実行時まで隠れる
  • コンパイルエラー > 実行時エラー: 早く見つけるほどダメージが小さい
  • Dart 3 のパターンマッチングでタプルを簡潔に分解できる
Q1. enum の switch で case を1つ書き忘れるとどうなる?
Dart の switch は enum の全ケースを網羅する必要がある(exhaustive check)。1つでも漏れるとコンパイルが通らない。ビルド前にバグを発見できる。
Q2. recordType が String で 'weght_reps'(typo)がシードに入ったらどうなる?
String は任意の文字列を受け入れるので、typo があってもコンパイルエラーにならない。switch 文で default に落ちて「不明な記録タイプ」と表示される。enum ならこの typo はコンパイルエラーで即座に検知される。
Q3. 以下のうち「コンパイルエラー」はどれ?(複数ある場合は最も典型的なもの)
Bはコンパイルエラー(ビルド時に検知)。Aは実行時エラー(LateInitializationError)。Cも実行時エラー(Null check operator used on a null value)。1-2で学んだ late と ! の危険性を思い出そう。
Q4. for (final (name, bodyPart, recordType) in seeds) の書き方は何と呼ばれる?
Dart 3 で導入されたパターンマッチング。タプルの各要素を変数に分解して取り出す。seed.$1 と書くより意図が明確。

Lv.1 全トピック完了!

Dart の基本構文を筋トレメモのコードで学びました。

  • class とフィールドでデータを設計
  • null safety で「ないかもしれない」を型で表現
  • List / Map / Set でデータを管理
  • 関数の3部読解(受け取る → する → 返す)
  • Future / Stream で非同期処理
  • enum / switch でコンパイル時に安全を保証

学習した実コードの場所:
workout_provider.dart / workout_repository.dart / history_repository.dart / app_database.dart / calendar_widget.dart / exercises.dart / workout_repository_test.dart