1. Flutter TikTok Clone - 인증(Authentication)

SignUp & Login Screen 기본 화면 만들기

🎯 이번 단계에서 배울 것

  • Flutter 앱의 첫 화면 만들기
  • 회원가입과 로그인 화면의 기본 구조
  • 화면 간 이동(Navigation)하기
  • 재사용 가능한 위젯 만들기

📂 파일 구조

이번 단계에서는 3개의 새로운 파일을 만듭니다:

1
2
3
4
5
6
7
8
lib/
└── features/
└── authentication/
├── screens/
│ ├── login_screen.dart (새로 만들기)
│ └── sign_up_screen.dart (새로 만들기)
└── widgets/
└── auth_button.dart (새로 만들기)

📝 1단계: 회원가입 화면 만들기 (sign_up_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
80
81
82
83
84
85
86
import 'package:flutter/material.dart';
import 'package:tiktok/constants/gaps.dart';
import 'package:tiktok/constants/sizes.dart';
import 'package:tiktok/features/authentication/screens/login_screen.dart';
import 'package:tiktok/features/authentication/widgets/auth_button.dart';

class SignUpScreen extends StatelessWidget {
const SignUpScreen({super.key});

// 로그인 화면으로 이동하는 함수
void onLoginTap(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LoginScreen(),
),
);
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea( // 노치나 상태바를 피해서 UI를 배치
child: Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.size20),
child: Column(
children: [
Gaps.v80, // 위쪽 여백 80

// 제목
Text(
'Sign Up for TikTok',
style: TextStyle(
fontSize: Sizes.size28,
fontWeight: FontWeight.w700,
),
),

Gaps.v20, // 여백 20

// 설명 텍스트
Text(
'Create a profile, follow other accounts, make your own videos, and more!',
style: TextStyle(
fontSize: Sizes.size16,
color: Colors.black45,
),
textAlign: TextAlign.center,
),

Gaps.v40, // 여백 40

// 회원가입 옵션 버튼들
AuthButton(text: "Use Phone or Email"),
AuthButton(text: "Continue with Facebook"),
AuthButton(text: "Continue with Apple"),
AuthButton(text: "Continue with Google"),
],
),
),
),

// 화면 하단에 고정되는 바
bottomNavigationBar: BottomAppBar(
color: Colors.grey.shade100,
elevation: 2,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Already have an account?'),
Gaps.h5,
GestureDetector( // 터치를 감지하는 위젯
onTap: () => onLoginTap(context),
child: Text(
'Log in',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
}
}

🔍 코드 설명

1. SafeArea란?

1
SafeArea(child: ...)
  • 스마트폰의 노치(화면 상단 카메라 부분)나 상태바를 피해서 UI를 안전하게 배치합니다
  • SafeArea를 사용하지 않으면 텍스트가 노치에 가려질 수 있습니다

2. Padding이란?

1
2
3
4
Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.size20),
child: ...
)
  • 화면의 양 옆(좌우)에 20픽셀의 여백을 만듭니다
  • symmetric은 “대칭적으로”라는 뜻입니다

3. Column이란?

1
2
3
Column(
children: [...],
)
  • 자식 위젯들을 세로(↓)로 배치합니다
  • Row는 가로(→)로 배치합니다

4. Navigator란?

1
2
3
4
5
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LoginScreen(),
),
)
  • 새로운 화면으로 이동합니다
  • 스마트폰의 “뒤로가기” 버튼을 누르면 이전 화면으로 돌아갑니다

📝 2단계: 로그인 화면 만들기 (login_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
import 'package:flutter/material.dart';
import 'package:tiktok/constants/gaps.dart';
import 'package:tiktok/constants/sizes.dart';

class LoginScreen extends StatelessWidget {
const LoginScreen({super.key});

// 이전 화면(회원가입)으로 돌아가는 함수
void onSignUpTap(BuildContext context) {
Navigator.of(context).pop(); // 현재 화면을 닫고 이전 화면으로
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: const SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.size20),
child: Column(
children: [
Gaps.v80,
Text(
'Login in to TikTok', // 제목
style: TextStyle(
fontSize: Sizes.size28,
fontWeight: FontWeight.w700,
),
),
Gaps.v20,
Text(
'Manage your account, check notifications, comment on videos, and more!',
style: TextStyle(
fontSize: Sizes.size16,
color: Colors.black45,
),
textAlign: TextAlign.center,
),
],
),
),
),

// 화면 하단 바
bottomNavigationBar: BottomAppBar(
color: Colors.grey.shade100,
elevation: 2,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Don\'t have an account?'), // \' 는 작은따옴표를 표시
Gaps.h5,
GestureDetector(
onTap: () => onSignUpTap(context),
child: Text(
'Sign up',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
}
}

🔍 코드 설명

Navigator.pop()이란?

1
Navigator.of(context).pop();
  • 현재 화면을 닫고 이전 화면으로 돌아갑니다
  • 스마트폰의 “뒤로가기” 버튼과 같은 동작입니다

📝 3단계: 버튼 위젯 만들기 (auth_button.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
import 'package:flutter/material.dart';
import 'package:tiktok/constants/sizes.dart';

class AuthButton extends StatelessWidget {
final String text; // 버튼에 표시될 텍스트

AuthButton({
super.key,
required this.text, // 반드시 text를 전달받아야 함
});

@override
Widget build(BuildContext context) {
return FractionallySizedBox(
widthFactor: 1, // 부모 너비의 100%
child: Container(
padding: const EdgeInsets.symmetric(
vertical: Sizes.size14,
),
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.shade300,
width: Sizes.size1,
),
),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: Sizes.size16,
fontWeight: FontWeight.w600,
),
),
),
);
}
}

🔍 코드 설명

1. FractionallySizedBox란?

1
2
3
4
FractionallySizedBox(
widthFactor: 1, // 1 = 100%, 0.5 = 50%
child: ...
)
  • 부모 위젯 크기의 비율로 자식 크기를 정합니다
  • widthFactor: 1은 부모 너비의 100%를 차지합니다

2. BoxDecoration이란?

1
2
3
4
5
6
BoxDecoration(
border: Border.all(
color: Colors.grey.shade300,
width: Sizes.size1,
),
)
  • Container를 꾸미는 데 사용합니다
  • 테두리, 배경색, 둥근 모서리 등을 설정할 수 있습니다

3. required 키워드란?

1
2
3
AuthButton({
required this.text, // 반드시 필요!
})
  • 이 값을 반드시 전달해야 합니다
  • 전달하지 않으면 에러가 발생합니다

📝 4단계: 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
import 'package:flutter/material.dart';

import 'features/authentication/screens/sign_up_screen.dart';

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

class TikTokApp extends StatelessWidget {
const TikTokApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TikTok Clone',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFE9435A)),
useMaterial3: true,
),
home: SignUpScreen(), // 앱을 시작하면 SignUpScreen이 보임
);
}
}

🎨 화면 미리보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌─────────────────────────┐
│ │
│ │
│ Sign Up for TikTok │
│ │
│ Create a profile... │
│ │
│ ┌──────────────────┐ │
│ │Use Phone or Email│ │
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │Continue with... │ │
│ └──────────────────┘ │
│ │
├─────────────────────────┤
│ Already have an account?│
│ Log in → │
└─────────────────────────┘

✅ 체크리스트

  • SignUpScreen 파일을 만들었나요?
  • LoginScreen 파일을 만들었나요?
  • AuthButton 파일을 만들었나요?
  • main.dart의 home을 SignUpScreen으로 변경했나요?
  • “Log in”을 누르면 로그인 화면으로 이동하나요?
  • 뒤로가기 버튼이 작동하나요?

💡 연습 과제

  1. 색상 바꾸기: 버튼의 테두리 색을 파란색으로 바꿔보세요
  2. 버튼 추가하기: “Continue with Twitter” 버튼을 추가해보세요
  3. 간격 조절하기: 버튼 사이의 간격을 늘려보세요 (Gaps 사용)

프로젝트 설정하기

🎯 이번 단계에서 배울 것

  • const 키워드로 성능 개선하기

📝 코드 최적화 (sign_up_screen.dart)

