はじめに
筆者はRPG風フィットネスアプリ「MUSCLE QUEST」をFlutterで個人開発し、Google Playにリリースしました。このアプリのコア機能はHIITタイマーです。ワーク・レスト・セット間レストを繰り返す複雑なタイマーを、外部の専用パッケージに頼らず自前で実装しました。
今回はその中から技術的に面白かった2点を解説します。
- Riverpod
StateNotifierで多段階タイマーの状態機械を設計する CustomPainterで円形タイマーとグロー効果を自作する
使用技術はFlutter 3.24、flutter_riverpod 2.6.1、audioplayers 6.4です。
→ MUSCLE QUESTの詳細はこちら:RPG×HIITフィットネスアプリ「MUSCLE QUEST」をリリースしました
1. HIITタイマーを「状態機械」として設計する
1-1. なぜ状態機械か
HIITタイマーが持つ状態は思ったより多いです。
- ready:初期状態(未開始)
- countdown:カウントダウン(3・2・1)
- work:ワーク中
- rest:ラウンド間レスト
- setRest:セット間レスト
- completed:セッション完了
これを bool isWorking や bool isResting のようなフラグで管理すると、すぐに「ワーク中でもレスト中でもない謎の状態」が発生します。状態を直交した列挙型として定義することでこの問題を防ぎます。
dart
enum TimerPhase { ready, countdown, work, rest, setRest, completed }
状態全体は TimerState というイミュータブルなデータクラスに集約します。
dart
class TimerState {
final TimerPhase phase;
final int secondsRemaining;
final int currentRound;
final int totalRounds;
final int currentSet;
final int totalSets;
final int totalWorkTime;
final String? currentExercise;
final bool isPaused;
final int workSeconds;
final int restSeconds;
final int setRestSeconds;
final List<String> exercises;
const TimerState({ /* 省略 */ });
TimerState copyWith({ /* 省略 */ });
}
copyWith パターンを採用しているため、変更したいフィールドだけ指定して新しい状態を生成できます。RiverpodのStateNotifierはイミュータブルな状態変更を前提としているので、このパターンとの相性は抜群です。
1-2. 進捗率の計算をStateに持たせる
CircularTimerに渡す「進捗率(0.0〜1.0)」は、フェーズごとに基準となる秒数が異なります。これをUIで計算するとロジックが分散するため、TimerState のgetterとして定義しました。
dart
double get progress {
switch (phase) {
case TimerPhase.work:
return workSeconds > 0 ? 1.0 - (secondsRemaining / workSeconds) : 0;
case TimerPhase.rest:
return restSeconds > 0 ? 1.0 - (secondsRemaining / restSeconds) : 0;
case TimerPhase.setRest:
return setRestSeconds > 0 ? 1.0 - (secondsRemaining / setRestSeconds) : 0;
case TimerPhase.completed:
return 1.0;
default:
return 0;
}
}
UIは timerState.progress を参照するだけでよく、フェーズを意識する必要がありません。
1-3. StateNotifierで状態遷移を実装する
dart
final timerProvider =
StateNotifierProvider.autoDispose<TimerNotifier, TimerState>((ref) {
return TimerNotifier();
});
class TimerNotifier extends StateNotifier<TimerState> {
Timer? _timer;
final AudioPlayer _audioPlayer = AudioPlayer();
bool _soundEnabled = true;
TimerNotifier() : super(const TimerState());
void start() {
state = state.copyWith(
phase: TimerPhase.countdown,
secondsRemaining: 3,
);
_startTicking();
}
void _startTicking() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
}
void _tick() {
if (state.isPaused) return;
if (state.secondsRemaining > 1) {
final newRemaining = state.secondsRemaining - 1;
if ((state.phase == TimerPhase.rest || state.phase == TimerPhase.setRest)
&& newRemaining == 3) {
_playSound('work_start.mp3');
}
state = state.copyWith(secondsRemaining: newRemaining);
return;
}
switch (state.phase) {
case TimerPhase.countdown:
_transitionToWork();
case TimerPhase.work:
if (state.currentRound >= state.totalRounds) {
if (state.currentSet >= state.totalSets) {
_playSound('session_complete.mp3');
state = state.copyWith(phase: TimerPhase.completed);
} else {
_playSound('rest_start.mp3');
state = state.copyWith(
phase: TimerPhase.setRest,
secondsRemaining: state.setRestSeconds,
);
}
} else {
_playSound('rest_start.mp3');
state = state.copyWith(
phase: TimerPhase.rest,
secondsRemaining: state.restSeconds,
);
}
case TimerPhase.rest:
final nextRound = state.currentRound + 1;
state = state.copyWith(
phase: TimerPhase.work,
secondsRemaining: state.workSeconds,
currentRound: nextRound,
currentExercise: state.getExerciseForRound(nextRound),
);
case TimerPhase.setRest:
final nextSet = state.currentSet + 1;
state = state.copyWith(
phase: TimerPhase.work,
secondsRemaining: state.workSeconds,
currentRound: 1,
currentSet: nextSet,
);
default:
_timer?.cancel();
}
}
@override
void dispose() {
_timer?.cancel();
_audioPlayer.dispose();
super.dispose();
}
}
ポイント①:レスト終了3秒前の予告音
dart
if ((state.phase == TimerPhase.rest || state.phase == TimerPhase.setRest)
&& newRemaining == 3) {
_playSound('work_start.mp3');
}
ユーザーがレスト中にスマホから目を離していても、残り3秒で予告音が鳴るため次のワークに備えられます。遷移直後にwork_startを鳴らすと予告音と重複するため、遷移時の再生は削除してこちらに一本化しました。
ポイント②:autoDispose
StateNotifierProvider.autoDispose を使うことで、タイマー画面を離れた際に _timer と _audioPlayer が確実に dispose() されます。音声リソースの解放漏れを防ぐうえで重要です。
1-4. UIとの統合:フェーズに応じた背景色トランジション
dart
final bgColor = switch (timerState.phase) {
TimerPhase.work => AppColors.workPhase.withOpacity(0.15),
TimerPhase.rest || TimerPhase.setRest => AppColors.restPhase.withOpacity(0.15),
_ => AppColors.background,
};
return AnimatedContainer(
duration: const Duration(milliseconds: 500),
color: bgColor,
child: Scaffold( /* ... */ ),
);
AnimatedContainer の color プロパティを変えるだけで、ワーク中は赤みがかった背景、レスト中は青みがかった背景に500msかけてアニメーションします。追加のアニメーションコードなしでフェーズ変化がユーザーに視覚的に伝わります。
2. CustomPainterで円形タイマーを自作する
2-1. なぜパッケージを使わないか
percent_indicator や circular_chart といったパッケージも検討しましたが、以下の理由から CustomPainter での自作を選びました。
- グロー効果(発光する縁取り)が欲しかった
- 進捗色をフェーズごとに動的に変えたかった
- 依存パッケージを増やしたくなかった
実装してみると思ったよりシンプルでした。
2-2. ウィジェットの構成
dart
class CircularTimer extends StatelessWidget {
final double progress;
final int secondsRemaining;
final Color progressColor;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 220,
height: 220,
child: CustomPaint(
painter: _CircularTimerPainter(
progress: progress,
progressColor: progressColor,
),
child: Center(
child: Text(
_formatTime(secondsRemaining),
style: const TextStyle(
fontSize: 56,
fontWeight: FontWeight.bold,
fontFamily: 'monospace',
),
),
),
),
);
}
String _formatTime(int seconds) {
if (seconds >= 60) {
final min = seconds ~/ 60;
final sec = seconds % 60;
return '$min:${sec.toString().padLeft(2, '0')}';
}
return seconds.toString().padLeft(2, '0');
}
}
CustomPaint の child に Text を渡すことで、円の中央に秒数テキストを重ねています。SizedBox で固定サイズを与えれば CustomPainter の size 引数が確定するため計算が楽です。
2-3. CustomPainterの実装
dart
class _CircularTimerPainter extends CustomPainter {
final double progress;
final Color progressColor;
_CircularTimerPainter({required this.progress, required this.progressColor});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 12;
// ① 背景の円(グレー)
final bgPaint = Paint()
..color = Colors.white12
..style = PaintingStyle.stroke
..strokeWidth = 10;
canvas.drawCircle(center, radius, bgPaint);
// ② 進捗の円弧
final progressPaint = Paint()
..color = progressColor
..style = PaintingStyle.stroke
..strokeWidth = 10
..strokeCap = StrokeCap.round;
final sweepAngle = 2 * pi * progress;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2,
sweepAngle,
false,
progressPaint,
);
// ③ グロー効果(ぼかしたレイヤーを重ねる)
final glowPaint = Paint()
..color = progressColor.withOpacity(0.3)
..style = PaintingStyle.stroke
..strokeWidth = 16
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2,
sweepAngle,
false,
glowPaint,
);
}
@override
bool shouldRepaint(covariant _CircularTimerPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.progressColor != progressColor;
}
}
ポイント①:12時位置から開始する
drawArc の開始角度デフォルトは3時の位置(右)です。タイマーは12時から始めるのが自然なため、-pi / 2(真上)を指定します。
ポイント②:StrokeCap.round
strokeCap = StrokeCap.round を指定すると、円弧の先端が丸くなります。これだけでタイマーの見た目が大幅に洗練されます。デフォルトは StrokeCap.butt(直角に切り落とし)です。
ポイント③:MaskFilter.blur でグロー効果
これが最大の差別化ポイントです。
dart
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8)
MaskFilter.blur は描画レイヤーにブラーをかけます。これを実際の円弧より太い・半透明のレイヤーとして同じパスに重ね描きすることで、発光しているような「グロー」効果を実現できます。
| プロパティ | 値 | 説明 |
|---|---|---|
| BlurStyle.normal | – | 外側にぼかしが広がる |
| sigma | 8 | ぼかし半径(値が大きいほど広く) |
| strokeWidth | 16(本体の1.6倍) | ぼかしを目立たせるため太めに |
| opacity | 0.3 | 透かして重ねることでグロー感を演出 |
ポイント④:shouldRepaintの最適化
dart
@override
bool shouldRepaint(covariant _CircularTimerPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.progressColor != progressColor;
}
shouldRepaint が true を返すときだけ再描画が走ります。progress と progressColor が変わっていなければ再描画をスキップするため、毎フレーム無駄に描画しません。1秒ごとにしか更新されないタイマーでは特に効果があります。
3. 全体アーキテクチャの振り返り
実装を終えて、設計上よかった点を整理します。
状態とUIを完全に分離できた
TimerState と TimerNotifier はFlutterのウィジェットに一切依存しません。純粋なDartクラスなので単体テストが書けます。UIは ref.watch(timerProvider) で状態を受け取り、表示するだけです。
autoDisposeでリソース管理を自動化できた
タイマー画面を離れると Timer.cancel() と AudioPlayer.dispose() が自動で呼ばれます。「音が止まらない」「バックグラウンドでタイマーが動き続ける」といったバグを防ぐ仕組みが autoDispose 一つで成立しています。
音の設計が体験を大きく変えた
最初は「遷移時に音を鳴らす」設計でしたが、「レスト終了3秒前に予告音を鳴らす」に変更したところ、スマホから目を離していてもワーク再開を予期できてUXが大幅に向上しました。ロジック変更は StateNotifier の _tick() 内を5行修正するだけで済みました。
まとめ
| 課題 | 解決手法 |
|---|---|
| 複雑なタイマー状態の管理 | enum + StateNotifier で状態機械として設計 |
| 進捗率のフェーズ依存計算 | TimerState のgetterに閉じ込め |
| 円形タイマーのグロー効果 | CustomPainter + MaskFilter.blur の重ね描き |
| リソースの自動解放 | autoDispose + dispose() |
| フェーズ変化の視覚的フィードバック | AnimatedContainer の color アニメーション |
FlutterのCustomPainterとRiverpodを組み合わせると、パッケージ依存を増やさずに凝ったUIを作れることが実感できました。特に MaskFilter.blur によるグロー効果は数行で実装できる割に見た目へのインパクトが大きく、ぜひ試してみてください。
アプリはGoogle Playで「MUSCLE QUEST」として公開中です。ぜひダウンロードして実際の動作を確認してみてください。
最後まで読んでいただきありがとうございました。
→ MUSCLE QUESTの詳細はこちら:RPG×HIITフィットネスアプリ「MUSCLE QUEST」をリリースしました



コメント