Flutter Basic Chapter 4 - Pomodoro 타이머 앱 만들기

UI 레이아웃과 화면 분리

🎯 이번 단계에서 배울 것

  • MaterialApp에 테마를 적용하고 화면을 별도 위젯으로 분리하는 방법 이해
  • Scaffold + Flexible + Expanded 조합으로 세로 레이아웃 설계
  • 테마 색상(backgroundColor, cardColor, textTheme)을 통해 일관된 스타일 적용
  • 새로운 화면 파일(lib/screens/home_screen.dart)을 생성하고 HomeScreen을 루트로 사용

📂 파일 구조

1
2
3
4
5
수정되는 파일
- lib/main.dart (테마 정의 및 HomeScreen 연결)

새로 생성되는 파일
- lib/screens/home_screen.dart (Pomodoro UI 기본 뼈대)

📝 1단계: 테마 정의와 화면 분리

전체 코드 (lib/main.dart):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import 'package:flutter/material.dart';
import 'package:toonflix/screens/home_screen.dart';

void main() {
runApp(App());
}

class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(
backgroundColor: const Color(0xFFE7626C),
textTheme: const TextTheme(
headline1: TextStyle(
color: Color(0xFF232B55),
),
),
cardColor: const Color(0xFFF4EDDB),
),
home: const HomeScreen(),
);
}
}
  • 배경색(연한 빨강), 카드색(밝은 베이지), headline1 컬러(남색)를 한 번에 정의해 후속 위젯이 재사용하도록 했습니다.
  • HomeScreen을 const 생성자로 호출해 불필요한 리빌드를 방지합니다.

📝 2단계: HomeScreen UI 뼈대 작성

핵심 코드 (lib/screens/home_screen.dart):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});

@override
State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).backgroundColor,
body: Column(
children: [
Flexible(
flex: 1,
child: Container(
alignment: Alignment.bottomCenter,
child: Text(
'25:00',
style: TextStyle(
color: Theme.of(context).cardColor,
fontSize: 89,
fontWeight: FontWeight.w600,
),
),
),
),
Flexible(
flex: 3,
child: Center(
child: IconButton(
iconSize: 120,
color: Theme.of(context).cardColor,
onPressed: () {},
icon: const Icon(Icons.play_circle_outline),
),
),
),
Flexible(
flex: 1,
child: Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Pomodoros',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.headline1!.color,
),
),
Text(
'0',
style: TextStyle(
fontSize: 58,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.headline1!.color,
),
),
],
),
),
),
],
),
)
],
),
);
}
}

🔍 코드 상세 설명

  • Flexible/Expanded: Column 세 영역에 flex 값을 부여해 타이머, 버튼, 통계 영역의 비율을 제어합니다.
  • Theme 재사용: 배경색·카드색·텍스트 색 모두 Theme.of(context)에서 가져와 테마 값과 동기화합니다.
  • StatefulWidget 도입: 추후 타이머 상태를 저장하기 위해 HomeScreen을 StatefulWidget으로 시작합니다(아직 상태 필드는 없음).

시각화:

1
2
3
4
5
6
7
┌──────────────────────────────┐
│ 25:00 │ ← 상단 타이머 (flex 1)
│ │
│ [ Play Icon ] │ ← 중앙 컨트롤 (flex 3)
│ │
│ Pomodoros 0 │ ← 하단 통계 (flex 1)
└──────────────────────────────┘

📊 동작 흐름

1
2
3
4
1. App → MaterialApp → ThemeData 설정
2. HomeScreen(StatefulWidget)이 Scaffold를 반환
3. Column이 세 영역을 Flexible로 분할
4. Theme.of(context) 호출로 전역 테마 색상을 가져옴

✅ 체크리스트

  • lib/screens 폴더에 home_screen.dart가 생성됐는가?
  • MaterialApp에서 home: const HomeScreen()을 사용했는가?
  • Flexible 영역 비율이 1:3:1로 구성됐는가?
  • UI 전체가 정의한 테마 색상을 사용하고 있는가?

💡 연습 과제

  1. headline1 색상을 다른 색으로 바꿔 카드 텍스트 색 변화 확인하기
  2. 하단 카드에 borderRadius를 추가해 모서리를 둥글게 만들기
  3. 타이머 텍스트를 중앙 정렬이 아니라 좌측 정렬로 바꿔 UI 변화를 비교하기

Timer.periodic으로 카운트다운 구현

🎯 이번 단계에서 배울 것

  • dart:asyncTimer.periodic으로 1초마다 콜백 실행하기
  • 타이머 상태(totalSeconds)를 감소시키고 setState로 UI 갱신하기
  • 타이머 핸들을 late Timer로 선언하고 onTick 콜백으로 추출하기
  • 타이머 시작 버튼에 실제 동작 연결하기

📂 파일 구조

1
2
수정되는 파일
- lib/screens/home_screen.dart (타이머 상태와 로직 추가)