1
2
3
4
5
6
7
// 변경 전
body: SafeArea(
child: Padding(

// 변경 후 - const 추가
body: const SafeArea(
child: Padding(
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 변경 전
class AuthButton extends StatelessWidget {
final String text;

AuthButton({
super.key,
required this.text,
});

// 변경 후 - const 생성자와 private 변수
class AuthButton extends StatelessWidget {
final String _text; // private 변수 (앞에 _ 붙임)

const AuthButton({ // const 추가
super.key,
required String text,
}) : _text = text; // 초기화 리스트 사용

🔍 const 키워드 설명

const란?

  • 한번 만들어지면 절대 변하지 않는 값입니다
  • Flutter는 const 위젯을 재사용해서 성능이 좋아집니다

예시로 이해하기:

1
2
3
4
5
// const 없이
Text('Hello') // 화면을 다시 그릴 때마다 새로 만듦 ❌

// const 사용
const Text('Hello') // 한 번만 만들고 계속 재사용 ✅

언제 const를 사용하나요?

  • 절대 변하지 않는 UI 요소
  • 예: 고정된 텍스트, 고정된 아이콘, 고정된 간격

언제 const를 사용하면 안 되나요?

  • 변할 수 있는 값
  • 예: 사용자 입력, API에서 받아온 데이터

✅ 체크리스트

  • const 키워드를 추가했나요?
  • 앱이 여전히 잘 작동하나요?

아이콘이 있는 버튼 만들기

🎯 이번 단계에서 배울 것

  • 버튼에 아이콘 추가하기
  • Font Awesome 아이콘 사용하기
  • Stack을 사용해서 위젯 겹치기

📝 1단계: Font Awesome 패키지 확인 (pubspec.yaml)

1
2
3
4
5
6
dependencies:
flutter:
sdk: flutter

cupertino_icons: ^1.0.8
font_awesome_flutter: 10.8.0 # 아이콘 패키지

📝 2단계: AuthButton에 아이콘 추가하기

전체 코드 (auth_button.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
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:tiktok/constants/sizes.dart';

class AuthButton extends StatelessWidget {
final String _text;
final FaIcon _icon; // Font Awesome 아이콘

const AuthButton({
super.key,
required FaIcon icon, // 아이콘을 받음
required String text,
}) : _icon = icon,
_text = text;

@override
Widget build(BuildContext context) {
return FractionallySizedBox(
widthFactor: 1,
child: Container(
padding: const EdgeInsets.all(Sizes.size14), // 모든 방향에 padding
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.shade300,
width: Sizes.size1,
),
),
child: Stack( // 위젯을 겹쳐서 배치
alignment: Alignment.center, // 중앙 정렬
children: [
Align(
alignment: Alignment.centerLeft, // 왼쪽 정렬
child: _icon, // 아이콘을 왼쪽에 배치
),
Text(
_text, // 텍스트를 중앙에 배치
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: Sizes.size16,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
}

🔍 코드 설명

1. Stack이란?

1
2
3
4
5
6
Stack(
children: [
위젯1, // 먼저 그려짐 (아래층)
위젯2, // 나중에 그려짐 (위층)
],
)
  • Stack은 위젯들을 겹쳐서 배치합니다
  • 마치 종이를 겹쳐놓는 것과 같습니다

시각적 예시:

1
2
3
4
5
6
7
8
┌─────────────────────┐
│ 📱 User Phone │ ← Icon (left) + Text (center)
├─────────────────────┤
│ [Icon] Text │
│ ↑ ↑ │
│ Left Center │
└─────────────────────┘

2. Align이란?

1
2
3
4
Align(
alignment: Alignment.centerLeft, // 왼쪽 중앙
child: _icon,
)
  • Stack 안에서 위젯의 위치를 정합니다
  • centerLeft: 왼쪽 중앙
  • center: 정중앙
  • centerRight: 오른쪽 중앙

📝 3단계: SignUpScreen에서 아이콘 사용하기

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:font_awesome_flutter/font_awesome_flutter.dart';  // 추가

// ...

Gaps.v40,
AuthButton(
icon: FaIcon(FontAwesomeIcons.user), // 사용자 아이콘
text: "Use Phone or Email",
),
Gaps.v16, // 버튼 사이 간격
AuthButton(
icon: FaIcon(FontAwesomeIcons.facebook), // 페이스북 아이콘
text: "Continue with Facebook",
),
Gaps.v16,
AuthButton(
icon: FaIcon(FontAwesomeIcons.apple), // 애플 아이콘
text: "Continue with Apple",
),
Gaps.v16,
AuthButton(
icon: FaIcon(FontAwesomeIcons.google), // 구글 아이콘
text: "Continue with Google",
),

📝 4단계: LoginScreen에도 아이콘 추가하기

1
2
3
import 'package:font_awesome_flutter/font_awesome_flutter.dart';

// ... (동일하게 아이콘 추가)

🎨 화면 미리보기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌────────────────────────────┐
│ Sign Up for TikTok │
│ │
│ ┌─────────────────────┐ │
│ │ 👤 Use Phone or... │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ f Continue with │ │
│ │ Facebook │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ Continue with │ │
│ │ Apple │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ G Continue with │ │
│ │ Google │ │
│ └─────────────────────┘ │
└────────────────────────────┘

✅ 체크리스트

  • Font Awesome 패키지가 설치되어 있나요?
  • 버튼에 아이콘이 표시되나요?
  • 아이콘이 왼쪽에, 텍스트가 중앙에 있나요?
  • 버튼 사이에 적절한 간격이 있나요?

💡 연습 과제

  1. 다른 아이콘 사용하기: Font Awesome 사이트에서 다른 아이콘을 찾아 사용해보세요
  2. 아이콘 크기 변경하기: FaIcon의 size 속성을 사용해보세요
  3. 아이콘 색상 바꾸기: FaIcon의 color 속성을 사용해보세요

화면 전환과 테마 설정

🎯 이번 단계에서 배울 것

  • 버튼 클릭 시 화면 이동하기
  • 앱 전체의 테마 설정하기
  • AppBar 스타일 통일하기

📝 1단계: AuthButton에 클릭 기능 추가하기

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
class AuthButton extends StatelessWidget {
final String _text;
final FaIcon _icon;
final void Function(BuildContext)? _onTapAction; // 클릭 시 실행할 함수

const AuthButton({
super.key,
required FaIcon icon,
required String text,
Function(BuildContext)? onTapAction, // 선택사항
}) : _icon = icon,
_text = text,
_onTapAction = onTapAction;

@override
Widget build(BuildContext context) {
return GestureDetector( // 터치를 감지
onTap: () => _onTapAction?.call(context), // 클릭하면 함수 실행
child: FractionallySizedBox(
widthFactor: 1,
child: Container(
padding: const EdgeInsets.all(Sizes.size14),
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.shade300,
width: Sizes.size1,
),
),
child: Stack(
alignment: Alignment.center,
children: [
Align(alignment: Alignment.centerLeft, child: _icon),
Text(
_text,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: Sizes.size16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
}

🔍 코드 설명

1. Function(BuildContext)? 란?

1
final void Function(BuildContext)? _onTapAction;

이것을 쉽게 풀어쓰면:

  • Function(BuildContext): BuildContext를 받아서 실행하는 함수
  • void: 함수가 아무것도 반환하지 않음
  • ?: 이 값이 없을 수도 있음(null일 수도 있음)

2. ?. 연산자 (null-aware operator)

1
_onTapAction?.call(context)
  • _onTapAction이 null이 아니면 실행
  • null이면 아무것도 하지 않음
  • 에러를 방지합니다

예시로 이해하기:

1
2
3
4
5
// _onTapAction이 null이 아닐 때
_onTapAction?.call(context) // 실행됨 ✅

// _onTapAction이 null일 때
_onTapAction?.call(context) // 아무것도 안 함 (에러 안 남) ✅

📝 2단계: UsernameScreen 만들기

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
import 'package:flutter/material.dart';
import 'package:tiktok/constants/gaps.dart';
import 'package:tiktok/constants/sizes.dart';

class UsernameScreen extends StatelessWidget {
const UsernameScreen({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Sign up"),
),
body: const Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.size36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, // 왼쪽 정렬
children: [
Gaps.v40,
Text(
"Create username",
style: TextStyle(
fontSize: Sizes.size20,
fontWeight: FontWeight.w700,
),
),
Gaps.v8,
Text(
"You can always change this later",
style: TextStyle(
fontSize: Sizes.size16,
color: Colors.black54,
),
),
],
),
),
);
}
}

📝 3단계: SignUpScreen에서 화면 이동 연결하기

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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
class SignUpScreen extends StatelessWidget {
const SignUpScreen({super.key});

void onLoginTap(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}

void _onEmailTap(BuildContext context) { // 새로운 함수
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const UsernameScreen()),
);
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Sizes.size20),
child: Column(
children: [
Gaps.v80,
const Text(
'Sign Up for TikTok',
style: TextStyle(
fontSize: Sizes.size28,
fontWeight: FontWeight.w700,
),
),
Gaps.v20,
const Text(
'Create a profile, follow other accounts...',
style: TextStyle(
fontSize: Sizes.size16,
color: Colors.black45,
),
textAlign: TextAlign.center,
),
Gaps.v40,

// "Use Phone or Email" 버튼만 클릭 가능
AuthButton(
onTapAction: _onEmailTap, // 클릭 시 UsernameScreen으로 이동
icon: const FaIcon(FontAwesomeIcons.user),
text: "Use Phone or Email",
),
Gaps.v16,

// 나머지 버튼들은 아직 기능 없음
AuthButton(
onTapAction: (context) {}, // 빈 함수
icon: const FaIcon(FontAwesomeIcons.facebook),
text: "Continue with Facebook",
),
Gaps.v16,
AuthButton(
onTapAction: (context) {},
icon: const FaIcon(FontAwesomeIcons.apple),
text: "Continue with Apple",
),
Gaps.v16,
AuthButton(
onTapAction: (context) {},
icon: const FaIcon(FontAwesomeIcons.google),
text: "Continue with Google",
),
],
),
),
),

bottomNavigationBar: BottomAppBar(
color: Colors.grey.shade50, // 색상 변경
elevation: 2,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Already have an account?'),
Gaps.h5,
GestureDetector(
onTap: () => onLoginTap(context),
child: Text(
'Log in',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
);
}
}

📝 4단계: 앱 전체 테마 설정하기 (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
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import 'package:flutter/material.dart';

import 'constants/sizes.dart';
import 'features/authentication/screens/sign_up_screen.dart';

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

class TikTokApp extends StatelessWidget {
const TikTokApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TikTok Clone',
theme: ThemeData(
// 1. 배경색 설정
scaffoldBackgroundColor: Colors.white,

// 2. AppBar 테마 설정
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white, // AppBar 배경색
foregroundColor: Colors.black, // AppBar 텍스트 색
elevation: 0, // 그림자 없음
titleTextStyle: TextStyle(
color: Colors.black,
fontSize: Sizes.size16 + Sizes.size2, // 18
fontWeight: FontWeight.w600,
),
),

// 3. 기본 색상 설정
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFE9435A), // TikTok 핑크색
),
useMaterial3: true,
),
home: const SignUpScreen(),
);
}
}

🔍 코드 설명

1. ThemeData란?

  • 앱 전체의 디자인 스타일을 한 번에 설정합니다
  • 모든 화면에 같은 스타일이 적용됩니다

2. AppBarTheme 속성들:

1
2
3
4
5
6
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white, // 배경색
foregroundColor: Colors.black, // 글자/아이콘 색
elevation: 0, // 그림자 (0=없음)
titleTextStyle: TextStyle(...), // 제목 스타일
),

3. elevation이란?

1
2
elevation: 0  // 그림자 없음 (평평함)
elevation: 4 // 그림자 있음 (떠있는 느낌)
  • 숫자가 클수록 그림자가 진해집니다
  • 0이면 완전히 평평합니다

🎨 화면 흐름

1
2
3
SignUpScreen
↓ (Use Phone or Email 클릭)
UsernameScreen

✅ 체크리스트

  • “Use Phone or Email” 버튼을 클릭하면 UsernameScreen으로 이동하나요?
  • AppBar가 흰색 배경에 검은색 텍스트로 표시되나요?
  • AppBar에 그림자가 없나요?
  • 뒤로가기 버튼이 작동하나요?

💡 연습 과제

  1. 테마 색상 바꾸기: seedColor를 다른 색으로 변경해보세요
  2. AppBar에 그림자 추가: elevation 값을 바꿔보세요
  3. 다른 화면 만들기: “Continue with Facebook” 버튼에도 화면을 연결해보세요

사용자명 입력 화면

🎯 이번 단계에서 배울 것

  • 사용자 입력 받기 (TextField)
  • 입력값에 따라 화면 업데이트하기 (StatefulWidget)
  • 컨트롤러로 입력값 관리하기
  • 애니메이션 적용하기

📝 1단계: UsernameScreen을 StatefulWidget으로 변경

🤔 StatelessWidget vs StatefulWidget

StatelessWidget (상태 없음):

1
2
3
4
class MyWidget extends StatelessWidget {
// 한번 만들어지면 절대 변하지 않음
// 예: 고정된 텍스트, 고정된 아이콘
}

StatefulWidget (상태 있음):

1
2
3
4
class MyWidget extends StatefulWidget {
// 값이 변할 수 있음
// 예: 사용자 입력, 카운터, 체크박스
}

전체 코드 (username_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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import 'package:flutter/material.dart';
import 'package:tiktok/constants/gaps.dart';
import 'package:tiktok/constants/sizes.dart';

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

@override
State<UsernameScreen> createState() => _UsernameScreenState();
}

class _UsernameScreenState extends State<UsernameScreen> {
// 입력값을 관리하는 컨트롤러
final TextEditingController _userNameController = TextEditingController();

// 현재 입력된 사용자명을 저장
String _userName = "";

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

// 입력값이 변경될 때마다 호출
_userNameController.addListener(() {
setState(() { // 화면을 다시 그림
_userName = _userNameController.text; // 입력값 저장
});
});
}

@override
void dispose() {
// 메모리 정리 (중요!)
_userNameController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Sign up"),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: Sizes.size36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v40,
const Text(
"Create username",
style: TextStyle(
fontSize: Sizes.size20,
fontWeight: FontWeight.w700,
),
),
Gaps.v8,
const Text(
"You can always change this later",
style: TextStyle(
fontSize: Sizes.size16,
color: Colors.black54,
),
),
Gaps.v16,

// 텍스트 입력 필드
TextField(
controller: _userNameController, // 컨트롤러 연결
cursorColor: Theme.of(context).primaryColor,
decoration: InputDecoration(
hintText: "Username", // 힌트 텍스트
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
),
),

Gaps.v16,

// Next 버튼
FractionallySizedBox(
widthFactor: 1,
child: AnimatedContainer( // 애니메이션이 적용되는 Container
padding: const EdgeInsets.symmetric(vertical: Sizes.size16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Sizes.size5),
// 입력값이 있으면 빨간색, 없으면 회색
color: _userName.isEmpty
? Colors.grey.shade400
: Theme.of(context).primaryColor,
),
duration: const Duration(milliseconds: 500), // 애니메이션 시간
child: const Text(
'Next',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
}
}

🔍 코드 상세 설명

1. TextEditingController란?

1
final TextEditingController _userNameController = TextEditingController();
  • TextField의 입력값을 관리합니다
  • 입력값을 읽거나, 지우거나, 설정할 수 있습니다

사용 예:

1
2
3
_userNameController.text      // 현재 입력된 텍스트 가져오기
_userNameController.clear() // 입력값 지우기
_userNameController.text = "새 값" // 값 설정하기

2. initState()란?

1
2
3
4
5
@override
void initState() {
super.initState();
// 위젯이 처음 만들어질 때 한 번만 실행
}
  • 위젯이 화면에 처음 나타날 때 한 번만 실행됩니다
  • 초기 설정을 여기서 합니다

3. addListener()란?

1
2
3
4
5
_userNameController.addListener(() {
setState(() {
_userName = _userNameController.text;
});
});
  • 입력값이 변경될 때마다 실행됩니다
  • setState()를 호출하면 화면이 다시 그려집니다

동작 과정:

1
2
3
4
5
6
7
8
9
10
11
1. 사용자가 "A"를 입력

2. addListener가 감지

3. setState 실행

4. _userName = "A"

5. 화면 다시 그리기

6. 버튼 색이 빨간색으로 변경 ✅

4. dispose()란?

1
2
3
4
5
@override
void dispose() {
_userNameController.dispose(); // 메모리 정리
super.dispose();
}
  • 위젯이 화면에서 사라질 때 실행됩니다
  • 컨트롤러를 반드시 정리해야 메모리 누수가 없습니다

왜 중요할까요?

1
2
3
4
5
// dispose 하지 않으면
화면1 → 화면2 → 화면3 → ... → 메모리 가득 참! ❌

// dispose 하면
화면1 ✅ → 화면2 ✅ → 화면3 ✅ → 메모리 안전 ✅

5. setState()란?

1
2
3
setState(() {
_userName = _userNameController.text;
});
  • 상태(State)가 변경되었다고 Flutter에게 알립니다
  • Flutter는 build() 메서드를 다시 실행합니다

6. AnimatedContainer란?

1
2
3
4
AnimatedContainer(
duration: const Duration(milliseconds: 500),
color: _userName.isEmpty ? Colors.grey : Colors.red,
)
  • Container의 속성이 변하면 자동으로 애니메이션이 적용됩니다
  • duration: 애니메이션 시간

예시:

1
2
3
입력 전: [회색 버튼]
↓ (0.5초 동안 부드럽게 변화)
입력 후: [빨간색 버튼]

7. TextField의 decoration이란?

1
2
3
4
5
6
7
TextField(
decoration: InputDecoration(
hintText: "Username", // 힌트 텍스트
enabledBorder: ..., // 선택 안 됐을 때 테두리
focusedBorder: ..., // 선택됐을 때 테두리
),
)

🎨 동작 흐름

1
2
3
4
5
6
7
8
9
10
11
12
┌────────────────────────┐
│ Create username │
│ You can always... │
│ │
│ ┌─────────────────┐ │
│ │ Username_ │ │ ← 입력 중
│ └─────────────────┘ │
│ │
│ ┌─────────────────┐ │
│ │ Next │ │ ← 입력값 있으면 빨간색
│ └─────────────────┘ │ 없으면 회색
└────────────────────────┘

📊 상태 변화 시각화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 초기 상태
_userName = ""
버튼 색 = 회색

2. "J" 입력
_userName = "J"
버튼 색 = 빨간색 (0.5초 애니메이션)

3. "Jo" 입력
_userName = "Jo"
버튼 색 = 빨간색 (유지)

4. 모두 삭제
_userName = ""
버튼 색 = 회색 (0.5초 애니메이션)

✅ 체크리스트

  • TextField에 글자를 입력할 수 있나요?
  • 입력하면 버튼 색이 바뀌나요?
  • 입력을 모두 지우면 버튼이 다시 회색이 되나요?
  • 색 변화가 부드럽게 애니메이션 되나요?

💡 연습 과제

  1. 글자수 표시: 입력한 글자수를 화면에 표시해보세요

    1
    Text('${_userName.length} / 20')
  2. 글자수 제한: 20자까지만 입력되게 해보세요

    1
    2
    3
    TextField(
    maxLength: 20,
    )
  3. 텍스트 스타일 변경: 입력되는 글자의 크기나 색을 바꿔보세요

  4. 커서 색상 변경: cursorColor를 다른 색으로 바꿔보세요


재사용 가능한 버튼 위젯

🎯 이번 단계에서 배울 것

  • 공통 버튼 위젯 만들기
  • 코드 중복 제거하기
  • 다음 화면으로 데이터 전달하기
  • 함수를 메서드로 분리하기

📂 새로운 파일

1
2
lib/features/authentication/widgets/
└── form_button.dart (새로 만들기)

📝 1단계: FormButton 위젯 만들기

form_button.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
import 'package:flutter/material.dart';
import 'package:tiktok/constants/sizes.dart';

class FormButton extends StatelessWidget {
const FormButton({
super.key,
required this.disabled, // 버튼 비활성화 여부
});

final Duration duration = const Duration(milliseconds: 500);
final bool disabled; // true면 회색, false면 빨간색

@override
Widget build(BuildContext context) {
return FractionallySizedBox(
widthFactor: 1,
child: AnimatedContainer(
padding: const EdgeInsets.symmetric(vertical: Sizes.size16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Sizes.size5),
color: disabled
? Colors.grey.shade300 // 비활성화: 회색
: Theme.of(context).primaryColor, // 활성화: 빨간색
),
duration: duration, // 애니메이션 시간
child: AnimatedDefaultTextStyle( // 텍스트도 애니메이션
duration: duration,
style: TextStyle(
color: disabled ? Colors.grey.shade400 : Colors.white,
fontWeight: FontWeight.w600,
),
child: const Text(
'Next',
textAlign: TextAlign.center,
),
),
),
);
}
}

🔍 코드 설명

1. AnimatedDefaultTextStyle이란?

1
2
3
4
5
6
7
AnimatedDefaultTextStyle(
duration: Duration(milliseconds: 500),
style: TextStyle(
color: disabled ? Colors.grey : Colors.white,
),
child: Text('Next'),
)
  • Text의 스타일이 변하면 자동으로 애니메이션됩니다
  • 색상, 크기, 굵기 등이 부드럽게 변합니다

2. final 키워드란?

1
2
final bool disabled;
final Duration duration = const Duration(milliseconds: 500);
  • 한 번 값이 정해지면 절대 변하지 않습니다
  • const와의 차이:
    • const: 컴파일 시점에 값이 정해짐
    • final: 실행 시점에 값이 정해짐

예시:

1
2
const int age = 10;  // 항상 10
final int age = 계산결과; // 실행해봐야 알 수 있음

📝 2단계: UsernameScreen에서 FormButton 사용하기

수정된 username_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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
import 'package:flutter/material.dart';
import 'package:tiktok/constants/gaps.dart';
import 'package:tiktok/constants/sizes.dart';
import 'package:tiktok/features/authentication/screens/email_screen.dart';
import 'package:tiktok/features/authentication/widgets/form_button.dart';

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

@override
State<UsernameScreen> createState() => _UsernameScreenState();
}

class _UsernameScreenState extends State<UsernameScreen> {
final TextEditingController _userNameController = TextEditingController();
String _userName = "";

@override
void initState() {
super.initState();
_userNameController.addListener(() {
setState(() {
_userName = _userNameController.text;
});
});
}

@override
void dispose() {
_userNameController.dispose();
super.dispose();
}

// 다음 버튼을 눌렀을 때 실행
void _onNextTap() {
if (_userName.isEmpty) return; // 입력값 없으면 아무것도 안 함

// EmailScreen으로 이동
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const EmailScreen(),
),
);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Sign up"),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: Sizes.size36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v40,
const Text(
"Create username",
style: TextStyle(
fontSize: Sizes.size20,
fontWeight: FontWeight.w700,
),
),
Gaps.v8,
const Text(
"You can always change this later",
style: TextStyle(
fontSize: Sizes.size16,
color: Colors.black54,
),
),
Gaps.v16,
_buildUserNameInput(context), // 메서드로 분리
Gaps.v28,
GestureDetector(
onTap: _onNextTap, // 클릭 시 다음 화면으로
child: FormButton(disabled: _userName.isEmpty), // FormButton 사용
),
],
),
),
);
}

