티스토리 뷰

직전 포스팅에서 간단한 UI 사용을 익히는 book list 프로젝트를 살펴봤다. 이번에는 state, 상태를 좀 더 직관적으로 이해하고 사용에 익숙해지기 위해 pomodoro 타이머를 만들어 보려고 한다.

 

Pomodoro Timer는 시간 관리 기법 중 하나로, 25분 집중 시간을 가지고 5분의 쉬는 시간을 가지는 방식이다.

timer를 만들어야 하므로, 정수 타입의 timer 변수를 1초마다 상태 변경을 해줘야 하며, 버튼을 클릭하거나 작업 시간이 끝났을 때 timer가 어떤 상태인지 동적으로 변경해줘야 한다.

 

화면은 TimerScreen 하나만 생성할 것이며, 가지고 있는 기능은 다음과 같다.

 - (시작하기) 타이머 시작, 1초씩 감소, 버튼을 (일시정지, 포기하기)로 변경

 - (일시정지) 타이머 일시정지, 버튼을 (계속하기, 포기하기)로 변경 

 - (계속하기) 타이머 계속하기, 버튼을 (일시정지, 포기하기)로 변경

 - (포기하기) 작업 중이던 타이머를 중지하고 초기화

 - (휴식하기) 작업 타이머가 끝나면 자동으로 휴식 타이머 작동

 - (휴식 종료) 휴식 타이머가 끝나면 초기 화면으로 이동, 알림 창

 

바로 한번 코드를 보자. 각 메서드나 상태에 대한 설명은 주석으로 코드 내에 작성하였다.


◎main.dart

import 'package:flutter/material.dart';
import 'package:timer/screens/timer_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Pomodoro Timer App',
      home: TimerScreen()
    );
  }
}

 

◎lib > screens > timer_screen.dart

import 'dart:async';

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:sprintf/sprintf.dart';
import 'package:fluttertoast/fluttertoast.dart';

// 타이머의 상태를 나타내기 위한 Enum
enum TimerStatus {
  running,
  paused,
  stopped,
  resting
}

class TimerScreen extends StatefulWidget {
  @override
  State<StatefulWidget> createState() => _TimerScreenState();
}

class _TimerScreenState extends State<TimerScreen> {

  // 집중 시간
  static const WORK_SECONDS = 25 * 60;

  // 쉬는 시간
  static const REST_SECONDS = 5 * 60;

  // 타이머 상태
  late TimerStatus _timerStatus;

  // 타이머 시간
  late int _timer;

  // pomodoro 횟수
  late int _pomodoroCount;

  // state 초기화
  @override
  void initState() {
    super.initState();
    _timerStatus = TimerStatus.stopped;
    _timer = WORK_SECONDS;
    _pomodoroCount = 0;
  }

