Flutterで使って【激ムズ】3秒ジャストで止めるストップウォッチアプリを作ってみた

Flutter
 

Flutterのストップウォッチクラス(Stopwatch)を使って激ムズな3秒ジャストで止めるストップウォッチアプリを作成してみます。

何が激ムズなのかというと、マイクロ秒まで3秒ジャストで止めるとクリアだからです!

難しすぎるのでクリアするための隠しコマンドを仕込んでます。

アプリの構造自体は簡単なので、「初学者だけど勉強がてら、簡単なアプリを作ってみたい!」方にもオススメです。

Stopwatchクラスとは

Flutterのストップウォッチクラス(Stopwatch)は、時間の計測やタイマー機能を簡単に実装できる便利なクラスです。

このクラスを使用することで、アプリケーションで経過時間を正確に測定したり、一定時間ごとにタスクを実行したりすることが可能になります。

Stopwatchクラスは、Flutterフレームワークに組み込まれているため、追加のパッケージのインストールは不要です。

タイマーやアニメーション、パフォーマンス測定など、さまざまな用途に活用することができます。

Stopwatchクラスの主な機能には以下が含まれます:

  1. start()メソッド:ストップウォッチの計測を開始します。
  2. stop()メソッド:計測を停止します。start()から呼び出された時間を計測します。
  3. reset()メソッド:ストップウォッチの値をリセットし、計測を停止します。
  4. elapsedプロパティ:現在の経過時間を取得します。
  5. isRunningプロパティ:ストップウォッチが実行中かどうかを示す真偽値を取得します。

どんなアプリなのか?どこが激ムズなのか?

ジャスト3秒で止めるストップウォッチのアプリです。

ただのストップウォッチではなく、ミリ秒・マイクロ秒まで表示されたストップウォッチです!

ミリ秒ですら難しいのにマイクロ秒までを3秒ジャストで止めるなんて正直無理だと思います。

そのためになんと裏コマンドを用意しています。

それを使えば誰でも3秒ジャストで揃えられます。それは後で紹介します

実装方法

  • Stopwatchクラスでストップウォッチの秒数を表示
  • modelとviewにロジックを分離
  • スタートボタンを実装
  • ストップ/リセットボタンを実装
  • チートボタンを作って勝手にジャスト3秒で止まるようにする

Stopwatchクラスでストップウォッチの秒数を表示

ストップウォッチクラスを使って、ストップウォッチを表示するようにします。

ストップウォッチインスタンスを定義し、それらの値をウィジェットロード時に実行可能なinitStateでelaspedにより取得します。

import 'package:flutter/material.dart';

void main() {
  runApp(
    MaterialApp(
      home: const MyApp(),
    ),
  );
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  Stopwatch _stopwatch = Stopwatch();
  String seconds = "00";
  String milliseconds = "000";
  String microseconds = "000";

  void _refresh() {
    String str2Digits(int n) => n.toString().padLeft(2, "0");
    String str3Digits(int n) => n.toString().padLeft(2, "0");
    seconds = str2Digits(_stopwatch.elapsed.inSeconds.remainder(60));
    milliseconds = str3Digits(_stopwatch.elapsed.inSeconds.remainder(60));
    microseconds = str3Digits(_stopwatch.elapsed.inSeconds.remainder(60));
  }

  @override
  void initState() {
    super.initState();
    _refresh();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Text(seconds + "." + milliseconds + "." + microseconds),
      ),
    );
  }
}

modelとviewにロジックを分離

statefulwidgetを使うとストップウォッチのロジック部分(例えば、スタート・ストップなど)
とストップウォッチの秒数やボタンを表示するビューのコードが一つのクラスの中で記述されることで
責任が複数の見にくいコードとなってしまいます。

そこでflutter_riverpodを使ってロジックを分離していきましょう

flutter_riverpodのインストールが必要です

flutter pub add flutter_riverpod

モデル側でストップウォッチの情報を保持し、ビュー側にはプロバイダーにより渡すことができます

model.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final stopwatchProvider = ChangeNotifierProvider((ref) => StopwatchModel());

