MultiTimer — Clean Architecture + Drift で複数タイマーを実装
| 開発記録 | MultiTimer
タグ: #Flutter #Clean Architecture #Drift
「料理用のタイマーを複数同時に動かしたい」というニーズに応えるアプリを、Clean Architecture でレイヤー分離して実装しました。Domain / Data / Presentation の整理を共有します。
複数タイマーが必要な理由
料理中、煮込みと焼きが並行する場面はよくあります。15 分タイマーと 5 分タイマーを同時に動かしたいのに、標準のタイマーは 1 つしか動かない。
MultiTimer は、複数のタイマーを名前付きで同時に管理できるアプリです。
レイヤー構成
Clean Architecture に従って、3 層に分けました。
- Domain: Entity(Timer, TimerPreset)、Repository インターフェース、UseCase
- Data: Drift(SQLite)による永続化、Repository 実装
- Presentation: UI Widget、Page、Provider
コミット履歴を見ると、feat(domain) → feat(data) → feat(presentation) の順で積み上げています。下から積むのが Clean Architecture の基本です。
Domain 層
class Timer {
final String id;
final String name;
final Duration duration;
final DateTime? startedAt;
final TimerStatus status;
}
UseCase は StartTimerUseCase, StopTimerUseCase, ResetTimerUseCase などのシングルアクションで切り出しました。テストを書くのが楽になります。
Domain エンティティは Drift から完全に独立させ、永続化型 (TimerEntity) との変換は Repository 実装の中で完結させます。これでテスト時に Drift をモックしなくて済みます。
class StartTimerUseCase {
StartTimerUseCase(this._repo, this._clock);
final TimerRepository _repo;
final Clock _clock;
Future<Timer> call(String id) async {
final current = await _repo.findById(id);
if (current == null) {
throw StateError('Timer not found: $id');
}
final updated = current.copyWith(
status: TimerStatus.running,
startedAt: _clock.now(),
);
await _repo.save(updated);
return updated;
}
}
Data 層: Drift
Drift で SQLite に永続化しています。アプリを終了してもタイマーの状態が残り、再起動時に「あと 3 分残っていたタイマー」もそのまま続行できます。
@DataClassName('TimerEntity')
class Timers extends Table {
TextColumn get id => text()();
TextColumn get name => text()();
IntColumn get durationMs => integer()();
DateTimeColumn get startedAt => dateTime().nullable()();
}
Presentation 層
タイマー一覧と詳細を分け、一覧では各タイマーの残り時間をリアルタイム表示します。Provider で状態管理し、1 秒ごとに残り時間を再計算する設計です。
バックグラウンドと通知
アプリがバックグラウンドにいる時もタイマーを正確に管理するため、flutter_local_notifications で通知をスケジュールしています。タイマー満了時に通知が飛びます。
教訓
- 並列処理が肝のアプリは、永続化を早めに入れる
- Clean Architecture はレイヤー名にこだわらず、依存方向だけ守る
- Drift は SQL を意識しすぎず宣言的に書けるのが楽
シンプルなアプリほど、設計を真面目にやると後が楽になります。