// TextField를 별도 메서드로 분리 (코드 정리)
TextField _buildUserNameInput(BuildContext context) {
return TextField(
controller: _userNameController,
cursorColor: Theme.of(context).primaryColor,
decoration: InputDecoration(
hintText: "Username",
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
),
);
}
}

🔍 코드 설명

1. _buildUserNameInput() 메서드

1
2
3
TextField _buildUserNameInput(BuildContext context) {
return TextField(...);
}

왜 메서드로 분리할까요?

  • build() 메서드가 너무 길어지는 것을 방지
  • 코드를 읽기 쉽게 만듦
  • 재사용 가능

이름 규칙:

  • _build...: 위젯을 만드는 메서드
  • _on...Tap: 클릭 이벤트 처리 메서드
  • _: private (이 파일 안에서만 사용)

2. early return 패턴

1
2
3
4
5
6
void _onNextTap() {
if (_userName.isEmpty) return; // 조건 안 맞으면 바로 종료

// 여기서부터는 _userName이 있다는 것이 보장됨
Navigator.of(context).push(...);
}

장점:

  • 코드가 간결해짐
  • 중첩된 if문을 피할 수 있음

비교:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌ 중첩된 if (읽기 어려움)
void _onNextTap() {
if (_userName.isNotEmpty) {
if (조건2) {
if (조건3) {
// 실제 코드
}
}
}
}

