33일차 과제 - 비밀듣는고양이(최종)
- 비밀듣는고양이를 secret_cat_sdk를 사용하지 않고 제작하시오.
- 아래의 API 명세서를 보고, 플러터에서 과제풀이에 활용할 수 있도록 하시오.
- 아래의 필수 기능을 포함해야 함.
- 로그인/회원가입 기능
- 유저 인증상태가 바뀌면 자동 페이지 리다이렉트 기능
- 디자인을 반드시 포함할 것
API명세서
👉 Users
📄 Read 유저리스트를 불러오는 기능
- GET http://52.79.115.43:8090/api/collections/users/records?sort=-created
- Response
{ "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)
- 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))
- 해당 author는 User Record ID를 입력할 것
- authorName (String, (optional))
- 해당 authorName은 닉네임을 입력할 것
- 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" }
Figma → 비밀먹는햄버거
[github 링크]
이미지 파일 > assets/images/secret_hamburger
코드 > day33
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'controller/auth_controller.dart';
import 'controller/login_controller.dart';
import 'controller/sign_up_controller.dart';
import 'controller/secret_controller.dart';
import 'controller/upload_controller.dart';
import 'util/pages.dart';
import 'view/page/splash_page.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({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,
home: SplashPage()
);
}
}
class Secret {
String id;
String secret;
String? author;
String authorName;
Secret({
required this.id,
required this.secret,
this.author,
required this.authorName,
});
factory Secret.fromMap(Map<String, dynamic> map) {
return Secret(
id: map['id'] as String,
secret: map['secret'] as String,
author: map['author'] as String,
authorName: map['authorName'] as String,
);
}
}
class User {
String id;
String username;
String email;
String name;
User({
required this.id,
required this.username,
required this.email,
required this.name,
});
factory User.fromMap(Map<String, dynamic> map) {
return User(
id: map['id'] as String,
username: map['username'] as String,
email: map['email'] as String,
name: map['name'] as String,
);
}
}
import 'package:flutter/material.dart';
import 'login_page.dart';
class SplashPage extends StatefulWidget {
_SplashPageState createState() => _SplashPageState();
}
class _SplashPageState extends State<SplashPage> {
void initState() {
super.initState();
Future.delayed(
Duration(seconds: 3), // 3초 동안 Splash Screen 표시
() => Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => LoginPage()),
),
);
}
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/secret_hamburger/Splash.png'),
fit: BoxFit.fill
)
),
)
);
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../widget/burgers.dart';
import '../../util/app_routes.dart';
import '../../controller/login_controller.dart';
class LoginPage extends GetView<LoginController> {
const LoginPage({Key? key}) : super(key: key);
static const String route = '/login';
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40.0),
child: SingleChildScrollView(
physics: NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(width: 150, height: 150, child: Image.asset(Burgers.mainHam)),
SizedBox(height: 50),
TextField(
controller: controller.idController,
decoration: InputDecoration(
labelText: '아이디',
border: OutlineInputBorder(),
),
),
SizedBox(height: 16.0),
TextField(
controller: controller.pwController,
decoration: InputDecoration(
labelText: '비밀번호',
border: OutlineInputBorder(),
),
),
SizedBox(height: 16.0),
Container(
width: double.infinity,
child: ElevatedButton(
onPressed: controller.login,
child: Text('로그인'),
),
),
TextButton(
onPressed: () {
Get.toNamed(AppRoutes.signUp);
},
child: Text('회원가입')
),
],
),
),
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../controller/sign_up_controller.dart';
import '../widget/burgers.dart';
class SignUpPage extends GetView<SignUpController> {
const SignUpPage({Key? key}) : super(key: key);
static const String route = '/signup';
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.black,
elevation: 0,
),
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40.0),
child: SingleChildScrollView(
physics: NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(width: 150, height: 150, child: Image.asset(Burgers.mainHam)),
SizedBox(height: 50),
TextField(
controller: controller.emailController,
decoration: InputDecoration(
labelText: '이메일',
border: OutlineInputBorder(),
),
),
SizedBox(height: 16.0),
TextField(
controller: controller.pwController,
decoration: InputDecoration(
labelText: '비밀번호',
border: OutlineInputBorder(),
),
),
SizedBox(height: 16.0),
TextField(
controller: controller.pwConfirmController,
decoration: InputDecoration(
labelText: '비밀번호 확인',
border: OutlineInputBorder(),
),
),
SizedBox(height: 16.0),
TextField(
controller: controller.userNameController,
decoration: InputDecoration(
labelText: '닉네임',
border: OutlineInputBorder(),
),
),
SizedBox(height: 16.0),
Container(
width: double.infinity,
child: ElevatedButton(
onPressed: controller.signUp,
child: Text('회원가입'),
),
),
],
),
),
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../util/app_routes.dart';
import '../widget/burgers.dart';
import 'secret_page.dart';
import 'upload_page.dart';
class MainPage extends StatelessWidget {
const MainPage({Key? key}) : super(key: key);
static const String route = '/main';
Widget build(BuildContext context) {
List menu = ['비밀 보러가기', '비밀 알려주기', '설정'];
return Scaffold(
backgroundColor: Colors.white,
body: Center(
child: SingleChildScrollView(
physics: NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 50.0),
child: Image.asset(Burgers.mainHam),
),
Column(
children: List.generate(
menu.length, (index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 50, vertical: 20),
child: ListTile(
leading: Image.asset(Burgers.mainHam),
title: Text(menu[index]),
onTap: () {
switch(index) {
case 0:
Get.toNamed(AppRoutes.secret);
break;
case 1:
Get.toNamed(AppRoutes.upload);
break;
case 2:
Get.toNamed(AppRoutes.setting);
break;
}
},
),
),
),
)
],
),
),
),
);
}
}
import 'package:animate_do/animate_do.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../controller/secret_controller.dart';
import '../widget/burgers.dart';
class SecretPage extends GetView<SecretController> {
const SecretPage({Key? key}) : super(key: key);
static const String route = '/secret';
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
foregroundColor: Colors.black,
backgroundColor: Colors.transparent,
elevation: 0,
),
body: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('assets/images/secret_hamburger/SecretPage-background.png'),
fit: BoxFit.fill
)
),
child: Column(
children: [
SizedBox(height: 50),
Expanded(
child: Obx(() => PageView.builder(
controller: controller.pageController,
onPageChanged: (int index) {
controller.currentPageIndex = index;
},
itemCount: controller.secrets.length,
itemBuilder: (context, index) {
var secret = controller.secrets[index];
var author = secret.authorName == '' ? '익명' : secret.authorName;
return Padding(
padding: const EdgeInsets.all(50.0),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ListTile(
leading: ZoomIn(child: SizedBox(child: Image.asset(Burgers.mainHam))),
title: FadeInLeft(child: Text(author)),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ZoomIn(
child: Container(
width: 200,
height: 100,
color: Colors.grey[200],
child: Text(secret.secret),
),
),
),
],
),
),
);
}
)),
),
Obx(() => Text(
controller.isPageChanging.value
? controller.pageController.page!.toInt() > controller.currentPageIndex
? '먹을게!!!!!' // 안뜸
: '냠냠'
: '짜잔',
style: TextStyle(fontSize: 20),
)),
SizedBox(height: 30,)
],
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../controller/upload_controller.dart';
class UploadPage extends GetView<UploadController> {
const UploadPage({Key? key}) : super(key: key);
static const String route = '/upload';
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.white,
appBar: AppBar(
foregroundColor: Colors.black,
backgroundColor: Colors.transparent,
elevation: 0,
),
body: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 60),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 20),
Container(
child: Stack(
children: [
Image.asset('assets/images/secret_hamburger/UploadPage-burger.png'),
Padding(
padding: const EdgeInsets.only(left: 120.0, top: 20),
child: Align(
child: Obx(() => Text('${controller.hamText}'))
),
)
]
),
),
SizedBox(height: 50),
Container(
width: double.infinity,
height: 200,
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: controller.secretController,
maxLines: null,
textAlignVertical: TextAlignVertical.top,
decoration: InputDecoration(
hintText: '비밀을 말해보렴!!!',
border: InputBorder.none,
contentPadding: EdgeInsets.all(10),
),
),
),
SizedBox(height: 20),
Container(
width: double.infinity,
child: ElevatedButton(
onPressed: controller.upload,
child: Text('비밀 등록'),
),
),
Spacer()
],
),
),
),
);
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../controller/auth_controller.dart';
import '../widget/burgers.dart';
import '../widget/logout_dialog.dart';
class SettingPage extends StatelessWidget {
const SettingPage({Key? key}) : super(key: key);
static const String route = '/setting';
Widget build(BuildContext context) {
var controller = Get.find<AuthController>();
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.black,
elevation: 0,
),
body: Padding(
padding: const EdgeInsets.all(40.0),
child: SingleChildScrollView(
physics: NeverScrollableScrollPhysics(),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8.0),
),
child: ListTile(
leading: CircleAvatar(
child: Text(controller.user!.username.substring(0, 1)),
),
title: Text(controller.user!.username),
subtitle: Text(controller.user!.email),
),
),
ListTile(
leading: Icon(Icons.logout),
title: Text('로그아웃하기'),
onTap: () {
Get.dialog(LogoutDialog());
},
),
],
),
),
),
);
}
}
class Burgers {
static const mainHam = 'assets/images/secret_hamburger/main-icon.png';
static const uploadHam = 'assets/images/secret_hamburger/UploadPage-burger.png';
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'burgers.dart';
import '../../controller/auth_controller.dart';
class LogoutDialog extends StatelessWidget {
const LogoutDialog({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 150,
height: 150,
child: Image.asset(Burgers.mainHam)
),
Text('정말 로그아웃하실건가요...', style: TextStyle(fontWeight: FontWeight.bold)),
],
),
actions: [
TextButton(
child: Text('아닙니다...'),
onPressed: () {
Get.back();
},
),
TextButton(
child: Text('네', style: TextStyle(color: Colors.red)),
onPressed: Get.find<AuthController>().logout,
),
],
);
}
}
import 'package:dio/dio.dart';
import 'package:get/get.dart';
import '../model/user.dart';
import '../util/app_routes.dart';
import '../util/api_routes.dart';
class AuthController extends GetxController {
final Rxn<User> _user = Rxn();
final RxString _token = RxString('');
Dio dio = Dio();
User? get user => _user.value;
String get token => _token.value;
login(String id, String pw) async {
dio.options.baseUrl = ApiRoutes.baseUrl;
try {
var res = await dio.post(
ApiRoutes.authWithPassword,
data: {'identity': id, 'password': pw}
);
if (res.statusCode == 200) {
var user = User.fromMap(res.data['record']);
var token = res.data['token'];
_user(user);
_token(token);
}
} on DioError catch(e) {
Get.snackbar('로그인 실패!', '아이디와 비밀번호를 다시 확인해보게...');
print(e.message);
}
}
signUp(String email, String password, String passwordConfirm, String username) async {
dio.options.baseUrl = ApiRoutes.baseUrl;
try {
await dio.post(
ApiRoutes.signUp,
data: {
'email': email,
'password': password,
'passwordConfirm': passwordConfirm,
'username': username,
}
);
Get.snackbar('회원가입 성공~', '환영합니다');
Get.back();
} on DioError catch(e) {
print(e.message);
Get.snackbar('회원가입 실패', '환영할뻔');
}
}
logout() {
_user.value = null;
}
_handleAuthChanged(User? data) {
if (data != null) {
Get.offNamed(AppRoutes.main);
return;
}
Get.offAllNamed(AppRoutes.login);
return;
}
void onInit() {
super.onInit();
ever(_user, _handleAuthChanged);
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'auth_controller.dart';
class LoginController extends GetxController {
var idController = TextEditingController();
var pwController = TextEditingController();
login() {
Get.find<AuthController>().login(idController.text, pwController.text);
}
}
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'auth_controller.dart';
class SignUpController extends GetxController {
TextEditingController emailController = TextEditingController();
TextEditingController pwController = TextEditingController();
TextEditingController pwConfirmController = TextEditingController();
TextEditingController userNameController = TextEditingController();
signUp() {
String email = emailController.text.trim();
String password = pwController.text.trim();
String passwordConfirm = pwConfirmController.text.trim();
String username = userNameController.text.trim();
if (!isEmailValid(email)) {
// 이메일이 올바른 형식이 아닌 경우
snackBar('이메일이 비어있거나 올바른 형식이 아닌듯?');
return false;
}
if (password.length < 9) {
// 비밀번호가 9자 미만인 경우
snackBar('비밀번호가 비어있거나 9글자 미만인듯?');
return false;
}
if (password != passwordConfirm) {
// 비밀번호와 비밀번호 확인이 일치하지 않는 경우
snackBar('비밀번호가 일치하지 않는듯?');
return false;
}
Get.find<AuthController>().signUp(email, password, passwordConfirm, username);
return true;
}
bool isEmailValid(String email) {
// 이메일 형식이 올바른지 확인하는 정규식
final emailRegExp = RegExp(r'^[\w-]+(\.[\w-]+)*@([\w-]+\.)+[a-zA-Z]{2,7}$');
return emailRegExp.hasMatch(email);
}
void snackBar(String message) {
Get.snackbar('제대로 입력하쇼', message);
}
}
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../model/secret.dart';
import '../util/api_routes.dart';
class SecretController extends GetxController {
RxList<Secret> secrets = RxList();
Dio dio = Dio();
PageController pageController = PageController();
RxBool isPageChanging = false.obs;
int currentPageIndex = 0;
readSecrets() async {
dio.options.baseUrl = ApiRoutes.baseUrl;
try {
var res = await dio.get(ApiRoutes.readSecret);
if (res.statusCode == 200) {
List<Map<String, dynamic>> data = List<Map<String, dynamic>>.from(res.data['items']);
secrets(data.map((e) => Secret.fromMap(e)).toList().obs);
}
} on DioError catch(e) {
print(e.message);
}
}
void _handlePageChange() {
if (pageController.page!.toInt() != currentPageIndex) {
isPageChanging.value = true;
} else {
isPageChanging.value = false;
}
}
void onInit() {
super.onInit();
readSecrets();
pageController = PageController(initialPage: currentPageIndex);
pageController.addListener(_handlePageChange);
}
void onClose() {
super.onClose();
pageController.dispose();
}
}
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../util/api_routes.dart';
import 'auth_controller.dart';
class UploadController extends GetxController {
TextEditingController secretController = TextEditingController();
Dio dio = Dio();
RxString hamText = '진짜\n나만 알고\n잇을게'.obs;
var user = Get.find<AuthController>().user;
upload() async {
dio.options.baseUrl = ApiRoutes.baseUrl;
if (secretController.text == '') return;
try {
var res = await dio.post(
ApiRoutes.uploadSecret,
data: {
'secret': secretController.text,
'author': user!.id,
'authorName': user!.username,
}
);
if (res.statusCode == 200) {
Get.snackbar('비밀 등록 성공!', '야호');
hamText('헤헤\n뻥이지롱');
Future.delayed(Duration(seconds: 3), () {
hamText('진짜\n나만 알고\n잇을게');
});
}
} on DioError catch(e) {
Get.snackbar('비밀 등록 실패', 'ㅠㅠ');
print(e.message);
}
}
}
import 'package:get/get.dart';
import '../view/page/main_page.dart';
import '../view/page/login_page.dart';
import '../view/page/secret_page.dart';
import '../view/page/upload_page.dart';
import '../view/page/sign_up_page.dart';
import '../view/page/setting_page.dart';
class AppPages {
static final pages = [
GetPage(name: MainPage.route, page: () => const MainPage()),
GetPage(name: LoginPage.route, page: () => const LoginPage()),
GetPage(name: SecretPage.route, page: () => const SecretPage()),
GetPage(name: UploadPage.route, page: () => const UploadPage()),
GetPage(name: SignUpPage.route, page: () => const SignUpPage()),
GetPage(name: SettingPage.route, page: () => const SettingPage()),
];
}
import '../view/page/login_page.dart';
import '../view/page/main_page.dart';
import '../view/page/secret_page.dart';
import '../view/page/upload_page.dart';
import '../view/page/sign_up_page.dart';
import '../view/page/setting_page.dart';
class AppRoutes {
static const login = LoginPage.route;
static const main = MainPage.route;
static const secret = SecretPage.route;
static const upload = UploadPage.route;
static const signUp = SignUpPage.route;
static const setting = SettingPage.route;
}
class ApiRoutes {
static const String baseUrl = 'http://52.79.115.43:8090/';
static const String authWithPassword = 'api/collections/users/auth-with-password';
static const String signUp = 'api/collections/users/records';
static const String readSecret = 'api/collections/secrets/records?sort=-created';
static const String uploadSecret = 'api/collections/secrets/records';
}
저번 과제에서는 페이지 이동을 판별하여 하단에 먹을게, 뱉을게를 출력하는 먹뱉기능을 구현하지 못해 '먹을게'를 고정 텍스트로 뒀었는데, 이번 과제에서는 먹기만 할 줄 알았던 햄버거가 이제 멈추는 방법도 알게 되엇습니다...
먹뱉기능은 아니지만 냠냠기능도 조금 뿌듯하네요👍