목표
만난지 며칠이 지났는지 확인할 수 있는 날짜 앱을 만들어보자. 이를 응용하여 100, 200, 300, 1년, 2주년까지 확인할 수 있도록 확장해보자.
본 내용을 전현직 마케터이자 현직 개발자 및 지망생이 개인공부를 하면서 배운 내용을 정리해놓은 글입니다. 해당내용에 있어서 내용상의 문의 사항 혹은 코드상에 수정사항이 발견이 되면 알려주시면 감사하겠습니다. 같이 고민해보고 수정할 수 있도록 하겠습니다.
! 알림 - 실기기 테스트 중 '계산된 날짜'불러오기가 정상적으로 작동되지 않는 경우를 발견을 했다. 이에 관련해서는 수정을 하여 새로운 코드를 업데이트 할 예정이다.
github (추후 공개예정)
화면 구성을 다시 새롭게 변경을 하면서 다음의 기능들을 추가 하였다.
- 로그인
- 회원가입
- 로그아웃
- 마지막으로 저장된 계산 기록 불러오기
점점 코드가 길어지고 있으니 이제부터는 부분별로만 보여주는 식으로 진행을 해야겠다.
로그인은 어떻게 해결을 하였는가?
- 일단 로그인의 경우에는 Firebase를 사용하였다. SharedPreference를 사용을 할 수도 있겠으나, 이왕이면 Firebase를 사용하여서 로그인을 할 수 있도록 하고, 각 기능을 접근하기 위해서는 로그인을 해야한다는 조건을 걸었다.
회원가입은 어떻게 해결을 하였는가?
- 회원가입 또한 Firebase를 사용하였다. 로그인을 하려면 회원가입을 해야하는거니까?
해당 코드들은 글의 마지막에 전체 코드를 공개 해놓겠다.
마지막으로 저장된 계산 기록 불러오기
LoggedIn상태는 일단 기본적으로는 false로 만들어 놓고 가장 마지막에 계산된 결과값을 저장하기 위한 변수도 선언을 하였다.
class _BirthdayAppState extends State<BirthdayApp> {
(...)
//로그인 상태 확인
bool _isLoggedIn = false;
//SharedPreferences 인스턴스 저장
late SharedPreferences _prefs;
final _lastCalculatedKey = 'lastCalculated';
(...)
그리고 이제 마지막으로 계산한 날짜를 저장해야하는데, 어떤 방식으로 저장할 수 있는지 그 '형태'를 알려줘야 한다.
//마지막으로 계산한 날짜 저장
final lastCalculated = DateFormat('yyyy-MM-dd').format(DateTime.now());
_prefs.setString(_lastCalculatedKey, lastCalculated);
} catch (e) {
_resultText0 = 'Invalid date format';
_resultText100 = 'Invalid date format';
_resultText200 = 'Invalid date format';
_resultText300 = 'Invalid date format';
_resultText365 = 'Invalid date format';
}
자, 그럼 우리가 앱을 다시 껐다가 켰을때 가장 마지막에 저장했던 기록을 토대로 저장된 날짜로써 '초기화' = 시작 할 수 있도록 하면 되겠다.
void setLastCalculated(DateTime lastDate) {
//마지막으로 계산한 날짜를 사용하여 초기화
setState(() {
final calculatedDate0 = lastDate.add(const Duration(days: 0)).toLocal();
final calculatedDate100 =
lastDate.add(const Duration(days: 100)).toLocal();
final calculatedDate200 =
lastDate.add(const Duration(days: 200)).toLocal();
final calculatedDate300 =
lastDate.add(const Duration(days: 300)).toLocal();
final calculatedDate365 =
lastDate.add(const Duration(days: 365)).toLocal();
final dateFormat = DateFormat('yyyy-MM-dd');
_resultText0 = '우리가 만난날: ${dateFormat.format(calculatedDate0)}';
_resultText100 = '100일: ${dateFormat.format(calculatedDate100)}';
_resultText200 = '200일: ${dateFormat.format(calculatedDate200)}';
_resultText300 = '300일: ${dateFormat.format(calculatedDate300)}';
_resultText365 = '1년: ${dateFormat.format(calculatedDate365)}';
});
}
이렇게 조건들을 설정을 했으니, 우리의 body 즉, 본체에서도 어떻게 반영이 될 수 있을지 조건문을 걸어서 반영할 수 있는 방법을 작성해보자.
@override
void initState() {
super.initState();
_isLoggedIn = FirebaseAuth.instance.currentUser?.email != null;
//SharedPreferences 인스턴스 초기화
SharedPreferences.getInstance().then((prefs) {
setState(() {
_prefs = prefs;
final lastCalculated = _prefs.getString(_lastCalculatedKey);
//저장된 데이터가 없을 경우 실행되지 않음
if (lastCalculated != null) {
//마지막으로 계산한 날짜 불러오기
final lastDate = DateTime.parse(lastCalculated);
setLastCalculated(lastDate);
}
그럼 완성이다. 이제 마지막으로 저장된 결과를 앱이 시작할때 마다 불러 올 것이다.
그럼 이렇게 작성된 첫번째 페이지의 모습은 다음과 같다.
import 'package:date_counter/sign_up.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:date_counter/container_box.dart';
import 'package:date_counter/memo_page.dart';
import 'package:date_counter/memories_page.dart';
import 'package:date_counter/login.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
class BirthdayApp extends StatefulWidget {
@override
State<BirthdayApp> createState() => _BirthdayAppState();
}
class _BirthdayAppState extends State<BirthdayApp> {
final currentUser = FirebaseAuth.instance.currentUser;
final _focusNode = FocusNode();
final TextEditingController _dayController = TextEditingController();
String _resultText0 = '';
String _resultText100 = '';
String _resultText200 = '';
String _resultText300 = '';
String _resultText365 = '';
//로그인 상태 확인
bool _isLoggedIn = false;
//SharedPreferences 인스턴스 저장
late SharedPreferences _prefs;
final _lastCalculatedKey = 'lastCalculated';
void calculateResult() {
setState(() {
try {
final enteredDate = DateTime.parse(_dayController.text);
final calculatedDate0 =
enteredDate.add(const Duration(days: 0)).toLocal();
final calculatedDate100 =
enteredDate.add(const Duration(days: 100)).toLocal();
final calculatedDate200 =
enteredDate.add(const Duration(days: 200)).toLocal();
final calculatedDate300 =
enteredDate.add(const Duration(days: 300)).toLocal();
final calculatedDate365 =
enteredDate.add(const Duration(days: 365)).toLocal();
final dateFormat = DateFormat('yyyy-MM-dd');
_resultText0 = '우리가 만난날: ${dateFormat.format(calculatedDate0)}';
_resultText100 = '100일: ${dateFormat.format(calculatedDate100)}';
_resultText200 = '200일: ${dateFormat.format(calculatedDate200)}';
_resultText300 = '300일: ${dateFormat.format(calculatedDate300)}';
_resultText365 = '1년: ${dateFormat.format(calculatedDate365)}';
_dayController.clear();
//마지막으로 계산한 날짜 저장
final lastCalculated = DateFormat('yyyy-MM-dd').format(DateTime.now());
_prefs.setString(_lastCalculatedKey, lastCalculated);
} catch (e) {
_resultText0 = 'Invalid date format';
_resultText100 = 'Invalid date format';
_resultText200 = 'Invalid date format';
_resultText300 = 'Invalid date format';
_resultText365 = 'Invalid date format';
}
});
}
void setLastCalculated(DateTime lastDate) {
//마지막으로 계산한 날짜를 사용하여 초기화
setState(() {
final calculatedDate0 = lastDate.add(const Duration(days: 0)).toLocal();
final calculatedDate100 =
lastDate.add(const Duration(days: 100)).toLocal();
final calculatedDate200 =
lastDate.add(const Duration(days: 200)).toLocal();
final calculatedDate300 =
lastDate.add(const Duration(days: 300)).toLocal();
final calculatedDate365 =
lastDate.add(const Duration(days: 365)).toLocal();
final dateFormat = DateFormat('yyyy-MM-dd');
_resultText0 = '우리가 만난날: ${dateFormat.format(calculatedDate0)}';
_resultText100 = '100일: ${dateFormat.format(calculatedDate100)}';
_resultText200 = '200일: ${dateFormat.format(calculatedDate200)}';
_resultText300 = '300일: ${dateFormat.format(calculatedDate300)}';
_resultText365 = '1년: ${dateFormat.format(calculatedDate365)}';
});
}
Future<void> logout() async {
try {
await FirebaseAuth.instance.signOut();
setState(() {
_isLoggedIn = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Logged out')),
);
// Navigator.push(context,
// MaterialPageRoute(builder: (BuildContext context) => LoginPage()));
Navigator.of(context).pop();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error occurred while logging out')),
);
}
}
void _handleDrawerTapwriter() async {
final currentUser = FirebaseAuth.instance.currentUser;
if (currentUser != null) {
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => MemoPage()),
);
} else {
await _showLoginDialog();
}
}
void _handleDrawerTapmemory() async {
final currentUser = FirebaseAuth.instance.currentUser;
if (currentUser != null) {
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => MemoryNote()),
);
} else {
await _showLoginDialog();
}
}
Future<void> _showLoginDialog() {
return showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text("로그인 필요"),
content: Text("로그인 후 기억하기가 가능합니다."),
actions: [
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text("닫기"),
),
ElevatedButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => LoginPage()),
);
},
child: Text("로그인"),
),
],
),
);
}
@override
void initState() {
super.initState();
_isLoggedIn = FirebaseAuth.instance.currentUser?.email != null;
//SharedPreferences 인스턴스 초기화
SharedPreferences.getInstance().then((prefs) {
setState(() {
_prefs = prefs;
final lastCalculated = _prefs.getString(_lastCalculatedKey);
//저장된 데이터가 없을 경우 실행되지 않음
if (lastCalculated != null) {
//마지막으로 계산한 날짜 불러오기
final lastDate = DateTime.parse(lastCalculated);
setLastCalculated(lastDate);
}
});
});
_focusNode.addListener(() {
if (_focusNode.hasFocus) {
print('TextField on foucs');
} else {
print('TextField lost focus');
}
});
}
@override
Widget build(BuildContext context) {
var scaffold2 = Scaffold(
appBar: AppBar(
title: Text('우리가 만난날'),
leading: Builder(
builder: (context) {
//build method의 build Context가 아니라 Builder 위젯의 BuildContext를 사용하도록 Builder위젯을 쓴다.
return IconButton(
icon: Icon(Icons.list),
onPressed: () {
setState(() {
Scaffold.of(context).openDrawer();
});
},
);
},
),
actions: <Widget>[
Row(
children: [
// IconButton(onPressed: () {}, icon: Icon(Icons.save_rounded)),
IconButton(
icon: Icon(Icons.power_settings_new),
tooltip: 'power off the app',
onPressed: () {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text("로그아웃"),
content: Text("로그아웃 하시겠습니까"),
actions: [
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text("취소"),
),
ElevatedButton(
onPressed: () => logout(),
child: Text("로그아웃"),
),
],
),
);
},
),
],
)
],
),
drawer: Drawer(
child: Column(
children: [
UserAccountsDrawerHeader(
accountName: Text(
'Welcome',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
),
accountEmail: _isLoggedIn
? Text(FirebaseAuth.instance.currentUser!.email.toString())
: Text('로그인 해주세요'),
currentAccountPicture: CircleAvatar(
backgroundColor: Colors.white,
backgroundImage: AssetImage('assets/images/developer.png'),
),
),
ListTile(
title: Text('기억하기'),
leading: Icon(Icons.radio_button_on),
onTap: _handleDrawerTapwriter,
),
ListTile(
title: Text('기억저장소'),
leading: Icon(Icons.radio_button_on),
onTap: _handleDrawerTapmemory,
),
Container(
height: 1,
width: MediaQuery.of(context).size.width * 0.68,
decoration: BoxDecoration(
border:
Border(bottom: BorderSide(color: Colors.black45, width: 1)),
),
),
SizedBox(height: 40),
InkWell(
child: Text('로그인'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => LoginPage()),
);
},
),
SizedBox(height: 8),
InkWell(
child: Text('회원가입'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => SignUpPage()),
);
},
),
],
),
),
body: SingleChildScrollView(
child: Center(
child: Column(
children: <Widget>[
Padding(padding: EdgeInsets.fromLTRB(0, 40, 0, 0)),
Container(
padding: EdgeInsets.all(8),
width: MediaQuery.of(context).size.width * 0.4,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: <Widget>[
Text(
'Today is',
style: TextStyle(color: Colors.white),
),
Text(
DateFormat('yyyy-MM-dd')
.format(DateTime.now().toLocal()), // DateFormat을 변경해줌
style: TextStyle(fontSize: 20, color: Colors.white),
),
],
),
),
SizedBox(
height: 72,
),
Container(
width: MediaQuery.of(context).size.width * 0.8,
child: TextField(
decoration: const InputDecoration(
labelText: 'Enter the day',
hintText: 'yyyy-mm-dd',
),
focusNode: _focusNode,
controller: _dayController,
keyboardType: TextInputType.text,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
calculateResult();
},
),
),
SizedBox(
height: 32,
),
Column(
children: <Widget>[
SizedBox(height: 8),
TextBox(text: _resultText0),
SizedBox(height: 8),
TextBox(text: _resultText100),
SizedBox(height: 8),
TextBox(text: _resultText200),
SizedBox(height: 8),
TextBox(text: _resultText300),
SizedBox(height: 8),
TextBox(text: _resultText365),
SizedBox(
height: 56,
),
ElevatedButton(
onPressed: calculateResult,
child: Text('Calculate'),
style: ElevatedButton.styleFrom(
minimumSize:
Size(MediaQuery.of(context).size.width * 0.5, 50),
),
),
],
)
],
),
),
),
);
var scaffold = scaffold2;
return scaffold;
}
}
그럼 오늘까지 업데이트한 부분은 여기까지이다.
로그인과 회원가입에 대한 전체 코드
@ login
import 'package:date_counter/home_page.dart';
import 'package:firebase_core/firebase_core.dart';
import 'main.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
Widget _userIdWidget() {
return TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '이메일',
),
validator: (String? value) {
if (value!.isEmpty) {
return 'Please enter your email';
}
return null;
},
);
}
Widget _passwordWidget() {
return TextFormField(
controller: _passwordController,
obscureText: true,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: '비밀번호',
),
validator: (String? value) {
if (value!.isEmpty) {
return 'Please enter your password';
}
return null;
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: const Text('로그인'),
centerTitle: true,
),
body: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
const SizedBox(height: 20.0),
_userIdWidget(),
const SizedBox(height: 20.0),
_passwordWidget(),
SizedBox(
height: 20,
),
Container(
height: MediaQuery.of(context).size.width * 0.13,
width: MediaQuery.of(context).size.width * 0.3,
padding: const EdgeInsets.only(top: 8.0),
child: ElevatedButton(
onPressed: () => _login(),
child: const Text(
"로그인",
style: TextStyle(
fontSize: 16,
),
),
),
),
],
),
),
),
);
}
@override
void initState() {
super.initState();
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
_login() async {
await Firebase.initializeApp();
if (_formKey.currentState!.validate()) {
FocusScope.of(context).requestFocus(FocusNode());
try {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: _emailController.text,
password: _passwordController.text,
);
//로그인 성공시 계산페이지로 이동
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => BirthdayApp()),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Logged in')),
);
} on FirebaseAuthException catch (e) {
String message = 'Hello';
if (e.code == 'user-not-found') {
message = 'The user does not exist.';
} else if (e.code == 'wrong-password') {
message = 'Check your password.';
} else if (e.code == 'invalid-email') {
message = 'Check your email.';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.black),
);
}
}
}
}
@sign_up
import 'package:date_counter/home_page.dart';
import 'package:firebase_core/firebase_core.dart';
import 'main.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class SignUpPage extends StatefulWidget {
const SignUpPage({super.key});
@override
State<SignUpPage> createState() => _SignUpPageState();
}
class _SignUpPageState extends State<SignUpPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
Future<void> registerUser() async {
await Firebase.initializeApp();
try {
final userCredential =
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: _emailController.text.trim(),
password: _passwordController.text,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('등록이 되었습니다')),
);
print('정상 등록 되었습니다');
} on FirebaseAuthException catch (e) {
if (e.code == 'weak-password') {
Get.snackbar('Error', 'The password is too weak.');
} else if (e.code == 'email-already-in-use') {
Get.snackbar('Error', 'The email is already in use.');
} else {
Get.snackbar(
'Error', 'Failed to register user. Please try again later.');
}
} catch (e) {
print('Error: $e');
Get.snackbar('Error', 'Failed to register user. Please try again later.');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('회원가입 페이지'),
),
body: Form(
key: _formKey,
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
children: [
const SizedBox(height: 20.0),
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: '이메일',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your email.';
}
return null;
},
),
const SizedBox(height: 20.0),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: '비밀번호',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password.';
}
if (value.length < 6) {
return 'The password must be at least 6 characters long.';
}
return null;
},
),
SizedBox(height: 20),
Container(
height: MediaQuery.of(context).size.width * 0.13,
width: MediaQuery.of(context).size.width * 0.3,
padding: const EdgeInsets.only(top: 8.0),
child: ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
registerUser();
}
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => BirthdayApp()),
);
},
child: const Text(
"회원가입",
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
),
);
}
}
내용을 쭈욱 길게 코드도 길게 길게 적는 것을 자주 볼 수 있을것인데, 이렇게 하는 이유가 나는 코딩을 처음 배울때, 부분별로 알려주는 블로그를 볼때면 항상 내가 어떤 부분을 적어야할지 놓칠때가 많았다. 그러다 보니, 자신이 작성한 코드와 저자가 작성한 코드를 상호 비교하면서 내가 코드를 잘 적었는지, 혹은 저자는 왜 여기다가 이 코드를 배치했는지 확인하길 바라는 마음에서 길지만 코드를 쭈욱 적어본다.
물론 Github 주소는 나중에 별도로 공개할 예정이다.
'Flutter' 카테고리의 다른 글
[Project] '만난지 며칠' 날짜 앱 만들기 - Chp 6. 사진 & 갤러리 (0) | 2023.06.08 |
---|---|
[개인공부] Monetizing apps with Flutter (0) | 2023.06.07 |
[Project] '만난지 며칠' 날짜 앱 만들기 - Chp 4. 메모 기능 (0) | 2023.05.26 |
[Project] '만난지 며칠' 날짜 앱 만들기 - Chp 3. 메인화면 구성 (0) | 2023.05.24 |
[Flutter] Refactoring (리팩토링) - Extract Method, Extract Widget (0) | 2023.05.22 |