// ✅ early return (읽기 쉬움)
void _onNextTap() {
if (_userName.isEmpty) return;
if (!조건2) return;
if (!조건3) return;

// 실제 코드
}

📝 3단계: EmailScreen 만들기

email_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
import 'package:flutter/material.dart';
import 'package:tiktok/constants/gaps.dart';
import 'package:tiktok/constants/sizes.dart';
import 'package:tiktok/features/authentication/widgets/form_button.dart';

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

@override
State<EmailScreen> createState() => _EmailScreenScreenState();
}

class _EmailScreenScreenState extends State<EmailScreen> {
final TextEditingController _emailController = TextEditingController();
String _userEmail = "";

@override
void initState() {
super.initState();
_emailController.addListener(() {
setState(() {
_userEmail = _emailController.text;
});
});
}

@override
void dispose() {
_emailController.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Sign up"),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: Sizes.size36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v40,
const Text(
"What is your email",
style: TextStyle(
fontSize: Sizes.size20,
fontWeight: FontWeight.w700,
),
),
Gaps.v16,
_buildEmailInput(context),
Gaps.v16,
FormButton(disabled: _userEmail.isEmpty), // 같은 버튼 재사용
],
),
),
);
}

TextField _buildEmailInput(BuildContext context) {
return TextField(
controller: _emailController,
cursorColor: Theme.of(context).primaryColor,
decoration: InputDecoration(
hintText: "Email",
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
),
);
}
}

🎨 화면 흐름

1
2
3
4
5
UsernameScreen
↓ (Next 클릭)
EmailScreen
↓ (Next 클릭)
PasswordScreen (다음 단계에서 구현)

📊 FormButton 재사용의 장점

재사용 전 (코드 중복):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// UsernameScreen
AnimatedContainer(
color: _userName.isEmpty ? Grey : Red,
// ... 50줄의 코드
)

// EmailScreen
AnimatedContainer(
color: _email.isEmpty ? Grey : Red,
// ... 50줄의 코드 (똑같은 내용 반복!)
)

// PasswordScreen
AnimatedContainer(
color: _password.isEmpty ? Grey : Red,
// ... 50줄의 코드 (또 반복!)
)

재사용 후 (간결):

1
2
3
4
5
6
7
8
// UsernameScreen
FormButton(disabled: _userName.isEmpty) // 1줄!

// EmailScreen
FormButton(disabled: _email.isEmpty) // 1줄!

// PasswordScreen
FormButton(disabled: _password.isEmpty) // 1줄!

✅ 체크리스트

  • UsernameScreen에서 Next 버튼이 작동하나요?
  • EmailScreen이 표시되나요?
  • 두 화면 모두 버튼 색이 입력에 따라 변하나요?
  • 버튼의 텍스트 색도 함께 변하나요?

💡 연습 과제

  1. 버튼 텍스트 변경 가능하게: FormButton에 text 파라미터를 추가해보세요
  2. 애니메이션 시간 변경: duration을 바꿔서 빠르게/느리게 만들어보세요
  3. 버튼 모양 바꾸기: borderRadius를 조절해서 더 둥글게 만들어보세요
  4. 전화번호 입력 화면: EmailScreen을 참고해서 PhoneScreen을 만들어보세요

이메일 입력과 유효성 검사

🎯 이번 단계에서 배울 것

  • 이메일 형식 검사하기 (정규식)
  • 에러 메시지 표시하기
  • 키보드 타입 설정하기
  • 키보드 닫기
  • 화면 터치 시 키보드 자동 닫기

📝 1단계: constants.dart 파일 만들기

constants/constants.dart:

1
2
export 'gaps.dart';
export 'sizes.dart';

🔍 export란?

1
2
3
4
5
6
7
// constants.dart
export 'gaps.dart';
export 'sizes.dart';

// 다른 파일에서
import 'package:tiktok/constants/constants.dart';
// → gaps.dart와 sizes.dart를 한 번에 import!

장점:

1
2
3
4
5
6
// export 사용 전
import 'package:tiktok/constants/gaps.dart';
import 'package:tiktok/constants/sizes.dart';

// export 사용 후
import 'package:tiktok/constants/constants.dart'; // 한 줄로 끝!

📝 2단계: EmailScreen 완성하기

전체 코드 (email_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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
import 'package:flutter/material.dart';
import 'package:tiktok/constants/constants.dart';

import '../widgets/form_button.dart';
import 'password_screen.dart';

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

@override
State<EmailScreen> createState() => _EmailScreenScreenState();
}

class _EmailScreenScreenState extends State<EmailScreen> {
final TextEditingController _emailController = TextEditingController();
String _email = "";

@override
void initState() {
super.initState();
_emailController.addListener(() {
setState(() {
_email = _emailController.text;
});
});
}

@override
void dispose() {
_emailController.dispose();
super.dispose();
}

// 이메일 유효성 검사
String? _isEmailValid() {
if (_email.isEmpty) return null; // 입력 안 했으면 에러 없음

// 정규식으로 이메일 형식 확인
final regExp = RegExp(
r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
);

if (!regExp.hasMatch(_email)) {
return "Email not valid"; // 형식이 틀리면 에러 메시지
}

return null; // 형식이 맞으면 에러 없음
}

// 화면 터치 시 키보드 닫기
void _onScaffoldTap() {
FocusScope.of(context).unfocus();
}

// 제출 버튼 클릭 or 키보드에서 완료 버튼 클릭
void _onSubmit() {
if (_email.isEmpty || _isEmailValid() != null) return; // 검증 실패 시 중단

Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const PasswordScreen(),
),
);
}

@override
Widget build(BuildContext context) {
return GestureDetector( // 화면 전체에 터치 감지
onTap: _onScaffoldTap, // 터치하면 키보드 닫기
child: Scaffold(
appBar: AppBar(
title: const Text("Sign up"),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: Sizes.size36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v40,
const Text(
"What is your email",
style: TextStyle(
fontSize: Sizes.size20,
fontWeight: FontWeight.w700,
),
),
Gaps.v16,
_buildEmailInput(context),
Gaps.v16,
GestureDetector(
onTap: _onSubmit,
child: FormButton(
disabled: _email.isEmpty || _isEmailValid() != null,
),
),
],
),
),
),
);
}

TextField _buildEmailInput(BuildContext context) {
return TextField(
controller: _emailController,
cursorColor: Theme.of(context).primaryColor,
keyboardType: TextInputType.emailAddress, // 이메일 키보드
autocorrect: false, // 자동 수정 끄기
onEditingComplete: _onSubmit, // 키보드의 완료 버튼
decoration: InputDecoration(
hintText: "Email",
errorText: _isEmailValid(), // 에러 메시지 표시
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
),
);
}
}

🔍 코드 상세 설명

1. 정규식(RegExp)이란?

1
2
3
final regExp = RegExp(
r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
);

정규식은 문자열 패턴을 표현하는 방법입니다.

쉽게 풀어쓰면:

1
2
3
4
5
6
7
^                        시작
[a-zA-Z0-9.!#$%...]+ 영문자, 숫자, 특수문자 (1개 이상)
@ @ 기호 (필수!)
[a-zA-Z0-9.-]+ 도메인 이름
\. 점(.) (필수!)
[a-zA-Z]{2,} 최상위 도메인 (com, kr 등, 2글자 이상)
$ 끝

검증 예시:

1
2
3
4
5
"test@example.com"     ✅ 통과
"user@gmail.com" ✅ 통과
"test@test" ❌ 실패 (.com 등이 없음)
"test.com" ❌ 실패 (@ 없음)
"@example.com" ❌ 실패 (@ 앞에 아무것도 없음)

2. hasMatch() 메서드

1
2
3
if (!regExp.hasMatch(_email)) {
return "Email not valid";
}
  • hasMatch(): 문자열이 정규식 패턴과 일치하는지 확인
  • !: not (반대)
  • “일치하지 않으면” 에러 메시지 반환

3. FocusScope.unfocus()란?

1
2
3
void _onScaffoldTap() {
FocusScope.of(context).unfocus();
}
  • 현재 포커스를 제거 → 키보드가 내려감
  • 사용자 경험(UX) 개선

동작 예시:

1
2
1. TextField 클릭 → 키보드 올라옴
2. 화면 빈 공간 터치 → 키보드 내려감 ✅

4. keyboardType이란?

1
2
3
TextField(
keyboardType: TextInputType.emailAddress,
)

다양한 키보드 타입:

1
2
3
4
5
TextInputType.emailAddress   // 이메일 (@포함)
TextInputType.phone // 전화번호 (숫자)
TextInputType.number // 일반 숫자
TextInputType.text // 일반 텍스트 (기본값)
TextInputType.url // URL (.com 등 포함)

5. autocorrect란?

1
2
3
TextField(
autocorrect: false, // 자동 수정 끄기
)
  • 이메일 주소는 자동으로 수정되면 안 됩니다!
  • 예: “gmail”이 “Gmail”로 바뀌면 안 됨

6. onEditingComplete란?

1
2
3
TextField(
onEditingComplete: _onSubmit,
)
  • 키보드의 “완료”, “다음”, “검색” 버튼을 눌렀을 때 실행
  • 엔터키를 누른 것과 같음

7. errorText란?

1
2
3
decoration: InputDecoration(
errorText: _isEmailValid(), // 에러 메시지
)
  • null이면: 에러 없음 (정상)
  • "문자열"이면: 에러 메시지 표시

동작 예시:

1
2
3
4
5
6
7
8
입력: ""
errorText: null → 에러 없음

입력: "test"
errorText: "Email not valid" → 빨간 글씨로 에러 표시

입력: "test@test.com"
errorText: null → 에러 없음

📝 3단계: PasswordScreen 기본 틀 만들기

password_screen.dart:

1
2
3
4
5
6
7
8
9
10
import 'package:flutter/material.dart';

class PasswordScreen extends StatelessWidget {
const PasswordScreen({super.key});

@override
Widget build(BuildContext context) {
return const Placeholder(); // 임시 화면
}
}

🎨 이메일 검증 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1. 입력: ""
→ errorText: null
→ 에러 없음
→ 버튼: 회색 (disabled)

2. 입력: "test"
→ errorText: "Email not valid"
→ 에러 메시지 빨간색으로 표시
→ 버튼: 회색 (disabled)

3. 입력: "test@"
→ errorText: "Email not valid"
→ 에러 메시지 표시
→ 버튼: 회색 (disabled)

4. 입력: "test@test.com"
→ errorText: null
→ 에러 없음
→ 버튼: 빨간색 (활성화) ✅

📱 키보드 동작

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────────┐
│ What is your email │
│ │
│ test@test.com_ │ ← 입력 중
│ ───────────────── │
├──────────────────────┤
│ ⌨️ Email Keyboard │ ← @키 포함
│ │
│ [1][2][3][4][5].. │
│ [q][w][e][r][@].. │ ← @ 쉽게 입력
│ [a][s][d][f][g].. │
│ [ Done ] │ ← 누르면 _onSubmit 실행
└──────────────────────┘

✅ 체크리스트

  • 이메일 형식이 틀리면 에러 메시지가 보이나요?
  • 올바른 이메일을 입력하면 Next 버튼이 활성화되나요?
  • 키보드가 이메일 타입으로 표시되나요? (@ 포함)
  • 화면을 터치하면 키보드가 내려가나요?
  • 키보드의 완료 버튼이 작동하나요?

💡 연습 과제

  1. 다른 정규식 시도하기: 더 간단한 이메일 검증 정규식을 만들어보세요

    1
    RegExp(r"^.+@.+\..+$")  // 가장 간단한 형태
  2. 에러 메시지 다국어: 한글 에러 메시지를 추가해보세요

    1
    return "이메일 형식이 올바르지 않습니다";
  3. 이메일 도메인 확인: 특정 도메인만 허용하도록 만들어보세요

    1
    2
    3
    if (!_email.endsWith("@gmail.com")) {
    return "Gmail만 사용 가능합니다";
    }
  4. 대문자 자동 변환 끄기: TextField에 다음을 추가해보세요

    1
    textCapitalization: TextCapitalization.none,

비밀번호 입력 화면

🎯 이번 단계에서 배울 것

  • 비밀번호 입력 필드 만들기
  • 비밀번호 보이기/숨기기 기능
  • 실시간 비밀번호 강도 검사
  • 정규식으로 복잡한 조건 검증하기
  • TextField suffix 사용하기

📂 새로운 파일

1
2
3
4
5
6
lib/constants/
└── constants.dart (이미 생성됨)

lib/features/authentication/screens/
└── password_screen.dart (수정)
└── birthday_screen.dart (빈 파일 생성)

📝 1단계: PasswordScreen 완성하기

전체 코드 (password_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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:tiktok/constants/constants.dart';

import '../widgets/form_button.dart';
import 'birthday_screen.dart';

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

@override
State<PasswordScreen> createState() => _PasswordScreenState();
}

class _PasswordScreenState extends State<PasswordScreen> {
final TextEditingController _passwordController = TextEditingController();
String _password = "";
bool _obscureText = true; // true: 비밀번호 숨김, false: 보임

@override
void initState() {
super.initState();
_passwordController.addListener(() {
setState(() {
_password = _passwordController.text;
});
});
}

@override
void dispose() {
_passwordController.dispose();
super.dispose();
}

// 비밀번호 유효성 검사
bool _isPasswordValid() {
// 8~20자, 대문자, 소문자, 숫자, 특수문자 모두 포함
final regExp = RegExp(
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,20}$"
);

if (!regExp.hasMatch(_password)) return false;
return true;
}

// 화면 터치 시 키보드 닫기
void _onScaffoldTap() {
FocusScope.of(context).unfocus();
}

// 비밀번호 지우기
void _onClearTap() {
_passwordController.clear();
}

// 비밀번호 보이기/숨기기 토글
void _onVisibilityTap() {
setState(() {
_obscureText = !_obscureText; // 반대로 변경
});
}

// 다음 화면으로 이동
void _onSubmit() {
if (!_isPasswordValid()) return; // 검증 실패 시 중단

Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const BirthdayScreen(),
),
);
}

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onScaffoldTap,
child: Scaffold(
appBar: AppBar(
title: const Text("Sign up"),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: Sizes.size36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v40,
const Text(
"Password",
style: TextStyle(
fontSize: Sizes.size20,
fontWeight: FontWeight.w700,
),
),
Gaps.v16,
_buildEmailInput(context),
Gaps.v10,

// 비밀번호 요구사항 제목
const Text(
'Your password must have:',
style: TextStyle(
fontSize: Sizes.size16,
fontWeight: FontWeight.w700,
),
),
Gaps.v4,

// 조건 1: 글자수
Row(
children: [
FaIcon(
FontAwesomeIcons.circleCheck,
size: Sizes.size20,
color: _isPasswordValid()
? Colors.green
: Colors.grey.shade400,
),
Gaps.h5,
const Text("8 to 20 characters"),
],
),
Gaps.v4,

// 조건 2: 문자 종류
Row(
children: [
FaIcon(
FontAwesomeIcons.circleCheck,
size: Sizes.size20,
color: _isPasswordValid()
? Colors.green
: Colors.grey.shade400,
),
Gaps.h5,
const Text("Letters, numbers, and special characters"),
],
),
Gaps.v28,

// Next 버튼
GestureDetector(
onTap: _onSubmit,
child: FormButton(disabled: !_isPasswordValid()),
),
],
),
),
),
);
}

// 비밀번호 입력 필드
TextField _buildEmailInput(BuildContext context) {
return TextField(
controller: _passwordController,
cursorColor: Theme.of(context).primaryColor,
onEditingComplete: _onSubmit,
obscureText: _obscureText, // 비밀번호 숨김 여부
decoration: InputDecoration(
hintText: "Make it strong!",
suffix: Row(
mainAxisSize: MainAxisSize.min, // 자식 크기만큼만 차지
children: [
// 지우기 버튼
GestureDetector(
onTap: _onClearTap,
child: FaIcon(
FontAwesomeIcons.solidCircleXmark,
color: Colors.grey.shade500,
size: Sizes.size20,
),
),
Gaps.h5,

// 보이기/숨기기 버튼
GestureDetector(
onTap: _onVisibilityTap,
child: FaIcon(
_obscureText
? FontAwesomeIcons.solidEye // 숨김 상태 → 눈 아이콘
: FontAwesomeIcons.solidEyeSlash, // 보임 상태 → 눈 금지 아이콘
color: Colors.grey.shade500,
size: Sizes.size20,
),
),
],
),
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
),
);
}
}

🔍 코드 상세 설명

1. obscureText란?

1
2
3
4
TextField(
obscureText: true, // ••••••••
obscureText: false, // password123
)
  • true: 비밀번호가 점(•)으로 표시됨
  • false: 비밀번호가 그대로 보임

2. 복잡한 정규식 이해하기

1
r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,20}$"

각 부분 설명:

1
2
3
4
5
6
7
8
^                    시작
(?=.*[a-z]) 최소 1개의 소문자 포함
(?=.*[A-Z]) 최소 1개의 대문자 포함
(?=.*\d) 최소 1개의 숫자 포함
(?=.*[@$!%*?&]) 최소 1개의 특수문자 포함
[A-Za-z\d@$!%*?&] 허용되는 문자들
{8,20} 8자 이상 20자 이하
$ 끝

검증 예시:

1
2
3
4
5
6
"password"           ❌ (대문자, 숫자, 특수문자 없음)
"Password" ❌ (숫자, 특수문자 없음)
"Password1" ❌ (특수문자 없음)
"Password1!" ✅ (모든 조건 만족!)
"Pass1!" ❌ (8자 미만)
"VeryLongPassword123!@#$%123" ❌ (20자 초과)

3. suffix란?

1
2
3
4
5
decoration: InputDecoration(
suffix: Row(
children: [아이콘1, 아이콘2],
),
)
  • TextField의 오른쪽 끝에 위젯을 배치합니다
  • prefix: 왼쪽 끝
  • suffix: 오른쪽 끝

시각화:

1
2
3
┌────────────────────────┐
│ Input______ [X] [👁] │ ← suffix
└────────────────────────┘

4. MainAxisSize.min이란?

1
2
3
4
Row(
mainAxisSize: MainAxisSize.min, // 자식 크기만큼
children: [아이콘1, 아이콘2],
)
  • MainAxisSize.max: 가능한 모든 공간 차지 (기본값)
  • MainAxisSize.min: 자식들의 크기만큼만 차지

비교:

1
2
3
4
5
// MainAxisSize.max (기본값)
[아이콘1, 아이콘2 ] ← 공간 다 차지

// MainAxisSize.min
[아이콘1, 아이콘2] ← 필요한 만큼만

5. !_isPasswordValid()의 의미

1
FormButton(disabled: !_isPasswordValid())
  • !: not (반대)
  • _isPasswordValid(): true면 유효, false면 무효
  • !_isPasswordValid(): true면 무효, false면 유효

논리:

1
2
3
4
5
6
7
8
9
비밀번호 유효 → _isPasswordValid() = true
→ !_isPasswordValid() = false
→ disabled: false
→ 버튼 활성화 ✅

비밀번호 무효 → _isPasswordValid() = false
→ !_isPasswordValid() = true
→ disabled: true
→ 버튼 비활성화 ❌

📝 2단계: BirthdayScreen 빈 파일 만들기

birthday_screen.dart:

1
2
3
4
5
6
7
8
9
10
import 'package:flutter/material.dart';

class BirthdayScreen extends StatelessWidget {
const BirthdayScreen({super.key});

@override
Widget build(BuildContext context) {
return const Placeholder(); // 임시 화면
}
}

🎨 비밀번호 검증 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1. 입력: ""
→ 검증: false
→ 체크 아이콘: 회색
→ 버튼: 회색 (비활성화)

2. 입력: "pass"
→ 검증: false (너무 짧음, 대문자/숫자/특수문자 없음)
→ 체크 아이콘: 회색
→ 버튼: 회색 (비활성화)

3. 입력: "Password"
→ 검증: false (숫자/특수문자 없음)
→ 체크 아이콘: 회색
→ 버튼: 회색 (비활성화)

4. 입력: "Password1!"
→ 검증: true ✅
→ 체크 아이콘: 초록색 ✅
→ 버튼: 빨간색 (활성화) ✅

📱 비밀번호 필드 동작

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌──────────────────────────┐
│ Password │
│ │
│ •••••••• [X] [👁] │ ← 숨김 상태
│ ────────── │
│ │
│ Your password must have:│
│ ✓ 8 to 20 characters │ ← 초록색 (충족)
│ ✓ Letters, numbers... │ ← 초록색 (충족)
│ │
│ ┌──────────────────┐ │
│ │ Next │ │ ← 빨간색 (활성화)
│ └──────────────────┘ │
└──────────────────────────┘

눈 아이콘 클릭 후:

1
2
3
4
┌──────────────────────────┐
│ Password1! [X] [👁🚫] │ ← 보임 상태
│ ────────── │
└──────────────────────────┘

