PR

FlutterでHIITタイマーアプリを個人開発して学んだこと——CustomPainterとRiverpod StateNotifierで作る「状態機械×アニメーション」

Flutter

はじめに

筆者はRPG風フィットネスアプリ「MUSCLE QUEST」をFlutterで個人開発し、Google Playにリリースしました。このアプリのコア機能はHIITタイマーです。ワーク・レスト・セット間レストを繰り返す複雑なタイマーを、外部の専用パッケージに頼らず自前で実装しました。

今回はその中から技術的に面白かった2点を解説します。

  1. Riverpod StateNotifier で多段階タイマーの状態機械を設計する
  2. 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外側にぼかしが広がる
sigma8ぼかし半径(値が大きいほど広く)
strokeWidth16(本体の1.6倍)ぼかしを目立たせるため太めに
opacity0.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」をリリースしました

コメント