티스토리 뷰
프로젝트 요구사항
화면은 하나만 구현하면 되고 구현할 기능들이 좀 많다..
프로젝트 데모
폴더 구조
타이머 화면을 구성하는 dart파일을 하나 생성 후,
main.dart에서 타이머 화면을 띄우도록 하면 된다.
타이머 UI 구현
import 'package:flutter/material.dart';
class TimerScreen extends StatefulWidget {
const TimerScreen({super.key});
@override
State<TimerScreen> createState() => _TimerScreenState();
}
class _TimerScreenState extends State<TimerScreen> {
@override
Widget build(BuildContext context) {
final List<Widget> runningButtons = [
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue),
onPressed: () {},
child: const Text(
1 == 2 ? '계속하기' : '일시정지', // 일시정지 중 ?
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
const Padding(padding: EdgeInsets.all(20)),
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey),
child: const Text(
'포기하기',
style: TextStyle(fontSize: 16),
),
),
];
final List<Widget> stoppedButtons = [
ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
backgroundColor: 1 == 2 ? Colors.green : Colors.blue, // 휴식 중 ?
),
child: const Text(
'시작하기',
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
];
return Scaffold(
appBar: AppBar(
title: const Text('타이머', style: TextStyle(color: Colors.white)),
backgroundColor: Colors.blue,
),
body: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Container(
height: MediaQuery.of(context).size.height * 0.5,
width: MediaQuery.of(context).size.width * 0.6,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: 1 == 2 ? Colors.green : Colors.blue, // 휴식 중 ?
),
child: const Center(
child: Text(
'00,00',
style: TextStyle(
color: Colors.white,
fontSize: 48,
fontWeight: FontWeight.bold),
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: 1 == 2 // 휴식 중?
? []
: 1 == 2 // 정지 ?
? stoppedButtons
: runningButtons,
)
],
),
);
}
}
타이머에서 상태변화를 구현해야 하므로 StatefulWidget으로 선언해야 한다.
원 모양의 위젯은 Container의 decoration 프로퍼티를 활용해
BoxDecoration으로 모양을 설정해 줘야 한다.
타이머가 작동 중일 때 출력할 버튼 2개가 들어간 runningButtons라는 리스트와
휴식 중일 때 출력할 버튼 1개가 들어간 stoppedButtons라는 리스트를 선언해 준다.
요구사항에 맞게 UI코드를 작성하였고 조건문 처리는 따로 하지 않았다.
(삼항연산자는 "조건 ? 조건이 참일 때 실행할 문장 : 조건이 거짓일 때 실행할 문장"을 표현하는 문법이다.)
타이머 상태 정의
1. 타이머의 현재 시간
타이머의 시간은 멈추지 않는 한 1초마다 1씩 줄어든다.
타이머의 시간은 상태(State)로 관리해 시간이 표시되는 부분만 갱신되도록 구현해야 한다.
2. 현재까지 완료한 할 일 개수
완료한 할 일의 개수는 휴식까지 끝났을 때 1만큼 증가한다.
휴식이 끝날 때마다 1씩 증가하도록 구현하면 된다.
3. 현재 타이머의 상태(Status)
이 타이머는 총 4개의 상태를 가지며 이벤트에 따라 변화하게 된다.
실행 중, 정지, 일시정지, 휴식 중 총 4가지의 상태가 있다.
타이머는 실행 중일 때 25분짜리 타이머가 돌아간다.
포기하기 버튼을 누르거나 휴식까지 모두 끝나면 정지 상태가 된다.
일시정지 버튼을 누르면 타이머가 잠시 멈추는 일시정지 상태가 되며,
타이머의 시간(25분)이 다 흐르면 휴식 타이머가 시작하며 휴식 중 상태가 된다.
(값이 지속적으로 변하는 변수로써의 상태는 State,
타이머의 이벤트 여부는 Status라 표현)
타이머 이벤트 정리
타이머의 상태변화를 정리하였다.
다이어그램으로 그린다면 이런 형식이다.
타이머 기능 구현
Enum을 활용해 TimeStatus선언
타이머의 상태를 구현할 때 Enum(열거형)을 쓸 것이다.
enum E { a, b, c }
E val = E.a; // val = a;
아래와 같은 문법으로 enum을 활용해 자료형을 정의할 수 있다.
import 'package:flutter/material.dart';
enum TimerStatus {run, pause, stop, rest}
class TimerScreen extends StatefulWidget {
}
enum으로 자료형을 선언할 땐 코드 최상단(import문) 바로 밑에 선언하는 것이 좋다.
타이머 State 정의
class _TimerScreenState extends State<TimerScreen> {
static const WORK_SECONDS = 25; // 작업시간
static const REST_SECONDS = 5; // 휴식시간
late TimerStatus _timerStatus; // 타이머 상태
late int _timer; // 타이머 시간
late int _doCount; // 한 일 개수
@override
void initState() {
super.initState();
_timerStatus = TimerStatus.stop;
_timer = WORK_SECONDS;
_doCount = 0;
}
타이머의 작업시간(25분)과 휴식시간(5분) 변수선언,
타이머 상태, 타이머 시간, 할 일 개수를 각각 변수선언,
타이머 상태는 멈춤, 타이머 시간은 25분, 한 일 개수는 0개로 초기화했다.
변수 앞에 late를 붙인 이유는 initState를 통해 상태를 초기화하므로
변수의 초기화를 나중에 하기 위해 붙였다.
타이머 Event 작성
void run() {
setState(() {
_timerStatus = TimerStatus.run;
runTimer();
});
}
void rest() {
setState(() {
_timer = REST_SECONDS;
_timerStatus = TimerStatus.rest;
runTimer();
});
}
void pause() {
setState(() {
_timerStatus = TimerStatus.pause;
runTimer();
});
}
void resume() {
run();
}
타이머의 상태는 "_timerStatus = TimerStatus.상태명"으로 나타낸다.
void runTimer() async {
Timer.periodic(const Duration(seconds: 1), (Timer t) {
switch (_timerStatus) {
case TimerStatus.pause:
t.cancel();
break;
case TimerStatus.run:
if (_timer <= 0) {
rest();
} else {
setState(() {
_timer -= 1;
});
}
break;
case TimerStatus.rest:
if (_timer <= 0) {
setState(() {
_doCount += 1;
});
print("오늘 $_doCount개의 할 일을 달성했습니다.");
t.cancel();
stop();
} else {
setState(() {
_timer -= 1;
});
}
default:
break;
}
});
}
1초씩 줄어드는 타이머를 구현하기 위해 dart:async패키지의 Timer 클래스를 이용했다.
Timer.periodic(Duration duration, Timer t)를 사용하면 단위 시간(duration)마다 코드를 실행시킬 수 있다.
위의 코드에선 매 duration마다 1초씩 시간을 감소시키고 있고,
_timer(시간)이라는 상태를 setState()를 통해 값을 수정하고 있다.
0초가 되어 타이머를 종료시킬 땐 t.cancel()을 사용한다.
코드에선 1초마다 switch-case 조건문을 확인한다.
조건은 _timerStatus값을 기반으로 나뉘어져 있으며,
현재 타이머의 상태(Status)에 따라 동작을 달리한다.
TimerStatus.pause거나 TimerStatus.stop이라면
타이머를 중지시키기 위해 t.cancel()을 실행시킨다.
TimerStatus.run이거나 TimerStatus.rest라면
_timer 값을 매초 1씩 감소시킨다.
화면에 시간 출력
정수형 변수 int -timer를 화면에 예쁘게 표시하려면 sprintf라는 패키지를 사용하면 된다.
(_timer값을 00:00의 형태로 변환해 준다.)
패키지명 복사 후
pubspec.yaml에 붙여 넣기
사용 예시
String secondsToString(int seconds) {
return sprintf("%02d:%02d", [seconds ~/ 60, seconds % 60]);
}
%02d는 정수 2자리만큼 출력하는데 정수가 2자리보다 작다면 앞에 0을 채우도록 하는 포맷 스트링이다.
A ~/ B는 다트에서 A를 B로 나는 몫을 계산하는 연산자이다.
UI와 기능 연결
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:sprintf/sprintf.dart';
enum TimerStatus { run, pause, stop, rest, home }
class TimerScreen extends StatefulWidget {
const TimerScreen({super.key});
@override
State<TimerScreen> createState() => _TimerScreenState();
}
class _TimerScreenState extends State<TimerScreen> {
static const WORK_SECONDS = 25; // * 60
static const REST_SECONDS = 5; // * 60
late TimerStatus _timerStatus = TimerStatus.stop;
late int _timer = WORK_SECONDS;
late int _doCount = 0;
String secondsToString(int seconds) {
return sprintf("%02d:%02d", [seconds ~/ 60, seconds % 60]);
}
void runTimer() async {
Timer.periodic(const Duration(seconds: 1), (Timer t) {
switch (_timerStatus) {
case TimerStatus.pause:
t.cancel();
break;
case TimerStatus.run:
if (_timer <= 0) {
rest();
} else {
setState(() {
_timer -= 1;
});
}
break;
case TimerStatus.rest:
if (_timer <= 0) {
setState(() {
_doCount += 1;
});
print("오늘 $_doCount개의 할 일을 달성했습니다.");
t.cancel();
stop();
} else {
setState(() {
_timer -= 1;
});
}
case TimerStatus.home:
setState(() {
_doCount += 1;
});
print("오늘 $_doCount개의 할 일을 달성했습니다.");
pause();
break;
default:
break;
}
});
}
void run() {
setState(() {
_timerStatus = TimerStatus.run;
runTimer();
});
}
void rest() {
setState(() {
_timer = REST_SECONDS;
_timerStatus = TimerStatus.rest;
});
}
void home() {
_timer = WORK_SECONDS;
_timerStatus = TimerStatus.home;
}
void pause() {
setState(() {
_timerStatus = TimerStatus.pause;
});
}
void resume() {
run();
}
void stop() {
setState(() {
_timer = WORK_SECONDS;
_timerStatus = TimerStatus.stop;
});
}
@override
Widget build(BuildContext context) {
final List<Widget> runningButtons = [
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue),
onPressed: _timerStatus == TimerStatus.pause ? resume : pause,
child: Text(
_timerStatus == TimerStatus.pause ? '계속하기' : '일시정지', // 일시정지 중 ?
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
const Padding(padding: EdgeInsets.all(20)),
ElevatedButton(
onPressed: stop,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey),
child: const Text(
'포기하기',
style: TextStyle(fontSize: 16),
),
),
];
final List<Widget> stoppedButton = [
ElevatedButton(
onPressed: run,
style: ElevatedButton.styleFrom(
backgroundColor: _timerStatus == TimerStatus.rest
? Colors.green
: Colors.blue, // 휴식 중 ?
),
child: const Text(
'시작하기',
style: TextStyle(color: Colors.white, fontSize: 16),
),
),
];
final List<Widget> homeButton = [
ElevatedButton(
onPressed: home,
style: ElevatedButton.styleFrom(backgroundColor: Colors.green),
child: const Text(
'휴식종료',
style: TextStyle(color: Colors.white, fontSize: 16),
)),
];
return Scaffold(
appBar: AppBar(
title: const Text('타이머', style: TextStyle(color: Colors.white)),
backgroundColor: Colors.blue,
),
body: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Container(
height: MediaQuery.of(context).size.height * 0.5,
width: MediaQuery.of(context).size.width * 0.6,
decoration: BoxDecoration(
shape: BoxShape.circle,
color:
_timerStatus == TimerStatus.rest ? Colors.green : Colors.blue,
),
child: Center(
child: Text(
secondsToString(_timer),
style: const TextStyle(
color: Colors.white,
fontSize: 48,
fontWeight: FontWeight.bold),
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: _timerStatus == TimerStatus.rest
? []
: _timerStatus == TimerStatus.stop
? stoppedButton
: runningButtons,
)
],
),
);
}
}
작성했던 UI코드와 타이머 관련 코드를 모두 연결했다.
"1==2?~"의 꼴로 항상 거짓이 나오게 하던 조건문을 TimeStatus 값에 대한 조건문으로 바꿨고,
onPressed에 적절한 이벤트를 설정하였다.
late로 선언한 동적변수들을 원래는 initState()내에서 초기화했으나,
변숫값을 지정해줘야 한다는 오류를 맞닥뜨려
동적변수들의 초기값들을 선언해 주었다.
휴식을 도중에 끝내는 기능이 없어서 따로 구현했다.
homeButton리스트를 추가해서 휴식 중일 때 '휴식종료' 버튼을 뜨게 했고
그 버튼을 누르면 한 일(_doCount)이 1개 추가되고
시작화면('포기하기 버튼을 누를 때 나오는 화면')으로 이동하도록 구현했다.
작업 완료 Toast 알림 띄우기
아까 하던 대로 패키지 추가하면 된다.
sprintf 밑에 예쁘게 추가해 주자.
이슈) 필자는 이렇게 넣고 적용이 안돼서 Toast가 뜨지 않았는데
그럴 땐 터미널에 flutter clean > flutter run 명령으로 앱을 다시 빌드하면 되더라.
showToast() 함수 작성
void showToast(String message) {
Fluttertoast.showToast(
msg: message,
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.TOP,
timeInSecForIosWeb: 5,
backgroundColor: Colors.grey,
textColor: Colors.white,
fontSize: 16,
);
}
message를 전달받아 5초 동안 화면 상단에 나타나게 하는 함수이다.
showToast() 함수 사용
void runTimer() async {
Timer.periodic(const Duration(seconds: 1), (Timer t) {
switch (_timerStatus) {
case TimerStatus.pause:
t.cancel();
break;
case TimerStatus.run:
if (_timer <= 0) {
showToast("작업완료!");
rest();
} else {
setState(() {
_timer -= 1;
});
}
break;
case TimerStatus.rest:
if (_timer <= 0) {
setState(() {
_doCount += 1;
});
showToast("오늘 $_doCount개의 할 일을 달성했습니다.");
t.cancel();
stop();
} else {
setState(() {
_timer -= 1;
});
}
case TimerStatus.home:
setState(() {
_doCount += 1;
});
showToast("오늘 $_doCount개의 할 일을 달성했습니다.");
pause();
break;
default:
break;
}
});
}
runTimer()의 함수에서 출력하고 싶은 말이 있으면
print 콘솔창에 출력하는 대신 showToast 써서 화면에 알림 창을 띄울 수 있다.
함수 따로 빼기
lib/tools/utils.dart에
타이머와 직접적인 관련성이 적은 함수
secondsToString()과 showToast()를 따로 빼놨다.
// timer_screen.dart
import 'dart:async';
import 'package:flutter/material.dart';
import '../tools/utils.dart';
임포트 구문에서 두 패키지를 불러오는 구문을 제거하고 'utils.dart'를 불러온다.
// utils.dart
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:sprintf/sprintf.dart';
void showToast(String message) {
Fluttertoast.showToast(
msg: message,
toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.TOP,
timeInSecForIosWeb: 5,
backgroundColor: Colors.grey,
textColor: Colors.white,
fontSize: 16,
);
}
String secondsToString(int seconds) {
return sprintf("%02d:%02d", [seconds ~/ 60, seconds % 60]);
}
함수는 utils.dart에 옮겨놓으면 된다. 생각보다 정말 간단하다!
이런 식으로 프로젝트의 연관성을 기반으로 코드를 분리하면
나중에 코드를 다시 확인할 때 편리할 것이다 :)
실행화면
프로젝트하면서 알게 된 것들
깃허브
'무스마 > 플러터' 카테고리의 다른 글
flutter localization 설정 / 언어변환 탭 만들기 (0) | 2023.10.12 |
---|---|
TodoList 구현 (0) | 2023.08.13 |
도서 목록 앱 (0) | 2023.07.31 |
플러터로 상태관리 (0) | 2023.07.31 |
플러터 화면 전환 구현 (0) | 2023.07.30 |