✅ 체크리스트

  • 비밀번호가 점(•)으로 표시되나요?
  • 눈 아이콘을 클릭하면 비밀번호가 보이나요?
  • X 버튼을 클릭하면 입력값이 지워지나요?
  • 조건을 만족하면 체크 아이콘이 초록색으로 변하나요?
  • 유효한 비밀번호일 때 Next 버튼이 활성화되나요?

💡 연습 과제

  1. 개별 조건 체크: 각 조건(글자수, 대문자, 소문자, 숫자, 특수문자)을 개별적으로 체크하는 함수를 만들어보세요

    1
    2
    bool _hasMinLength() => _password.length >= 8;
    bool _hasUpperCase() => _password.contains(RegExp(r'[A-Z]'));
  2. 비밀번호 강도 표시: 약함/보통/강함을 색으로 표시해보세요

  3. 비밀번호 확인 필드: 비밀번호 확인 입력 필드를 추가하고 일치 여부를 확인해보세요

  4. 실시간 힌트: 입력 중에 부족한 조건을 빨간색으로 표시해보세요


생년월일 선택 화면

🎯 이번 단계에서 배울 것

  • iOS 스타일 날짜 선택기 사용하기
  • CupertinoDatePicker 사용법
  • DateTime 다루기
  • TextField를 읽기 전용으로 만들기
  • BottomAppBar에 위젯 배치하기

📝 1단계: BirthdayScreen 완성하기

전체 코드 (birthday_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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import 'package:flutter/cupertino.dart' show CupertinoDatePicker, CupertinoDatePickerMode;
import 'package:flutter/material.dart';
import 'package:tiktok/constants/constants.dart';
import 'package:tiktok/features/onboarding/interests_screen.dart';

import '../widgets/form_button.dart';

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

@override
State<BirthdayScreen> createState() => _BirthdayScreenState();
}

class _BirthdayScreenState extends State<BirthdayScreen> {
final TextEditingController _birthdayController = TextEditingController();

DateTime initialDateTime = DateTime.now(); // 오늘 날짜

@override
void initState() {
super.initState();
_setTextFieldDate(initialDateTime); // 초기값 설정
}

@override
void dispose() {
_birthdayController.dispose();
super.dispose();
}

// 다음 화면으로 이동
void _onNextTap() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const InterestsScreen(),
),
);
}

// TextField에 날짜 표시
void _setTextFieldDate(DateTime date) {
final textDate = date.toString().split(" ").first; // "2025-01-15"
_birthdayController.value = TextEditingValue(text: textDate);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Sign up"),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: Sizes.size36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gaps.v40,
const Text(
"When's your birthday?",
style: TextStyle(
fontSize: Sizes.size20,
fontWeight: FontWeight.w700,
),
),
Gaps.v8,
const Text(
"Your birthday won't be shown publicly",
style: TextStyle(
fontSize: Sizes.size16,
color: Colors.black54,
),
),
Gaps.v16,
_buildUserNameInput(context),
Gaps.v28,
GestureDetector(
onTap: _onNextTap,
child: FormButton(disabled: false), // 항상 활성화
),
],
),
),

// 하단에 날짜 선택기 배치
bottomNavigationBar: BottomAppBar(
height: 300, // 높이 설정
child: CupertinoDatePicker(
initialDateTime: initialDateTime, // 초기 날짜
maximumDate: initialDateTime, // 선택 가능한 최대 날짜 (오늘까지만)
mode: CupertinoDatePickerMode.date, // 날짜만 선택
onDateTimeChanged: _setTextFieldDate, // 날짜 변경 시 호출
),
),
);
}

// 읽기 전용 TextField
TextField _buildUserNameInput(BuildContext context) {
return TextField(
controller: _birthdayController,
cursorColor: Theme.of(context).primaryColor,
readOnly: true, // 직접 입력 불가 (날짜 선택기로만 변경)
decoration: InputDecoration(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
),
);
}
}

🔍 코드 상세 설명

1. DateTime이란?

1
DateTime initialDateTime = DateTime.now();  // 현재 날짜와 시간

DateTime 속성들:

1
2
3
4
5
6
DateTime now = DateTime.now();
print(now.year); // 2025
print(now.month); // 10
print(now.day); // 5
print(now.hour); // 18
print(now.minute); // 30

DateTime 만들기:

1
2
DateTime birthday = DateTime(2000, 1, 15);  // 2000년 1월 15일
DateTime exact = DateTime(2025, 10, 5, 18, 30); // 2025년 10월 5일 18시 30분

2. .toString().split(“ “).first 설명

1
final textDate = date.toString().split(" ").first;

단계별 분해:

1
2
3
4
5
6
7
8
9
10
11
12
13
DateTime date = DateTime(2025, 10, 5, 18, 30);

// 1단계: toString()
date.toString()
// → "2025-10-05 18:30:00.000"

// 2단계: split(" ")
.split(" ")
// → ["2025-10-05", "18:30:00.000"]

// 3단계: .first
.first
// → "2025-10-05"

시각화:

1
2
3
4
5
6
7
DateTime(2025, 10, 5, 18, 30)
↓ .toString()
"2025-10-05 18:30:00.000"
↓ .split(" ")
["2025-10-05", "18:30:00.000"]
↓ .first
"2025-10-05" ← 최종 결과!

3. TextEditingValue란?

1
_birthdayController.value = TextEditingValue(text: textDate);
  • TextField의 값을 직접 설정합니다
  • _birthdayController.text = textDate와 비슷하지만 더 안전합니다

4. readOnly란?

1
2
3
TextField(
readOnly: true, // 직접 입력 불가
)
  • 사용자가 키보드로 직접 입력할 수 없습니다
  • 날짜 선택기로만 값을 변경할 수 있습니다
  • 키보드가 올라오지 않습니다

5. CupertinoDatePicker 속성들

1
2
3
4
5
6
7
CupertinoDatePicker(
initialDateTime: DateTime.now(), // 처음 표시할 날짜
maximumDate: DateTime.now(), // 선택 가능한 최대 날짜
minimumDate: DateTime(1900), // 선택 가능한 최소 날짜
mode: CupertinoDatePickerMode.date, // 날짜만/시간만/날짜+시간
onDateTimeChanged: (date) { }, // 날짜 변경 시 호출
)

mode 옵션들:

1
2
3
CupertinoDatePickerMode.date          // 날짜만 (년/월/일)
CupertinoDatePickerMode.time // 시간만 (시:분)
CupertinoDatePickerMode.dateAndTime // 날짜 + 시간

6. import 구문 설명

1
import 'package:flutter/cupertino.dart' show CupertinoDatePicker, CupertinoDatePickerMode;
  • show: 특정 클래스만 import
  • 전체를 import하지 않고 필요한 것만 가져옵니다
  • 파일 크기를 줄이고 충돌을 방지합니다

비교:

1
2
3
4
5
// 전체 import (무거움)
import 'package:flutter/cupertino.dart';

// 필요한 것만 import (가벼움)
import 'package:flutter/cupertino.dart' show CupertinoDatePicker, CupertinoDatePickerMode;

📝 2단계: InterestsScreen 빈 파일 만들기

onboarding/interests_screen.dart:

1
2
3
4
5
6
7
8
9
10
import 'package:flutter/material.dart';

class InterestsScreen extends StatelessWidget {
const InterestsScreen({super.key});

@override
Widget build(BuildContext context) {
return const Placeholder(); // 임시 화면
}
}

📝 3단계: main.dart 수정

main.dart:

1
2
3
4
5
6
7
theme: ThemeData(
scaffoldBackgroundColor: Colors.white,
appBarTheme: const AppBarTheme(...),
primaryColor: const Color(0xFFE9435A),
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFE9435A)),
useMaterial3: false, // Material 2 사용 (중요!)
),

useMaterial3를 false로 하는 이유:

  • CupertinoDatePicker가 Material 3에서 제대로 작동하지 않을 수 있습니다
  • 일관된 디자인을 위해 Material 2를 사용합니다

🎨 화면 구성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌──────────────────────────┐
│ ← Sign up │
├──────────────────────────┤
│ │
│ When's your birthday? │
│ Your birthday won't... │
│ │
│ 2025-10-05 │ ← 읽기 전용
│ ────────── │
│ │
│ ┌──────────────────┐ │
│ │ Next │ │
│ └──────────────────┘ │
│ │
├──────────────────────────┤
│ ⚙️ Date Picker │
│ ┌──────┬─────┬──────┐ │
│ │ 2024 │ 10 │ 4 │ │
│ │ 2025 │ 11 │ 5 │ │ ← iOS 스타일
│ │ 2026 │ 12 │ 6 │ │
│ └──────┴─────┴──────┘ │
│ year month day │
└──────────────────────────┘

📊 날짜 선택 흐름

1
2
3
4
5
6
7
8
9
10
11
12
1. 화면 로드
→ initialDateTime = 오늘 (2025-10-05)
→ TextField에 "2025-10-05" 표시

2. 날짜 선택기에서 스크롤
→ 2000년 1월 15일 선택

3. onDateTimeChanged 호출
→ _setTextFieldDate(2000-01-15)

4. TextField 업데이트
→ "2000-01-15" 표시

✅ 체크리스트

  • 화면 로드 시 오늘 날짜가 표시되나요?
  • TextField를 클릭해도 키보드가 올라오지 않나요?
  • 날짜 선택기에서 날짜를 바꾸면 TextField가 업데이트되나요?
  • 오늘 이후의 날짜는 선택할 수 없나요?
  • iOS 스타일의 날짜 선택기가 표시되나요?

💡 연습 과제

  1. 최소 연령 제한: 13세 이상만 가입 가능하도록 만들어보세요

    1
    DateTime maximumDate = DateTime.now().subtract(Duration(days: 365 * 13));
  2. 날짜 형식 변경: “2025-10-05” 대신 “2025년 10월 5일”로 표시해보세요

    1
    "${date.year}${date.month}${date.day}일"
  3. 나이 표시: 선택한 생년월일로 현재 나이를 계산해서 표시해보세요

    1
    int age = DateTime.now().year - selectedDate.year;
  4. 날짜 선택기 높이 조절: BottomAppBar의 height를 바꿔보세요


로그인 폼 만들기

🎯 이번 단계에서 배울 것

  • Form과 FormState 사용하기
  • TextFormField로 입력 검증하기
  • 여러 입력값을 한 번에 검증하기
  • GlobalKey 사용하기
  • Map으로 데이터 저장하기

📝 1단계: FormButton에 텍스트 파라미터 추가

수정된 form_button.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
import 'package:flutter/material.dart';
import 'package:tiktok/constants/constants.dart';