📝 1단계: 타이머 상태와 콜백 추가

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class _HomeScreenState extends State<HomeScreen> {
int totalSeconds = 1500;
late Timer timer;

void onTick(Timer timer) {
setState(() {
totalSeconds = totalSeconds - 1;
});
}

void onStartPressed() {
timer = Timer.periodic(
const Duration(seconds: 1),
onTick,
);
}
  • totalSeconds는 25분(1500초)부터 내려가기 시작하는 타이머 값입니다.
  • Timer.periodic이 1초마다 onTick을 호출하며, 콜백 안에서 setState로 카운트다운 값을 갱신합니다.
  • timerlate로 선언해 나중에 초기화하되, 반드시 타이머 생성 후 접근하도록 합니다.

📝 2단계: UI에서 남은 시간 출력

1
2
3
4
5
6
7
8
Text(
'$totalSeconds',
style: TextStyle(
color: Theme.of(context).cardColor,
fontSize: 89,
fontWeight: FontWeight.w600,
),
),
  • 타이머 숫자를 그대로 출력해 정상적으로 감소하는지 확인합니다(아직 포맷팅 전 단계).
  • 중앙의 IconButtononStartPressed에 연결해 재생 버튼을 누르면 타이머가 시작됩니다.

📊 동작 흐름

1
2
3
4
1. 사용자가 재생 버튼을 누름 → onStartPressed 실행
2. Timer.periodic이 1초 주기로 onTick 호출
3. onTick에서 totalSeconds-- 후 setState 호출
4. build() 재호출 → Text('$totalSeconds')가 갱신된 숫자를 표시

✅ 체크리스트

  • dart:async가 최상단에 import되어 있는가?
  • Timer.periodic이 Duration(seconds: 1)로 생성되는가?
  • 타이머 시작 후 숫자가 1초마다 감소하는가?
  • 타이머를 멈추지 않아도 안전하게 동작하는가? (아직 종료 로직은 없음)

💡 연습 과제

  1. totalSeconds 초기값을 5로 줄여 빠르게 타이머 완료를 테스트해보세요.
  2. Timer.periodic 대신 Future.delayed로 재귀 호출을 구현해 비교해보세요.
  3. 남은 시간이 0이 되었을 때 경고 로그를 출력하도록 조건문을 추가하세요.

재생/일시정지 토글 구현

🎯 이번 단계에서 배울 것

  • 타이머 실행 여부를 추적하는 불리언 상태(isRunning) 추가
  • 타이머 중지 시 Timer.cancel()을 호출해 자원 누수를 막기
  • 하나의 버튼에서 재생/일시정지 아이콘과 동작을 토글하기

📂 파일 구조

1
2
수정되는 파일
- lib/screens/home_screen.dart (isRunning 상태 및 Pause 기능 추가)

📝 1단계: 상태 변수와 로직 확장

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool isRunning = false;

void onStartPressed() {
timer = Timer.periodic(
const Duration(seconds: 1),
onTick,
);
setState(() {
isRunning = true;
});
}

void onPausePressed() {
timer.cancel();
setState(() {
isRunning = false;
});
}
  • 타이머 시작/정지 시 isRunning 값을 갱신하여 UI와 상태를 동기화합니다.
  • 타이머를 중지할 때는 반드시 timer.cancel()을 호출해야 콜백이 더 이상 실행되지 않습니다.

📝 2단계: 단일 버튼에서 토글 구현

1
2
3
4
5
6
7
8
9
10
IconButton(
iconSize: 120,
color: Theme.of(context).cardColor,
onPressed: isRunning ? onPausePressed : onStartPressed,
icon: Icon(
isRunning
? Icons.pause_circle_outline
: Icons.play_circle_outline,
),
),
  • isRunning이 true면 일시정지 아이콘과 핸들러를, false면 재생 아이콘과 핸들러를 사용합니다.
  • 단일 위젯으로 상태에 따라 동작이 바뀌는 것을 경험할 수 있습니다.

📊 동작 흐름

1
2
3
4
5
1. isRunning이 false → 재생 버튼 표시 → onStartPressed 실행
2. onStartPressed에서 Timer 시작 + isRunning = true
3. 버튼이 즉시 일시정지 아이콘으로 바뀜
4. 사용자가 다시 누르면 onPausePressed 실행 → Timer.cancel + isRunning = false
5. 버튼이 재생 아이콘으로 돌아감

✅ 체크리스트

  • isRunning 기본값이 false로 설정되어 있는가?
  • 타이머를 정지하면 setState가 호출되는가?
  • 버튼 아이콘/동작이 즉시 토글되는가?
  • 타이머가 여러 번 start/pause 되어도 오류 없이 동작하는가?

💡 연습 과제

  1. 타이머가 멈췄을 때 배경색을 살짝 바꿔 상태 변화를 강조해보세요.
  2. isRunning 대신 enum(TimerStatus.idle/running)을 사용해 상태를 확장해보세요.
  3. pause 후 resume 시 남은 시간을 유지하는지 확인하고, resume 버튼을 별도로 만들어보세요.

남은 시간 포맷과 포모도로 누적

🎯 이번 단계에서 배울 것

  • 상수(static const twentyFiveMinutes)로 변하지 않는 값 관리하기
  • 타이머 완료 시 포모도로 횟수(totalPomodoros) 증가 및 타이머 리셋
  • Duration과 문자열 조작으로 MM:SS 형식의 타이머 표시 만들기
  • README를 간단한 강의 안내 문구로 정리 (프로젝트 소개 업데이트)

📂 파일 구조

1
2
3
수정되는 파일
- lib/screens/home_screen.dart (포맷 함수, 포모도로 누적, 초기화 로직 추가)
- README.md (프로젝트 소개 문구 정리)

📝 1단계: 상수와 누적 상태 추가

1
2
3
4
static const twentyFiveMinutes = 1500;
int totalSeconds = twentyFiveMinutes;
bool isRunning = false;
int totalPomodoros = 0;
  • 초기 25분 값을 상수로 선언해 재사용합니다.
  • 타이머가 끝나면 totalSeconds를 상수 값으로 다시 세팅하고, totalPomodoros를 1 증가시킵니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
void onTick(Timer timer) {
if (totalSeconds == 0) {
setState(() {
totalPomodoros = totalPomodoros + 1;
isRunning = false;
totalSeconds = twentyFiveMinutes;
});
timer.cancel();
} else {
setState(() {
totalSeconds = totalSeconds - 1;
});
}
}
  • 남은 시간이 0이면 타이머를 멈추고 초기 상태로 복원한 뒤 누적 횟수를 증가시킵니다.
  • 그렇지 않으면 기존과 같이 1초씩 감소시킵니다.

📝 2단계: 시간 포맷 함수 도입

1
2
3
4
String format(int seconds) {
var duration = Duration(seconds: seconds);
return duration.toString().split(".").first.substring(2, 7);
}
  • Duration 객체를 문자열로 변환하면 0:25:00.000000 형식이 나오므로, splitsubstring으로 25:00 부분만 추출합니다.
  • 빌드 메서드에서는 format(totalSeconds)를 호출해 항상 MM:SS 형식으로 표시합니다.
1
2
3
4
Text(
format(totalSeconds),
style: TextStyle(...),
)

📝 3단계: 누적 포모도로 출력

1
2
3
4
5
6
7
8
Text(
'$totalPomodoros',
style: TextStyle(
fontSize: 58,
fontWeight: FontWeight.w600,
color: Theme.of(context).textTheme.headline1!.color,
),
),
  • 하단 카드에 누적 포모도로 수를 표시해 사용자에게 성취감을 제공합니다.
  • README.md는 다음과 같이 간단한 소개와 강의 링크만 남기도록 정리했습니다:
1
2
3
4
5
# Toonflix

Lean Flutter by making a Webtoon App

[Watch now (무료 강의)](https://nomadcoders.co/flutter-for-beginners)

📊 동작 흐름

1
2
3
4
5
6
1. 타이머 시작 → onTick이 매초 totalSeconds 감소
2. totalSeconds == 0 이 되면
a. timer.cancel()
b. totalPomodoros++, totalSeconds 초기화, isRunning = false
3. format(totalSeconds)가 MM:SS 문자열을 반환하여 타이머 텍스트에 표시
4. 하단 카드가 누적 포모도로 수를 최신 상태로 보여줌

✅ 체크리스트

  • twentyFiveMinutes 상수가 선언되고 재사용되는가?
  • 타이머가 0이 되면 자동으로 멈추고 값이 초기화되는가?
  • 시간 표시가 25:00 형식으로 나온다는 것을 확인했는가?
  • 포모도로 누적 숫자가 증가하는가?
  • README가 간결한 소개와 강의 링크만 포함하도록 업데이트됐는가?

💡 연습 과제

  1. 5분 휴식 타이머를 추가하고 완료 시 교차로 실행되도록 확장해보세요.
  2. format 함수에서 substring 대신 padLeft를 사용해 같은 결과를 만들어보세요.
  3. 포모도로가 목표치에 도달하면 알림 다이얼로그를 띄우도록 기능을 추가해보세요.

📚 전체 흐름 정리

  • 테마 적용과 화면 분리로 Pomodoro UI의 골격을 세웠습니다.
  • Timer.periodic을 도입해 1초 간격으로 남은 시간이 감소하도록 만들었습니다.
  • 재생/일시정지 토글을 구현해 타이머 제어가 가능해졌습니다.
  • 남은 시간을 MM:SS 형식으로 포맷하고, 타이머 완료 시 포모도로 횟수를 누적하도록 완성했습니다.

✅ 최종 점검 리스트

  • 메인 앱이 HomeScreen을 불러오고 테마를 재사용하는가?
  • Timer가 정상적으로 시작/일시정지/완료를 처리하는가?
  • 남은 시간이 형식화되어 있고, 포모도로 누적 수가 표시되는가?

출처 : https://nomadcoders.co/flutter-for-beginners