class StopwatchModel extends ChangeNotifier {
  Stopwatch _stopwatch = Stopwatch();
  String seconds = "00";
  String milliseconds = "000";
  String microseconds = "000";

  void _refresh() {
    String str2Digits(int n) => n.toString().padLeft(2, "0");
    String str3Digits(int n) => n.toString().padLeft(2, "0");
    seconds = str2Digits(_stopwatch.elapsed.inSeconds.remainder(60));
    milliseconds = str3Digits(_stopwatch.elapsed.inSeconds.remainder(60));
    microseconds = str3Digits(_stopwatch.elapsed.inSeconds.remainder(60));
  }

  StopwatchModel() {
    _refresh();
    notifyListeners();
  }
}

view.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tmp_blog/home/model.dart';

class Home extends ConsumerWidget {
  const Home({
    super.key,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final StopwatchModel stopwatchModel = ref.watch(stopwatchProvider);

    return Scaffold(
      body: SafeArea(
        child: Text(stopwatchModel.seconds +
            "." +
            stopwatchModel.milliseconds +
            "." +
            stopwatchModel.microseconds),
      ),
    );
  }
}

mainクラスがHomeクラスを呼び出すだけになります

main.dart(一部)

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return Home();
  }
}

スタートボタンを実装

ストップウォッチの秒数をスタートさせるためのボタンを実装します。

ストップウォッチインスタンスのstartメソッドを使うことでインスタンス内の秒数のフィールドが更新されていきます。

それをperiodicによりミリ秒ごとにrefreshで画面の秒数を更新することで、
インスタンス内の秒数を画面に表示するようにします

model.dart(一部)

void start() {
  _stopwatch.start();
  Timer.periodic(const Duration(milliseconds: 1), (_) {
    _refresh();
  });
}

ビュー側ではモデルに定義されたスタートメソッドをボタン押下時の処理として呼び出します

view.dart(一部)

child: Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: [
    Text(
      stopwatchModel.seconds +
          "." +
          stopwatchModel.milliseconds +
          "." +
          stopwatchModel.microseconds,
      style: TextStyle(
        fontSize: 56,
        fontFeatures: [FontFeature.tabularFigures()],
      ),
    ),
    ElevatedButton(
      onPressed: () {
        stopwatchModel.start();
      },
      child: Text("Start"),
    ),
  ],
),

ストップ/リセットボタンを実装

同じようにボタンの処理を実装します。

ただiPhoneのようにボタンは一つにしたいので、
ストップウォッチの状態によってボタンテキストの表示とボタン押下時の処理を変えるようにします。

また、ここでもストップウォッチのクラスとボタン状態のクラスを分けています。

この考え方も単一責任原則に基づいています

model.dart(一部)

enum Status { start, stop, reset }

class StopwatchModel extends ChangeNotifier {
  Stopwatch _stopwatch = Stopwatch();
  String seconds = "00";
  String milliseconds = "000";
  String microseconds = "000";
  Status nextState = Status.start;

.
.
.

  void doExecute() {
    if (nextState == Status.start) {
      _start();
    } else if (nextState == Status.stop) {
      _stop();
    } else if (nextState == Status.reset) {
      _reset();
    }
  }

  void _start() {
    _stopwatch.start();
    Timer.periodic(const Duration(milliseconds: 1), (_) {
      _refresh();
    });
    buttonContext.display(Status.start);
    nextState = Status.stop;
  }

  void _stop() {
    _stopwatch.stop();
    buttonContext.display(Status.stop);
    nextState = Status.reset;
  }

  void _reset() {
    _stopwatch.reset();
    buttonContext.display(Status.reset);
    nextState = Status.start;
  }
}

class ButtonContext {
  String buttonDisplay = "start";

  void display(Status status) {
    if (status == Status.start) {
      buttonDisplay = "stop";
    } else if (status == Status.stop) {
      buttonDisplay = "reset";
    } else if (status == Status.reset) {
      buttonDisplay = "start";
    }
  }
}

view.dart(一部)