class FormButton extends StatelessWidget {
const FormButton({
super.key,
required this.disabled,
String text = "Next", // 기본값 설정
}) : _text = text;

final Duration duration = const Duration(milliseconds: 500);
final bool disabled;
final String _text; // 추가

@override
Widget build(BuildContext context) {
return FractionallySizedBox(
widthFactor: 1,
child: AnimatedContainer(
padding: const EdgeInsets.symmetric(vertical: Sizes.size16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Sizes.size5),
color: disabled
? Colors.grey.shade300
: Theme.of(context).primaryColor,
),
duration: duration,
child: AnimatedDefaultTextStyle(
duration: duration,
style: TextStyle(
color: disabled ? Colors.grey.shade400 : Colors.white,
fontWeight: FontWeight.w600,
),
child: Text(
_text, // 동적으로 변경 가능
textAlign: TextAlign.center,
),
),
),
);
}
}

사용 예:

1
2
FormButton(disabled: false)  // "Next" 표시
FormButton(disabled: false, text: "Login") // "Login" 표시

📝 2단계: LoginFormScreen 완성하기

전체 코드 (login_form_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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
import 'package:flutter/material.dart';
import 'package:tiktok/constants/constants.dart';
import 'package:tiktok/features/authentication/widgets/form_button.dart';

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

@override
State<LoginFormScreen> createState() => _LoginFormScreenState();
}

class _LoginFormScreenState extends State<LoginFormScreen> {
// Form을 제어하는 Key
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

// 비밀번호 보이기/숨기기
bool _isPasswordVisible = false;

// 입력한 데이터를 저장할 Map
final Map<String, String> _formData = {};

// 비밀번호 보이기/숨기기 토글
void _onVisibilityTap() {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
}

// 이메일 검증
String? _isValidEmail(String? value) {
final emailRegex = RegExp(r'^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,7}$');
if (value == null || value.isEmpty) {
return 'Email cannot be empty';
}
return null; // 검증 통과
}

// 비밀번호 검증
String? _isPasswordValid(String? value) {
if (value == null || value.isEmpty) {
return 'Password cannot be empty';
}
return null; // 검증 통과
}

// 로그인 버튼 클릭
void _onSubmitTap() {
// 모든 필드 검증
final isValid = _formKey.currentState?.validate() ?? false;

if (isValid) {
// 검증 통과 시 모든 필드의 값 저장
_formKey.currentState?.save();
// TODO: 실제 로그인 처리
print(_formData); // 디버그용
}
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Log in"),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: Sizes.size36),
child: Form( // Form 위젯으로 감싸기
key: _formKey, // Key 연결
child: Column(
children: [
Gaps.v28,

// 이메일 입력 필드
TextFormField(
decoration: InputDecoration(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
hintText: "Email",
),
validator: _isValidEmail, // 검증 함수
onSaved: (value) { // 저장 함수
if (value != null) {
_formData['email'] = value;
}
},
),
Gaps.v16,

// 비밀번호 입력 필드
TextFormField(
obscureText: _isPasswordVisible, // 비밀번호 숨기기
decoration: InputDecoration(
enabledBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Colors.grey.shade400),
),
hintText: "Password",
suffix: GestureDetector(
onTap: _onVisibilityTap,
child: _isPasswordVisible
? const Icon(Icons.visibility)
: const Icon(Icons.visibility_off),
),
),
validator: _isPasswordValid, // 검증 함수
onSaved: (value) { // 저장 함수
if (value != null) {
_formData['password'] = value;
}
},
),
Gaps.v28,

// 로그인 버튼
GestureDetector(
onTap: _onSubmitTap,
child: FormButton(
disabled: false,
text: 'Login',
),
),
],
),
),
),
);
}
}

🔍 코드 상세 설명

1. GlobalKey란?

1
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
  • Form의 상태(State)를 제어하는 열쇠입니다
  • 이 Key로 Form의 메서드를 호출할 수 있습니다

Key 사용법:

1
2
3
_formKey.currentState?.validate()  // 모든 필드 검증
_formKey.currentState?.save() // 모든 필드 저장
_formKey.currentState?.reset() // 모든 필드 초기화

2. Form 위젯이란?

1
2
3
4
5
6
7
8
9
Form(
key: _formKey,
child: Column(
children: [
TextFormField(...), // 필드1
TextFormField(...), // 필드2
],
),
)
  • 여러 TextFormField를 그룹으로 묶습니다
  • 모든 필드를 한 번에 검증/저장할 수 있습니다

3. TextFormField vs TextField

TextField:

1
2
3
4
5
6
7
8
TextField(
// 개별적으로 검증해야 함
onChanged: (value) {
if (value.isEmpty) {
// 에러 처리
}
},
)

TextFormField:

1
2
3
4
5
6
7
8
TextFormField(
validator: (value) { // 검증 함수만 지정
if (value == null || value.isEmpty) {
return 'Error message';
}
return null;
},
)

4. validator란?

1
2
3
4
5
6
7
8
9
10
11
validator: (value) {
// value: 사용자가 입력한 값

// 에러가 있으면 에러 메시지 반환
if (value == null || value.isEmpty) {
return 'Email cannot be empty';
}

// 문제없으면 null 반환
return null;
},

반환값의 의미:

1
2
return null;         // 검증 통과 ✅
return "에러 메시지"; // 검증 실패 ❌ (에러 메시지 표시)

5. onSaved란?

1
2
3
4
5
onSaved: (value) {
if (value != null) {
_formData['email'] = value; // Map에 저장
}
},
  • validate()가 통과한 후에만 호출됩니다
  • 입력한 값을 저장하는 곳입니다

6. Map이란?

1
2
3
4
5
6
7
8
9
final Map<String, String> _formData = {};

// 데이터 저장
_formData['email'] = 'test@test.com';
_formData['password'] = '12345678';

// 데이터 가져오기
print(_formData['email']); // test@test.com
print(_formData['password']); // 12345678
  • Key-Value 쌍으로 데이터를 저장합니다
  • Map<Key타입, Value타입>

예시:

1
2
3
4
{
'email': 'test@test.com',
'password': 'password123'
}

7. ?. 연산자 (null-safe)

1
_formKey.currentState?.validate()
  • currentState가 null이 아니면 validate() 호출
  • null이면 아무것도 하지 않음
  • 에러를 방지합니다

8. ?? 연산자 (null coalescing)

1
final isValid = _formKey.currentState?.validate() ?? false;
  • 왼쪽 값이 null이면 오른쪽 값 사용
  • validate()가 null이면 false 사용

📝 3단계: LoginScreen에서 연결하기

수정된 login_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
import 'login_form_screen.dart';  // 추가

class LoginScreen extends StatelessWidget {
// ...

void _onEmailTap(BuildContext context) { // 새 함수 추가
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const LoginFormScreen()),
);
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
// ...
AuthButton(
onTapAction: _onEmailTap, // 연결
icon: const FaIcon(FontAwesomeIcons.user),
text: "Use Email & password", // 텍스트 변경
),
// ...
),
),
bottomNavigationBar: BottomAppBar(
height: 50, // 높이 추가
// ...
),
);
}
}

SignUpScreen도 동일하게 수정:

1
2
3
4
bottomNavigationBar: BottomAppBar(
height: 50, // 추가
// ...
),

🎨 Form 검증 흐름

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
1. 사용자가 입력
이메일: ""
비밀번호: ""

2. Login 버튼 클릭
→ _onSubmitTap() 호출
→ validate() 실행

3. 각 필드의 validator 실행
이메일 validator: "Email cannot be empty" 반환
비밀번호 validator: "Password cannot be empty" 반환

4. 결과
→ isValid = false
→ 에러 메시지 표시 (빨간색)
→ save() 호출 안 됨

─────────────────────────────

1. 사용자가 입력
이메일: "test@test.com"
비밀번호: "password123"

2. Login 버튼 클릭
→ validate() 실행

3. 각 필드의 validator 실행
이메일 validator: null 반환 (통과)
비밀번호 validator: null 반환 (통과)

4. 결과
→ isValid = true ✅
→ save() 호출
→ _formData에 저장
→ 로그인 처리 진행

📊 데이터 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
TextFormField (이메일)
↓ (사용자 입력)
"test@test.com"
↓ (validate 통과)
↓ (save 호출)
_formData['email'] = "test@test.com"

TextFormField (비밀번호)
↓ (사용자 입력)
"password123"
↓ (validate 통과)
↓ (save 호출)
_formData['password'] = "password123"

최종 _formData:
{
'email': 'test@test.com',
'password': 'password123'
}

✅ 체크리스트

  • 빈 값으로 Login 버튼을 누르면 에러 메시지가 표시되나요?
  • 올바른 값을 입력하면 콘솔에 데이터가 출력되나요?
  • 비밀번호 보이기/숨기기 버튼이 작동하나요?
  • LoginScreen에서 “Use Email & password”를 누르면 LoginFormScreen으로 이동하나요?

💡 연습 과제

  1. 비밀번호 확인: 비밀번호 확인 필드를 추가하고 일치 여부를 검증해보세요

    1
    2
    3
    4
    5
    6
    String? _confirmPassword(String? value) {
    if (value != _formData['password']) {
    return "Passwords don't match";
    }
    return null;
    }
  2. 로그인 유지: “Remember me” 체크박스를 추가해보세요

  3. 비밀번호 찾기: “Forgot password?” 링크를 추가해보세요

  4. 실시간 검증: TextFormField의 autovalidateMode를 사용해보세요

    1
    autovalidateMode: AutovalidateMode.onUserInteraction,

스플래시 화면 추가

🎯 이번 단계에서 배울 것

  • 앱 시작 시 표시되는 스플래시 화면 만들기
  • Timer를 사용한 자동 화면 전환
  • Navigator.pushReplacement 사용하기
  • mounted 속성 이해하기
  • Transform.translate로 위젯 이동하기

📝 1단계: SplashScreen 만들기

전체 코드 (splash_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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
import 'dart:async';

import 'package:feature_authentication/screens/sign_up_screen.dart';
import 'package:flutter/material.dart';

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

@override
State<SplashScreen> createState() => _SplashScreenState();
}

class _SplashScreenState extends State<SplashScreen> {
@override
void initState() {
super.initState();

// 2초 후 SignUpScreen으로 이동
Timer(const Duration(seconds: 2), () {
// mounted: 위젯이 아직 화면에 있는지 확인
if (mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => const SignUpScreen(),
),
);
}
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black, // 검정 배경
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// TikTok 로고 아이콘
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
),
child: Stack(
alignment: Alignment.center,
children: [
// 청록색 레이어 (왼쪽 위)
Transform.translate(
offset: const Offset(-3, -3),
child: Icon(
Icons.music_note_rounded,
size: 80,
color: Colors.cyan.shade400,
),
),

// 핑크색 레이어 (오른쪽 아래)
Transform.translate(
offset: const Offset(3, 3),
child: Icon(
Icons.music_note_rounded,
size: 80,
color: Colors.pink.shade400,
),
),

// 흰색 레이어 (중앙)
const Icon(
Icons.music_note_rounded,
size: 80,
color: Colors.white,
),
],
),
),
const SizedBox(height: 20),

