33일차에서는 지금까지 배운 내용을 적용해 비밀듣는 고양이 앱을 MVC 패턴으로 만들어 보았다.
학습한 내용
- 비밀듣는 고양이 앱 제작(최종)
앱을 생성할 때 모든 컨트롤러를 등록하고 사용할 필요는 없다. 해당 컨트롤러가 사용될 때 등록을 하고 사용하면 되는데 이를 Get.lazyPut
으로 할 수 있다.
Get.lazyPut
을 하면 컨트롤러 인스턴스를 바로 만들지 않고, Get.find
를 할 때 생성한다.
하지만 코드를 작성하면서 initialBindind
에서 Get.lazyPut
을 사용하고, 페이지 이동을 할 때, Get.offNamed()
등을 사용할 때가 있는데 이 경우에는 해당 페이지가 스택에서 제거되면서 컨트롤러도 dispose
되어 다시 페이지에 들어가면 에러가 발생한다.
따라서 Get.lazyPut
의 fenix
속성을 true
로 설정하면 이전에 컨트롤러가 dispose
되어도 다시 사용이 가능하게 할 수 있어 에러를 해결할 수 있다.
- 비밀듣는 고양이 앱 제작(최종)
지금까지 비밀듣는 고양이 앱을 총 두 가지 버전으로 제작했었다.
이번에는 비밀듣는 고양이 앱을 pub.dev
의 패키지를 사용하지 않고 직접 API URL에 요청을 보내 데이터를 받아 만들었다.
또한 GetX를 사용하여 상태관리를 수행한다.
- 비밀듣는 고양이를
secret_cat_sdk
를 사용하지 않고 제작하시오.- 주어진 API 명세서를 보고, 플러터에서 앱을 제작하시오.
- 아래의 필수 기능을 포함하시오.
- 로그인, 회원가입 기능
- 유저 인증상태가 바뀌면 자동으로 페이지 리다이렉트 기능
- 디자인은 직접 수행할 것
Users
- Read 유저 리스트를 불러오는 기능
{ "page": 1, "perPage": 30, "totalPages": 1, "totalItems": 2, "items": [ { "id": "**USER_RECORD_ID** (주말과제시 필요)", "collectionId": "_pb_users_auth_", "collectionName": "users", "created": "2022-01-01 01:00:00Z", "updated": "2022-01-01 23:59:59Z", "username": "username123", "verified": false, "emailVisibility": true, "email": "test@example.com", "name": "test", "avatar": "filename.jpg" } ] }
- Login 로그인
- POST http://52.79.115.43:8090/api/collections/users/auth-with-password
- Request
- identity (String - required)
- password (String - required, 9글자 이상)
- Success Response
{ "token": "JWT_TOKEN", "record": { "id": "RECORD_ID", "collectionId": "_pb_users_auth_", "collectionName": "users", "created": "2022-01-01 01:00:00Z", "updated": "2022-01-01 23:59:59Z", "username": "username123", "verified": false, "emailVisibility": true, "email": "test@example.com", "name": "test", "avatar": "filename.jpg" } }
- SignUp 회원가입
- POST http://52.79.115.43:8090/api/collections/users/records
- Request
- email (String - required, 올바른 이메일 형식일 것)
- password (String - required, 9글자 이상)
- passwordConfirm (String - required, 9글자 이상)
- username (String - required, 9글자 이상)
- Success Response
{ "id": "RECORD_ID", "collectionId": "_pb_users_auth_", "collectionName": "users", "created": "2022-01-01 01:00:00Z", "updated": "2022-01-01 23:59:59Z", "username": "username123", "verified": false, "emailVisibility": true, "email": "test@example.com", "name": "test", "avatar": "filename.jpg" }
Secrets
- Read 비밀 리스트를 불러오는 기능
- GET http://52.79.115.43:8090/api/collections/secrets/records?sort=-created
- Success Response
{ "page": 1, "perPage": 30, "totalPages": 1, "totalItems": 2, "items": [ { "id": "RECORD_ID", "collectionId": "5647cebjvtwtcu1", "collectionName": "secrets", "created": "2022-01-01 01:00:00Z", "updated": "2022-01-01 23:59:59Z", "secret": "test", "author": "RELATION_RECORD_ID", "authorName": "test", } ] }
- Upload 비밀을 업로드하는 기능
- POST http://52.79.115.43:8090/api/collections/secrets/records
- Request
- secret (String)
- author (String, optional, User Record ID를 입력)
- authorName(String, optional, 닉네임을 입력)
- Success Response
{ "id": "RECORD_ID", "collectionId": "5647cebjvtwtcu1", "collectionName": "secrets", "created": "2022-01-01 01:00:00Z", "updated": "2022-01-01 23:59:59Z", "secret": "test", "author": "RELATION_RECORD_ID", "authorName":"test" }
dependencies:
cupertino_icons: ^1.0.2
dio: ^5.0.1
flutter:
sdk: flutter
font_awesome_flutter: ^10.4.0
get: ^4.6.5
pubspec.yaml
에 사용할 패키지들을 설치했다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/auth_controller.dart';
import 'package:secret_app/controller/login_controller.dart';
import 'package:secret_app/controller/secret_controller.dart';
import 'package:secret_app/controller/signup_controller.dart';
import 'package:secret_app/controller/upload_controller.dart';
import 'package:secret_app/util/app_pages.dart';
import 'package:secret_app/util/app_routes.dart';
void main() {
runApp(const SecretApp());
}
class SecretApp extends StatelessWidget {
const SecretApp({super.key});
Widget build(BuildContext context) {
return GetMaterialApp(
//컨트롤러 바인딩
initialBinding: BindingsBuilder(() {
Get.put(AuthController());
Get.lazyPut(() => LoginController(), fenix: true);
Get.lazyPut(() => SignupController(), fenix: true);
Get.lazyPut(() => SecretController(), fenix: true);
Get.lazyPut(() => UploadController(), fenix: true);
}),
getPages: AppPages.pages,
initialRoute: AppRoutes.login, //로그인 페이지 호출
);
}
}
main.dart
에서는 컨트롤러들을 바인딩하고, 페이지 라우트를 설정했다. 초기 라우트로는 로그인 페이지를 띄웠다.
import 'package:dio/dio.dart';
class Network {
//dio 객체 생성
static final dio = Dio(
BaseOptions(
baseUrl: 'http://52.79.115.43:8090/',
),
);
}
Network
에 앱에서 사용할 dio 객체를 생성했다. 여기서 baseUrl
을 설정했다.
class ApiRoutes {
static const user = 'api/collections/users/records?sort=-created'; //GET
static const authWithPassword =
'api/collections/users/auth-with-password'; //POST
static const signup = 'api/collections/users/records'; //POST
static const getSecrets =
'api/collections/secrets/records?sort=-created'; //GET
static const uploadSecret = 'api/collections/secrets/records'; //POST
}
ApiRoutes
에 사용할 API의 라우트들을 저장했다.
import 'package:secret_app/view/page/login_page.dart';
import 'package:secret_app/view/page/main_page.dart';
import 'package:secret_app/view/page/secret_page.dart';
import 'package:secret_app/view/page/setting_page.dart';
import 'package:secret_app/view/page/signup_page.dart';
import 'package:secret_app/view/page/upload_page.dart';
class AppRoutes {
static const main = MainPage.route; //메인페이지
static const login = LoginPage.route; //로그인 페이지
static const signup = SignupPage.route; //회원가입 페이지
static const secret = SecretPage.route; //비밀 페이지
static const upload = UploadPage.route; //비밀 업로드 페이지
static const setting = SettingPage.route; //설정 페이지
}
AppRoutes
에 각 페이지들의 라우트를 저장했다.
import 'package:get/get.dart';
import 'package:secret_app/view/page/login_page.dart';
import 'package:secret_app/view/page/main_page.dart';
import 'package:secret_app/view/page/secret_page.dart';
import 'package:secret_app/view/page/setting_page.dart';
import 'package:secret_app/view/page/signup_page.dart';
import 'package:secret_app/view/page/upload_page.dart';
import 'app_routes.dart';
class AppPages {
//페이지 라우팅
static final pages = [
GetPage(name: AppRoutes.main, page: () => const MainPage()),
GetPage(name: AppRoutes.login, page: () => const LoginPage()),
GetPage(name: AppRoutes.signup, page: () => const SignupPage()),
GetPage(name: AppRoutes.secret, page: () => const SecretPage()),
GetPage(name: AppRoutes.upload, page: () => const UploadPage()),
GetPage(name: AppRoutes.setting, page: () => const SettingPage()),
];
}
AppPages
에 페이지 라우트를 설정한 pages
를 저장했다.
// ignore_for_file: public_member_api_docs, sort_constructors_first
class User {
String id; //아이디
String collectionId; //컬렉션 아이디
String collectionName; //컬렉션 이름
DateTime created; //생성 날짜
DateTime updated; //업데이트 날짜
String username; //유저 이름
bool verified; //인증 여부
bool emailVisibility; //이미지 가시성
String email; //이메일
String? name; //이름
String? avatar; //아바타
User({
required this.id,
required this.collectionId,
required this.collectionName,
required this.created,
required this.updated,
required this.username,
required this.verified,
required this.emailVisibility,
required this.email,
this.name,
this.avatar,
});
factory User.fromMap(Map<String, dynamic> map) {
return User(
id: map['id'] as String,
collectionId: map['collectionId'] as String,
collectionName: map['collectionName'] as String,
created: DateTime.parse(map['created']),
updated: DateTime.parse(map['updated']),
username: map['username'] as String,
verified: map['verified'] as bool,
emailVisibility: map['emailVisibility'] as bool,
email: map['email'] as String,
name: map['name'] != '' ? map['name'] as String : null,
avatar: map['avatar'] != '' ? map['avatar'] as String : null,
);
}
}
User
는 사용자의 정보를 가지는 클래스 모델이다.
// ignore_for_file: public_member_api_docs, sort_constructors_first
class Secret {
String id; //아이디
String collectionId; //컬렉션 아이디
String collectionName; //컬렉션 이름
DateTime created; //생성 날짜
DateTime updated; //업데이트 날짜
String secret; //비밀 텍스트
String? author; //작성자(user record id)
String? authorName; //작성자이름(닉네임)
Secret({
required this.id,
required this.collectionId,
required this.collectionName,
required this.created,
required this.updated,
required this.secret,
this.author,
this.authorName,
});
factory Secret.fromMap(Map<String, dynamic> map) {
return Secret(
id: map['id'] as String,
collectionId: map['collectionId'] as String,
collectionName: map['collectionName'] as String,
created: DateTime.parse(map['created']),
updated: DateTime.parse(map['updated']),
secret: map['secret'] as String,
author: map['author'] != '' ? map['author'] as String : null,
authorName: map['authorName'] != '' ? map['authorName'] as String : null,
);
}
}
Secret
은 비밀의 정보를 가지는 클래스 모델이다.
import 'package:dio/dio.dart';
import 'package:get/get.dart';
import 'package:secret_app/model/user.dart';
import 'package:secret_app/util/api_routes.dart';
import 'package:secret_app/util/app_routes.dart';
import 'package:secret_app/util/network.dart';
class AuthController extends GetxController {
final Rxn<User> _user = Rxn(); //유저 정보
User? get user => _user.value; //user 정보 읽기
//로그인
login(String id, String pw) async {
try {
var res = await Network.dio.post(ApiRoutes.authWithPassword,
data: {
'identity': id,
'password': pw,
},
options: Options(contentType: Headers.formUrlEncodedContentType));
if (res.statusCode == 200) {
var user = User.fromMap(res.data['record']);
_user(user); //유저 정보 저장
}
} on DioError catch (e) {
print(e.message);
}
}
//로그 아웃
logout() {
_user.value = null; //유저 정보 삭제
}
//회원 가입
signup(
String email,
String password,
String passwordConfirm,
String username,
) async {
try {
await Network.dio.post(
ApiRoutes.signup,
data: {
'email': email,
'password': password,
'passwordConfirm': passwordConfirm,
'username': username,
},
);
} on DioError catch (e) {
print(e);
}
}
//유저 정보에 따른 페이지 이동
_handleAuthChanged(User? data) {
//유저 정보가 있으면 메인페이지로 이동
if (data != null) {
Get.offNamed(AppRoutes.main);
return;
}
//유저 정보가 없으면 로그인 페이지로 이동
Get.offAllNamed(AppRoutes.login);
return;
}
void onInit() {
super.onInit();
//유저 정보를 관찰하여 변경된 경우 실행
ever(_user, _handleAuthChanged);
}
}
AuthController
는 유저의 정보를 가지는 전역 컨트롤러이다. 로그인을 수행하는 login()
로그아웃을 수행하는 logout()
회원가입을 수행하는 signup()
메소드를 작성했다.
유저의 정보가 있으면 화면이 자동으로 메인 페이지로 이동하고 없으면 로그인 페이지로 이동하도록 onInit
에 ever
를 사용했다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/auth_controller.dart';
class LoginController extends GetxController {
var idController = TextEditingController(); //id 텍스트 필드 컨트롤러
var pwController = TextEditingController(); //패스워드 텍스트 필트 컨트롤러
//AuthController의 login()을 사용해 로그인 수행
login() {
Get.find<AuthController>().login(idController.text, pwController.text);
}
}
LoginController
는 로그인 페이지에서 아이디와 패스워드 입력 폼을 연결한 컨트롤러를 가지고 있다.
login()
메소드는 AuthController
의 login()
을 사용해 로그인을 수행한다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/auth_controller.dart';
class SignupController extends GetxController {
var emailController = TextEditingController(); //id 텍스트 필드 컨트롤러
var pwController = TextEditingController(); //패스워드 텍스트 필트 컨트롤러
var pwConfirmController = TextEditingController(); //패스워드 확인 텍스트 필트 컨트롤러
var userNameController = TextEditingController(); //닉네임 텍스트 필트 컨트롤러
RxString errorMsg = ''.obs;
//AuthController의 signup()을 사용해 로그인 수행
Future<bool> signup() async {
//이메일 형식 체크
if (!RegExp(
r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+")
.hasMatch(emailController.text)) {
errorMsg('이메일 형식이 잘못되었습니다.');
return false;
}
//비밀번호 글자 수 체크
if (pwController.text.length < 9) {
errorMsg('비밀번호는 9자리 이상이어야 합니다.');
return false;
}
//비밀번호와 비밀번호 확인을 체크
if (pwConfirmController.text != pwController.text) {
errorMsg('비밀번호와 비밀번호 확인이 다릅니다.');
return false;
}
//username이 null인지 체크
if (userNameController.text == '') {
errorMsg('닉네임을 입력하세요.');
return false;
}
await Get.find<AuthController>().signup(
emailController.text,
pwController.text,
pwConfirmController.text,
userNameController.text,
);
return true;
}
}
SignupController
는 회원가입 페이지의 네 가지 요소의 TextEditingController
를 가지고 있으며, 각 요소의 형식을 체크하고 결과를 저장할 errorMsg
를 가진다.
signup()
에서는 이메일, 비밀번호, 닉네임을 확인해 형식이 맞은 경우에 AuthController
의 signup()
을 호출한다.
import 'package:dio/dio.dart';
import 'package:get/get.dart';
import 'package:secret_app/model/secret.dart';
import 'package:secret_app/util/api_routes.dart';
import 'package:secret_app/util/network.dart';
class SecretController extends GetxController {
RxList<Secret> secrets = RxList();
readSecrets() async {
try {
//네트워크 데이터 요청(비밀 리스트)
var res = await Network.dio.get(ApiRoutes.getSecrets);
if (res.statusCode == 200) {
List<Map<String, dynamic>> data =
List<Map<String, dynamic>>.from(res.data['items']);
//data serialization 후 저장
secrets.addAll(data.map((e) => Secret.fromMap(e)).toList());
}
} on DioError catch (e) {
print(e);
}
}
void onInit() {
super.onInit();
readSecrets(); //비밀 데이터 가져오기
}
}
SecretController
는 비밀 리스트를 저장하고 있다. 비밀리스트를 불러오는 readSecrets
를 작성하고, onInit()
에서 호출했다.
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/auth_controller.dart';
import 'package:secret_app/util/api_routes.dart';
import 'package:secret_app/util/network.dart';
class UploadController extends GetxController {
var textController = TextEditingController();
String resultMsg = '';
RxBool isAnonymous = false.obs;
//비밀 업로드
uploadSecret() async {
if (textController.text == '') return;
var controller = Get.find<AuthController>();
try {
var res = await Network.dio.post(
ApiRoutes.uploadSecret,
data: {
'secret': textController.text,
'author': controller.user!.id,
'authorName': !isAnonymous.value ? controller.user!.username : ''
},
);
if (res.statusCode == 200) {
resultMsg = '비밀을 성공적으로 업로드했습니다.';
}
} on DioError catch (e) {
resultMsg = '[ERROR]비밀 업로드에 실패하였습니다.';
print(e);
}
}
checkAnonymous(bool? value) {
if (value != null) {
isAnonymous(value);
}
}
}
UploadController
는 업로드할 텍스트 필드의 컨트로러와 결과 메세지를 가지며, 익명으로 할지 여부를 판단할 isAnonymous
를 저장한다.
비밀을 업로드하는 메소드는 uploadSecret()
으로 작성했고, 업로드에 성공하면 성공 메세지를 resultMsg
에 저장한다.
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
class AppLogo extends StatelessWidget {
const AppLogo({super.key});
Widget build(BuildContext context) {
return const Column(
children: [
Icon(
size: 40,
color: Colors.redAccent,
FontAwesomeIcons.cat,
),
Text(
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
'비밀듣는 고양이',
),
],
);
}
}
앱에서 자주 사용할 로고를 커스텀 위젯으로 작성했다.
import 'package:flutter/material.dart';
class CustomTextField extends StatelessWidget {
const CustomTextField(
{super.key,
this.maxLines,
required this.hintText,
required this.controller});
final int? maxLines; //최대 라인 수
final String hintText; //힌트 텍스트
final TextEditingController controller; //컨트롤러
Widget build(BuildContext context) {
return TextField(
controller: controller,
maxLines: maxLines ?? 1,
cursorColor: Colors.redAccent,
decoration: InputDecoration(
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(
color: Colors.redAccent,
),
),
focusColor: Colors.redAccent,
border: const OutlineInputBorder(
borderSide: BorderSide(),
),
hintText: hintText,
),
);
}
}
앱에서 사용할 텍스트 필드를 커스텀 위젯으로 만들었다.
import 'package:flutter/material.dart';
class CustomButton extends StatelessWidget {
const CustomButton(
{super.key, required this.text, required this.onPressed, this.margin});
final String text; //출력 텍스트
final VoidCallback onPressed; //onPressed 이벤트 핸들러
final EdgeInsets? margin; //버튼의 마진
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: margin,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.redAccent,
),
child: Text(
style: const TextStyle(
fontSize: 16,
),
text,
),
),
);
}
}
앱에서 사용할 버튼을 커스텀 위젯으로 만들었다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/login_controller.dart';
import 'package:secret_app/util/app_routes.dart';
import 'package:secret_app/view/widget/app_logo.dart';
import 'package:secret_app/view/widget/custom_button.dart';
import 'package:secret_app/view/widget/custom_text_field.dart';
class LoginPage extends GetView<LoginController> {
const LoginPage({super.key});
static const route = '/login';
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AppLogo(),
const SizedBox(height: 16),
//아이디 텍스트 필드
Padding(
padding: const EdgeInsets.all(8.0),
child: CustomTextField(
hintText: '아이디',
controller: controller.idController,
),
),
//패스워드 텍스트 필드
Padding(
padding: const EdgeInsets.all(8.0),
child: CustomTextField(
hintText: '비밀번호',
controller: controller.pwController,
),
),
CustomButton(
margin: const EdgeInsets.all(8.0),
text: '로그인',
onPressed: () {
controller.login();
},
),
//회원가입 페이지 이동
TextButton(
onPressed: () => Get.toNamed(AppRoutes.signup),
style: TextButton.styleFrom(
foregroundColor: Colors.redAccent,
),
child: const Text('회원가입'),
),
],
),
);
}
}
LoginPage
는 로그인을 수행하는 페이지로 아이디와 패스워드의 텍스트 필드를 가지고, 로그인 버튼을 넣었다.
회원가입을 누르면 회원가입 페이지로 이동한다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/signup_controller.dart';
import 'package:secret_app/view/widget/app_logo.dart';
import 'package:secret_app/view/widget/custom_button.dart';
import 'package:secret_app/view/widget/custom_text_field.dart';
class SignupPage extends GetView<SignupController> {
const SignupPage({super.key});
static const route = '/signup';
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.all(16.0),
child: AppLogo(),
),
//이메일 입력 필드
Padding(
padding: const EdgeInsets.all(8.0),
child: CustomTextField(
hintText: '이메일',
controller: controller.emailController,
),
),
//비밀번호 입력 필드
Padding(
padding: const EdgeInsets.all(8.0),
child: CustomTextField(
hintText: '비밀번호',
controller: controller.pwController,
),
),
//비밀번호 확인 입력 필드
Padding(
padding: const EdgeInsets.all(8.0),
child: CustomTextField(
hintText: '비밀번호 확인',
controller: controller.pwConfirmController,
),
),
//닉네임 입력 필드
Padding(
padding: const EdgeInsets.all(8.0),
child: CustomTextField(
hintText: '닉네임',
controller: controller.userNameController,
),
),
//입력 형식 오류 메세지
Obx(
() => Text(
style: const TextStyle(
color: Colors.redAccent,
),
controller.errorMsg.value,
),
),
//회원가입 버튼
CustomButton(
margin: const EdgeInsets.all(8.0),
text: '회원가입',
onPressed: () async {
if (await controller.signup()) {
Get.back();
}
},
),
],
),
),
),
);
}
}
SignupPage
는 회원가입을 수행하는 페이지로, 이메일, 패스워드, 패스워드 확인, 닉네임 텍스트 필드를 가진다.
회원가입 버튼을 누르면 회원가입을 수행하고, 페이지를 뒤로 이동한다.
입력 형식이 맞지 않으면 오류 메세지를 출력한다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/util/app_routes.dart';
import 'package:secret_app/view/widget/app_logo.dart';
class MainPage extends StatelessWidget {
const MainPage({super.key});
static const route = '/main';
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AppLogo(),
const SizedBox(height: 12),
//비밀페이지로 이동
Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
tileColor: Colors.black12,
title: const Text('비밀 보기'),
subtitle: const Text('모든 비밀을 확인하기'),
trailing: const Icon(Icons.navigate_next),
onTap: () => Get.toNamed(AppRoutes.secret),
),
),
//작성자들 페이지로 이동
Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
tileColor: Colors.black12,
title: const Text('비밀 만들기'),
subtitle: const Text('나의 비밀 작성하기'),
trailing: const Icon(Icons.navigate_next),
onTap: () => Get.toNamed(AppRoutes.upload),
),
),
//비밀 업로드 페이지로 이동
Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
tileColor: Colors.black12,
title: const Text('설정'),
subtitle: const Text('내 정보 설정하기'),
trailing: const Icon(Icons.navigate_next),
onTap: () => Get.toNamed(AppRoutes.setting),
),
),
],
),
);
}
}
MainPage
는 세 가지 페이지로 이동할 수 있는 리스트 타일을 출력한다. 각 리스트 타일을 누르면 해당 페이지로 이동한다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/secret_controller.dart';
import 'package:secret_app/view/widget/app_logo.dart';
class SecretPage extends GetView<SecretController> {
const SecretPage({super.key});
static const route = '/secret';
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
//배경 이미지
image: DecorationImage(
image: NetworkImage(
'https://cdn.pixabay.com/photo/2018/07/31/20/27/silhouette-3575860_960_720.png',
),
fit: BoxFit.cover,
colorFilter: ColorFilter.mode(Colors.white70, BlendMode.lighten),
),
),
child: Obx(
//비밀들 페이지 뷰
() => PageView.builder(
physics: const BouncingScrollPhysics(),
itemCount: controller.secrets.length,
itemBuilder: (context, index) {
var currentSecret = controller.secrets[index];
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppLogo(), //앱 로고
const SizedBox(height: 16),
//비밀 텍스트
Text(
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
currentSecret.secret,
),
const SizedBox(height: 16),
//작성자
Text(
currentSecret.authorName ?? '익명',
)
],
),
);
},
),
),
),
);
}
}
SecretPage
는 비밀들을 페이지 뷰로 출력한다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/upload_controller.dart';
import 'package:secret_app/view/widget/app_logo.dart';
import 'package:secret_app/view/widget/custom_button.dart';
import 'package:secret_app/view/widget/custom_text_field.dart';
class UploadPage extends GetView<UploadController> {
const UploadPage({super.key});
static const route = '/upload';
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const AppLogo(), //앱 로고
const SizedBox(height: 16),
//비밀 입력 텍스트 필드
CustomTextField(
maxLines: 6,
hintText: '비밀을 입력하세요.',
controller: controller.textController,
),
//익명 체크박스
Row(
children: [
Obx(
() => Checkbox(
activeColor: Colors.redAccent,
value: controller.isAnonymous.value,
onChanged: controller.checkAnonymous,
),
),
const Text('익명'),
],
),
//비밀 업로드 버튼
CustomButton(
text: '비밀 업로드',
onPressed: () async {
await controller.uploadSecret();
if (controller.textController.text != '') {
Get.back();
Get.snackbar('비밀 업로드', controller.resultMsg);
}
},
),
],
),
),
);
}
}
UploadPage
는 입력한 텍스트를 비밀로 서버에 업로드한다. 익명 체크 박스를 선택하면 익명으로 비밀을 올릴 수 있다.
비밀 업로드 버튼을 누르면 페이지가 뒤로 이동하며, 성공 또는 실패 메세지를 스낵바로 출력한다.
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:secret_app/controller/auth_controller.dart';
import 'package:secret_app/view/widget/app_logo.dart';
class SettingPage extends GetView<AuthController> {
const SettingPage({super.key});
static const route = '/setting';
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
const AppLogo(), //앱 로고
const SizedBox(height: 16),
//유저 정보 출력
ListTile(
title: Text(controller.user!.username),
subtitle: const Text('안녕하세요'),
leading: CircleAvatar(
backgroundColor: Colors.redAccent,
child: Text(
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
controller.user!.username[0],
),
),
),
//리스트 타일을 누르면 로그아웃
ListTile(
title: const Text('로그아웃'),
leading: const Icon(FontAwesomeIcons.rightFromBracket),
onTap: controller.logout,
)
],
),
),
),
);
}
}
SettingPage
는 로그인 된 유저의 정보를 출력하며, 로그아웃 리스트 타일을 누르면 로그아웃이 되어 로그인 페이지로 이동된다.
회원가입 기능
로그인과 설정페이지의 로그아웃 기능
비밀 업로드와 비밀 리스트 페이지
오늘은 지금까지 배웠던 내용들을 종합해서 비밀듣는 고양이 앱을 만들었다. 코드를 나름 깔끔하게 잘 쓴 것 같다. 근데 파일이 21개가 만들어져서 블로그 쓰는데 좀 오래 걸렸다.ㅠㅠ 추가 내용 정리는 새롭게 학습한 내용은 없고 과제에서 사용한 Get.lazyPut의 fenix 속성만 간단히 적었다. 이제 테디님 강의 보고 학습만 하면 된다.ㅋㅋㅋㅋ 포스팅은 여기서 끝!!!! (앱 하나 만들었는데 벌써 오후 4시네...ㅋㅋ)