ElevatedButton(
  onPressed: () {
    stopwatchModel.doExecute();
  },
  child: Text(stopwatchModel.buttonContext.buttonDisplay),
),

チートボタンを作って勝手にジャスト3秒で止まるようにする

ジャスト3秒で止まるような処理を内部に作ってしまうことでチート機能を作ってしまいます。

マイクロ秒までを3秒ジャストで止めるなんで無理です(笑)

これで自慢しちゃいましょう!!!

処理の記載自体はスタートボタンと同じような実装で、
最終的なコードは以下の通りです

ボタンを長く押下した時にチート機能が動作するようにしています

main.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tmp_blog/home/view.dart';

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        home: const MyApp(),
      ),
    ),
  );
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  Widget build(BuildContext context) {
    return Home();
  }
}

view.dart

import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tmp_blog/home/model.dart';

class Home extends ConsumerWidget {
  const Home({
    super.key,
  });

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final StopwatchModel stopwatchModel = ref.watch(stopwatchProvider);

    return Scaffold(
      body: SafeArea(
        child: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                stopwatchModel.seconds +
                    "." +
                    stopwatchModel.milliseconds +
                    "." +
                    stopwatchModel.microseconds,
                style: TextStyle(
                  fontSize: 56,
                  fontFeatures: [FontFeature.tabularFigures()],
                ),
              ),
              ElevatedButton(
                onPressed: () {
                  stopwatchModel.doExecute();
                },
                onLongPress: () {
                  stopwatchModel.doCheat();
                },
                child: Text(stopwatchModel.buttonContext.buttonDisplay),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

model.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

final stopwatchProvider = ChangeNotifierProvider((ref) => StopwatchModel());

enum Status { start, stop, reset }

class StopwatchModel extends ChangeNotifier {
  Stopwatch _stopwatch = Stopwatch();
  String seconds = "00";
  String milliseconds = "000";
  String microseconds = "000";
  Status nextState = Status.start;

  ButtonContext buttonContext = ButtonContext();

  void _refresh() {
    String str2Digits(int n) => n.toString().padLeft(2, "0");
    String str3Digits(int n) => n.toString().padLeft(3, "0");
    seconds = str2Digits(_stopwatch.elapsed.inSeconds.remainder(60));
    milliseconds =
        str3Digits(_stopwatch.elapsed.inMilliseconds.remainder(1000));
    microseconds =
        str3Digits(_stopwatch.elapsed.inMicroseconds.remainder(1000));
    notifyListeners();
  }

  StopwatchModel() {
    _refresh();
  }

  void doExecute() {
    if (nextState == Status.start) {
      _start();
    } else if (nextState == Status.stop) {
      _stop();
    } else if (nextState == Status.reset) {
      _reset();
    }
  }

  void doCheat() {
    _stopwatch.start();
    Timer.periodic(const Duration(milliseconds: 1), (_) {
      _refresh();
      if (_stopwatch.elapsed.inSeconds == 3) {
        _stop();
        _magic();
      }
    });
    buttonContext.display(Status.start);
    nextState = Status.stop;
  }

  void _magic() {
    String str3Digits(int n) => n.toString().padLeft(3, "0");
    seconds = str3Digits(3);
    milliseconds = str3Digits(0);
    microseconds = str3Digits(0);
    notifyListeners();
  }

  void _start() {
    _stopwatch.start();
    Timer.periodic(const Duration(milliseconds: 1), (_) {
      _refresh();
    });
    buttonContext.display(Status.start);
    nextState = Status.stop;
  }

  void _stop() {
    _stopwatch.stop();
    buttonContext.display(Status.stop);
    nextState = Status.reset;
  }

  void _reset() {
    _stopwatch.reset();
    buttonContext.display(Status.reset);
    nextState = Status.start;
  }
}

class ButtonContext {
  String buttonDisplay = "start";

  void display(Status status) {
    if (status == Status.start) {
      buttonDisplay = "stop";
    } else if (status == Status.stop) {
      buttonDisplay = "reset";
    } else if (status == Status.reset) {
      buttonDisplay = "start";
    }
  }
}

コメント

タイトルとURLをコピーしました