// TikTok 텍스트
const Text(
'TikTok Clone',
style: TextStyle(
color: Colors.white,
fontSize: 48,
fontWeight: FontWeight.bold,
letterSpacing: -1, // 글자 간격
),
),
],
),
),
);
}
}

🔍 코드 상세 설명

1. Timer란?

1
2
3
Timer(Duration(seconds: 2), () {
// 2초 후 실행될 코드
});
  • 지정된 시간 후에 코드를 실행합니다
  • 딱 한 번만 실행됩니다 (반복 아님)

다른 사용 예:

1
2
3
Timer(Duration(milliseconds: 500), () { });  // 0.5초 후
Timer(Duration(minutes: 1), () { }); // 1분 후
Timer(Duration(hours: 1), () { }); // 1시간 후

2. mounted란?

1
2
3
if (mounted) {
// 위젯이 아직 화면에 있을 때만 실행
}
  • 위젯이 여전히 화면에 있는지 확인합니다
  • true: 화면에 있음
  • false: 화면에서 사라짐

왜 필요할까?

1
2
3
4
5
6
7
8
9
10
11
12
// mounted 체크 없이
Timer(Duration(seconds: 2), () {
Navigator.push(...); // 사용자가 뒤로가기 버튼을 누르면?
});
// → 에러 발생! (위젯이 이미 사라짐)

// mounted 체크로 안전하게
Timer(Duration(seconds: 2), () {
if (mounted) { // 화면에 아직 있는지 확인
Navigator.push(...); // 안전! ✅
}
});

3. Navigator.pushReplacement란?

1
2
3
4
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => NewScreen()),
)
  • 현재 화면을 새 화면으로 교체합니다
  • 뒤로가기 버튼을 눌러도 이전 화면으로 돌아갈 수 없습니다

비교:

1
2
3
4
5
6
7
// push: 화면 추가
Navigator.push(...)
// A → A, B (뒤로가기하면 A로)

// pushReplacement: 화면 교체
Navigator.pushReplacement(...)
// A → B (뒤로가기 불가, A는 사라짐)

언제 사용할까?

  • 스플래시 화면 → 메인 화면
  • 로그인 화면 → 홈 화면
  • 온보딩 화면 → 메인 화면

4. Transform.translate란?

1
2
3
4
Transform.translate(
offset: Offset(-3, -3), // 왼쪽으로 3, 위로 3 이동
child: Icon(...),
)
  • 위젯의 위치를 이동시킵니다
  • 실제 공간은 차지하지 않고 겉보기만 이동합니다

Offset 설명:

1
2
3
4
5
6
7
8
9
Offset(x, y)
// x: 가로 이동 (양수=오른쪽, 음수=왼쪽)
// y: 세로 이동 (양수=아래, 음수=위)

Offset(5, 0) // 오른쪽으로 5픽셀
Offset(-5, 0) // 왼쪽으로 5픽셀
Offset(0, 5) // 아래로 5픽셀
Offset(0, -5) // 위로 5픽셀
Offset(3, 3) // 오른쪽 아래 대각선으로 3픽셀

TikTok 로고 효과 원리:

1
2
3
4
5
6
7
8
9
1. 청록색 아이콘: 왼쪽 위 (-3, -3)
2. 핑크색 아이콘: 오른쪽 아래 (3, 3)
3. 흰색 아이콘: 중앙 (0, 0)

결과:
[청록]
[흰색]
[핑크]
→ 3D 글리치 효과!

5. letterSpacing이란?

1
2
3
TextStyle(
letterSpacing: -1, // 글자 간격
)
  • 글자 사이의 간격을 조절합니다
  • 양수: 간격 넓어짐
  • 음수: 간격 좁아짐

예시:

1
2
3
letterSpacing: 0    // TikTok Clone  (기본)
letterSpacing: 5 // T i k T o k (넓음)
letterSpacing: -2 // TikTokClone (좁음)

📝 2단계: main.dart 수정

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
25
26
27
28
29
30
31
32
33
34
35
36
37
import 'package:flutter/material.dart';

import 'splash_screen.dart'; // 추가

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

class TikTokApp extends StatelessWidget {
const TikTokApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'TikTok Clone',
theme: ThemeData(
scaffoldBackgroundColor: Colors.white,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
titleTextStyle: TextStyle(
color: Colors.black,
fontSize: Sizes.size16 + Sizes.size2,
fontWeight: FontWeight.w600,
),
),
primaryColor: const Color(0xFFE9435A),
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFE9435A),
),
useMaterial3: false,
),
home: const SplashScreen(), // SignUpScreen → SplashScreen
);
}
}

🎨 스플래시 화면 시각화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌──────────────────────────┐
│ │
│ │
│ │
│ [3D Logo] │ ← 청록/흰/핑크 겹침
│ │
│ TikTok Clone │ ← 흰색 큰 글씨
│ │
│ │
│ │
│ │
└──────────────────────────┘

2초 후 자동으로...


┌──────────────────────────┐
│ Sign Up for TikTok │
│ │
│ Create a profile... │
│ │
│ [Use Phone or Email] │
│ ... │
└──────────────────────────┘

📊 앱 시작 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1. 앱 실행
→ main() 실행
→ TikTokApp 생성
→ home: SplashScreen

2. SplashScreen 표시
→ initState() 실행
→ Timer 시작 (2초)
→ 로고와 텍스트 표시

3. 2초 경과
→ Timer 콜백 실행
→ mounted 체크 (true)
→ pushReplacement로 화면 교체

4. SignUpScreen 표시
→ SplashScreen은 완전히 사라짐
→ 뒤로가기 불가

✅ 체크리스트

  • 앱을 실행하면 먼저 스플래시 화면이 보이나요?
  • 2초 후 자동으로 SignUpScreen으로 이동하나요?
  • TikTok 로고가 3D 효과로 보이나요?
  • SignUpScreen에서 뒤로가기를 누르면 앱이 종료되나요? (스플래시로 돌아가지 않음)

💡 연습 과제

  1. 로딩 인디케이터 추가: 스플래시 화면에 로딩 애니메이션을 추가해보세요

    1
    2
    3
    CircularProgressIndicator(
    valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
    )
  2. 페이드 인 애니메이션: 로고가 서서히 나타나도록 만들어보세요

  3. 대기 시간 변경: Timer의 시간을 1초나 3초로 바꿔보세요

  4. 로고 회전: Transform.rotate를 사용해서 로고를 회전시켜보세요

  5. 실제 로고 사용: 이미지 파일을 추가해서 실제 TikTok 로고를 사용해보세요


🎉 완성! 전체 플로우 정리

📱 완성된 앱의 전체 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SplashScreen (2초)

SignUpScreen
├─ [Use email & password] → UsernameScreen
│ ↓
│ EmailScreen
│ ↓
│ PasswordScreen
│ ↓
│ BirthdayScreen
│ ↓
│ InterestsScreen

└─ [Log in] → LoginScreen

LoginFormScreen

📂 최종 파일 구조

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
lib/
├── main.dart
├── splash_screen.dart
├── constants/
│ ├── constants.dart
│ ├── gaps.dart
│ └── sizes.dart
└── features/
├── authentication/
│ ├── screens/
│ │ ├── sign_up_screen.dart
│ │ ├── login_screen.dart
│ │ ├── username_screen.dart
│ │ ├── email_screen.dart
│ │ ├── password_screen.dart
│ │ ├── birthday_screen.dart
│ │ └── login_form_screen.dart
│ └── widgets/
│ ├── auth_button.dart
│ └── form_button.dart
└── onboarding/
└── interests_screen.dart

🎓 배운 내용 요약

위젯

  • StatelessWidget: 변하지 않는 UI
  • StatefulWidget: 변하는 UI
  • Scaffold: 화면의 기본 틀
  • SafeArea: 안전 영역
  • Column/Row: 세로/가로 배치
  • Stack: 위젯 겹치기
  • GestureDetector: 터치 감지
  • AnimatedContainer: 자동 애니메이션
  • Form: 폼 관리

입력 관련

  • TextField: 기본 입력 필드
  • TextFormField: 검증 기능 있는 입력 필드
  • TextEditingController: 입력값 관리
  • InputDecoration: 입력 필드 꾸미기

상태 관리

  • setState(): 화면 다시 그리기
  • initState(): 초기화
  • dispose(): 정리
  • mounted: 위젯 존재 여부

유효성 검사

  • RegExp: 정규식
  • validator: 입력 검증
  • onSaved: 값 저장
  • GlobalKey: Form 제어
  • Navigator.push(): 화면 추가
  • Navigator.pop(): 화면 닫기
  • Navigator.pushReplacement(): 화면 교체

기타

  • Timer: 지연 실행
  • DateTime: 날짜/시간
  • CupertinoDatePicker: iOS 날짜 선택기
  • Transform.translate: 위치 이동
  • Theme: 앱 전체 스타일

🏆 최종 체크리스트

기본 기능

  • 스플래시 화면이 2초간 표시된다
  • 회원가입 화면에서 로그인 화면으로 이동할 수 있다
  • 모든 입력 필드에서 입력이 가능하다
  • 입력값이 유효하지 않으면 에러 메시지가 표시된다
  • 모든 Next 버튼이 작동한다

UI/UX

  • 버튼 색상이 입력 상태에 따라 변한다
  • 비밀번호를 보이기/숨기기 할 수 있다
  • 키보드가 적절한 타입으로 표시된다
  • 화면을 터치하면 키보드가 내려간다
  • 모든 애니메이션이 부드럽게 작동한다

데이터 검증

  • 이메일 형식이 올바른지 검사한다
  • 비밀번호 강도를 검사한다
  • 빈 값으로는 진행할 수 없다
  • 조건을 만족하면 체크 아이콘이 초록색으로 변한다

🎯 다음 단계 제안

추가 기능 구현

  1. 소셜 로그인: Firebase Authentication 연동
  2. 프로필 사진: 이미지 선택 기능
  3. 약관 동의: 체크박스와 약관 화면
  4. 이메일 인증: 인증 코드 전송/확인

개선 사항

  1. 에러 처리: try-catch로 안전하게
  2. 로딩 상태: 로딩 인디케이터 추가
  3. 다국어 지원: 한국어/영어 전환
  4. 다크 모드: 테마 전환 기능

고급 기능

  1. 상태 관리: Provider/Riverpod 도입
  2. API 연동: HTTP 통신
  3. 로컬 저장소: SharedPreferences
  4. 애니메이션: 화면 전환 애니메이션

📚 추가 학습 자료

Flutter 공식 문서

추천 패키지

  • provider - 상태 관리
  • dio - HTTP 클라이언트
  • shared_preferences - 로컬 저장소
  • firebase_auth - Firebase 인증

커뮤니티