  @override
  Widget build(BuildContext context) {
    // 집중 타이머가 돌고 있을 때 버튼 리스트
    final List<Widget> _runningButtons = [
      ElevatedButton(
        child: Text(
          _timerStatus == TimerStatus.paused ? '계속하기' : '일시정지', // 일시정지 중? 계속하기 : 일시정지,
          style: TextStyle(color: Colors.white, fontSize: 20)
        ),
        style: ElevatedButton.styleFrom(primary: Colors.blue),
        onPressed: _timerStatus == TimerStatus.paused ? resume : pause,
      ),
      Padding(
        padding: EdgeInsets.all(20),
      ),
      ElevatedButton(
          onPressed: stop,
          child: Text(
            '포기하기',
            style: TextStyle(fontSize: 16),
          ),
          style: ElevatedButton.styleFrom(primary: Colors.grey),
      )
    ];

    // 타이머 정지 시 버튼
    final List<Widget> _stoppedButtons = [
      ElevatedButton(
          onPressed: run,
          child: Text(
            '시작하기',
            style: TextStyle(color: Colors.white, fontSize: 16),
          ),
          style: ElevatedButton.styleFrom(
            primary:
            _timerStatus == TimerStatus.resting ? Colors.green : Colors.blue, // 휴식 중? 녹색 : 파란색색
         ),
      )
    ];

    return Scaffold(
      appBar: AppBar(
        title: Text('Pomodoro Timer')
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          Container(
            height: MediaQuery.of(context).size.height * 0.5,
            width: MediaQuery.of(context).size.width * 0.6,
            child: Center(
              child: Text(
                secondToString(_timer),
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 48,
                  fontWeight: FontWeight.bold,
                ),
              )
            ),
            decoration: BoxDecoration(
              shape: BoxShape.circle,
              color: _timerStatus == TimerStatus.resting ? Colors.green: Colors.blue, // 휴식 중? 녹색 : 파란색
            ),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children:
            _timerStatus == TimerStatus.resting ? const[] : _timerStatus == TimerStatus.stopped ? _stoppedButtons : _runningButtons,
          )
        ],
      )
    );
  }

  // timer 숫자를 00:00 으로 표현하기 위한 포맷 함수
  String secondToString(int seconds) {
    return sprintf("%02d:%02d" , [seconds ~/ 60, seconds % 60]);
  }

  // 타이머 시작
  void run() {
    setState(() {
      _timerStatus = TimerStatus.running;
      print("[=>] " + _timerStatus.toString());
      runTimer();
    });
  }

  // 집중 끝 -> 휴식
  void rest() {
    setState(() {
      _timer = REST_SECONDS;
      _timerStatus = TimerStatus.resting;
      print("[=>] " + _timerStatus.toString());
    });
  }

  // 일시정지
  void pause() {
    setState(() {
      _timerStatus = TimerStatus.paused;
      print("[=>] " + _timerStatus.toString());
    });
  }

  // 계속하기
  void resume() {
    run();
  }

  // 포기하기
  void stop() {
    setState(() {
      _timer = WORK_SECONDS;
      _timerStatus = TimerStatus.stopped;
      print("[=>] " + _timerStatus.toString());
    });
  }

  // 타이머 작동을 위한 비동기 함수
  void runTimer() async {
    Timer.periodic(const Duration(seconds: 1), (Timer t) {
      switch (_timerStatus) {
        case TimerStatus.paused:
          t.cancel();
          break;
        case TimerStatus.stopped:
          t.cancel();
          break;
        case TimerStatus.running:
          if(_timer <= 0) {
            showToast("작업 완료!");
            rest();
          } else {
            setState(() {
              _timer -= 1;
            });
          }
          break;
        case TimerStatus.resting:
          if(_timer <= 0) {
            setState(() {
              _pomodoroCount += 1;
            });
            showToast("오늘 $_pomodoroCount개의 뽀모도로를 달성했습니다.");
            t.cancel();
            stop();
          } else {
            setState(() {
              _timer -= 1;
            });
          }
          break;
        default:
          break;
      }
    });
  }

  // context 없이 toast message 를 띄우기 위한 Fluttertoast
  void showToast(String message) {
    Fluttertoast.showToast(
      msg: message,
      toastLength: Toast.LENGTH_LONG,
      gravity: ToastGravity.BOTTOM,
      timeInSecForIosWeb: 5,
      backgroundColor: Colors.grey,
      textColor: Colors.white,
      fontSize: 16.0
    );
  }
}

따로 엄청 어렵거나 하는 코드는 없지만, 조금 생소할 수 있는 부분을 짚고 넘어가면,

 

1. 타이머 포맷(00:00)을 표시하기 위한 포맷 함수를 좀 더 쉽게 구성하려고 sprintf패키지를 설치하여 사용하였다. 

// timer 숫자를 00:00 으로 표현하기 위한 포맷 함수
String secondToString(int seconds) {
  return sprintf("%02d:%02d" , [seconds ~/ 60, seconds % 60]);
}

sprintf는 C언어에서의 printf 함수의 포맷팅 방식을 그대로 구현한 패키지이다.

 

2. 타이머의 배경인 꽉 찬 원을 그려주기 위해 BoxDecoration 위젯을 사용하여 도형을 그려줬다.

decoration: BoxDecoration(
  shape: BoxShape.circle,
  color: _timerStatus == TimerStatus.resting ? Colors.green: Colors.blue, // 휴식 중? 녹색 : 파란색
),

 

3. 마지막으로 위 코드에서 확인할 수 있듯, 타이머가 돌아가는 runTimer함수에서 토스트 메시지를 띄워줬다. 그러나 Flutter에서 기본으로 제공하는 Toast를 사용하려면 Context에 접근하여야 하는데 그럴 수 없으니 Context 없이도 Toast를 띄울 수 있는 외부 패키지인 fluttertoast를 사용했다.

// context 없이 toast message 를 띄우기 위한 Fluttertoast
void showToast(String message) {
  Fluttertoast.showToast(
    msg: message,
    toastLength: Toast.LENGTH_LONG,
    gravity: ToastGravity.BOTTOM,
    timeInSecForIosWeb: 5,
    backgroundColor: Colors.grey,
    textColor: Colors.white,
    fontSize: 16.0
  );
}

이번 프로젝트는 외부 패키지를 2개 정도 사용하였으니, pubspec.yaml에서 그 부분도 get 해야 한다.

 

◎pubspec.yaml

dev_dependencies:
  flutter_test:
    sdk: flutter
    
  flutter_lints: ^2.0.0

  fluttertoast: "^8.0.7"

 

실행 결과를 확인해보면, 아래와 같이 타이머가 잘 동작하는 것을 확인할 수 있다.

또한 집중 시간이 모두 끝났을 때, 휴식으로 돌아가는 부분도 문제없이 잘 작동한다.

 

끝!

Comments