연습용 api문서를 참고해 서버를 실행한다.
https://blog.naver.com/getinthere/222426449415
우리 서버로 요청을 보낼것이므로 core패키지에 아래 설정을 추가한다.
import 'package:dio/dio.dart';
final dio = Dio(BaseOptions(
baseUrl: "http://192.168.200.124:8080", // 위에서 실행시킨 스프링서버
contentType: "application/json; charset=utf-8",
));
dio 는 BaseOptions
을 매개변수로 넣어 생성자를 만들 수 있다.
void main() async {
await fetchJoin_test();
}
Future<void> fetchJoin_test() async {
// given
String username = "gildong";
String password = "1234";
String email = "gildong@nate.com";
// when
Map<String, dynamic> requestBody = {
"username": username,
"password": password,
"email": email
};
Response response = await dio.post("/join", data: requestBody);
print(response.data);
}
http 라이브러리에서는 Map데이터를 jsonEncode
을 이용해 String으로 변환해 보낸다.
dio 라이브러리를 이용하면 json변환 없이 Map데이터를 보내면 되고 응답받는 json을 Map으로 변환시켜준다.
터미널에서 플러터 테스트 실행하기
flutter test test/model/auth/auth_repository_test.dart
api문서에서는 다음과 같은 json을 리턴받는다고 되어있다.
{
"code": 1,
"msg": "회원가입완료",
"data": {
"id": 3,
"username": "getinthere",
"password": null,
"email": "getinthere@nate.com",
"created": "2021-07-10T07:45:15.764705",
"updated": "2021-07-10T07:45:15.764705"
}
}
I/O 결과는 터미널에 출력되었다.
fetchJoin_test
에 코드를 추가
Response response = await dio.post("/join", data: requestBody);
// print(response.data);
ResponseDTO responseDTO = ResponseDTO.fromJson(response.data);
print(responseDTO.code);
print(responseDTO.msg);
print(responseDTO.data);
// User result = User.fromJson(responseDTO.data); // map타입을(dynamic) User타입으로 변환
// print(result.email); // 입력한 이메일 출력
responseDTO.data =
User.fromJson(responseDTO.data); // dynamic 타입이므로 다시 자신으로 리턴가능
// print(responseDTO.data); // User 타입이므로 [Instance of 'User'] 출력됨
User user = responseDTO.data;
// print(user.email); // 유저 이메일에 접근
fromJson
메소드로 map데이터를 dart오브젝트에 넣는다.
map데이터를 받기 위한 오브젝트를 만든다.
서버로부터 오는 모든 응답을 받으므로 api문서를 참고해 틀을 만든다.
class ResponseDTO {
final int? code;
final String? msg;
String? token;
dynamic data; // JsonArray [], JsonObject {}
// dynamic 쓰는 이유는 타입이 자유롭기 때문에 다양한 타입으로 변환가능
ResponseDTO({
this.code,
this.msg,
this.data,
});
ResponseDTO.fromJson(Map<String, dynamic> json)
: code = json["code"],
msg = json["msg"],
data = json["data"];
}
테스트 결과는
테스트 통과후 실제 코드로 가져간다.
Future<void> fetchLogin_test() async {
// given
String username = "ssar";
String password = "1234";
// when
Map<String, dynamic> requestBody = {
"username": username,
"password": password
};
// 1. 통신 시작
Response response = await dio.post("/login", data: requestBody);
// 2. DTO 파싱
ResponseDTO responseDTO = ResponseDTO.fromJson(response.data);
responseDTO.data = User.fromJson(responseDTO.data); // map -> User 변환
// 3. 토큰 받기
final authorization = response.headers["Authorization"];
if (authorization != null) {
responseDTO.token = authorization.first; // headers는 map<String,String>의 배열로 되어 있다.
}
print(responseDTO.code);
print(responseDTO.msg);
print(responseDTO.token);
User user = responseDTO.data;
print(user.id);
print(user.username);
}
결과는
$ flutter test test/model/auth/auth_repository_test.dart
00:01 +0: loading C:\Temp\Flutter-RiverPod_MVCS_Blog_Start\test\model\auth\auth_repository_test.dart
1
성공
[Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb3PthqDtgbAiLCJpZCI6MSwiZXhwIjoxNjgyNTc2Nzk0fQ.52h441fuX29U1qEd01HxfXje3-0hQXaPOO90SVDSxsBgTJ4KFj-qgKABmlofpQeQxiOVlA1pS0xFxGNxKIu4ag]
1
ssar
폼에 데이터를 입력할때 유효성 검사를 해야하는데 다음과 같은 방법을 이용해보자.
class CustomTextFormField extends StatelessWidget {
final String hint;
final funValidator;
final controller;
const CustomTextFormField({
required this.hint,
required this.funValidator,
this.controller,
});
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: TextFormField(
controller: controller,
validator: funValidator,
obscureText: hint == "Password" ? true : false,
decoration: InputDecoration(
hintText: "Enter $hint",
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
),
),
);
}
}
obscureText
는 텍스트필드에 입력된 데이터를 ****
처럼 숨긴다. ->
텍스트폼 이므로 controller
에는 TextEditingController
를 넣는다.
final _password = TextEditingController();
CustomTextFormField(
controller: _password,
hint: "Password",
funValidator: validatePassword(),
)
TextEditingController
는 폼에 입력된 데이터를 동적으로 읽고 수정할 수 있다.
TextEditingController
의 onChanged
같은 속성을 이용하면 입력할 때마다 호출될 기능을 넣을 수 있다.
텍스트 필드의 validator
에는 입력된 데이터의 유효성을 검사하는 콜백함수를 넣는다.
사용자가 입력한 데이터가 유효하다면 null
을 리턴하고 유효하지 않다면 문자열을 리턴한다.
validatePassword()
-> 콜백함수는 외부로 분리시킨다.
Function validatePassword() {
return (String? value) {
if (value!.isEmpty) {
return "패스워드 공백이 들어갈 수 없습니다.";
} else if (value.length > 12) {
return "패스워드의 길이를 초과하였습니다.";
} else if (value.length < 4) {
return "패스워드의 최소 길이는 4자입니다.";
} else {
return null;
}
};
}
유효성을 통과하지 못하면 텍스트필드의 validator
는 위의 문자열을 리턴한다.
class LoginForm extends ConsumerWidget {
final _formKey = GlobalKey<FormState>();
final _username = TextEditingController();
final _password = TextEditingController();
LoginForm({Key? key}) : super(key: key);
Widget build(BuildContext context, WidgetRef ref) {
return Form(
key: _formKey,
child: Column(
children: [
CustomTextFormField(
controller: _username,
hint: "Username",
funValidator: validateUsername(),
),
CustomTextFormField(
controller: _password,
hint: "Password",
funValidator: validatePassword(),
),
CustomElevatedButton(
text: "로그인",
funPageRoute: () async {
if (_formKey.currentState!.validate()) {
ref
.read(userControllerProvider)
.login(_username.text.trim(), _password.text.trim());
}
},
),
TextButton(
onPressed: () {
Navigator.pushNamed(context, Move.joinPage);
},
child: const Text("아직 회원가입이 안되어 있나요?"),
),
TextButton(
onPressed: () {
Navigator.pushNamed(context, Move.postHomePage);
},
child: const Text("홈페이지 로그인 없이 가보는 테스트"),
),
],
),
);
}
}
Form
위젯과 상호작용을 하기위해 해당 폼의 고유한 GlobalKey
를 생성한다.
_formKey.currentState
로 해당 FormState
의 인스턴스를 얻고 validate()
로 해당 Form 위젯의 모든 텍스트필드의 유효성을 확인한 후 유효하다면 null
을 리턴하고 유효하지 않다면 입력한 문자열을 리턴한다.
텍스트필드 위젯의 validator
가 모두 null
을 리턴하게 되면 _formKey.currentState!.validate()
는 true
가 되어 입력된 기능을 수행한다.
ref.read(userControllerProvider)
로 프로바이더에 접근하고 해당 컨트롤러의 메소드를 호출한다.
플러터에서는 라우팅할 때 해당 페이지의 컨텍스트를 참고해서 라우팅한다.
하지만 컨트롤러에서 라우팅을 한다면 컨텍스트가 존재하지 않아서 라우팅 로직을 만들 수가 없다.
이럴때는 main.dart
에서 GlobalKey
를 이용해서 위젯트리에 접근할 수 있다.
아래 코드를 추가해서 위젯트리의 네이게이션을 navigatorKey
으로 어디서든 접근 가능하다.
// GlobalKey
GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() async {
// 생략
}
컨트롤러에서 네비게이션에 접근한다면 다음코드를 추가해서 이용하면된다.
class UserController {
final mContext = navigatorKey.currentContext;
// 생략
}
예를들어 버튼을 눌렀을때 해당 컨트롤러의 로직에서 스낵바를 날린다면
ScaffoldMessenger.of(mContext!)
.showSnackBar(const SnackBar(content: Text("회원가입 실패")));
// 더 간단한 토스트
// showToast('로그인 실패 !!');
dependencies:
shared_preferences: ^2.0.8
안드로이드 플랫폼에서 제공하는 기능으로 Map 형태의 데이터를 저장한다.
SharedPreferences
를 이용하면 앱이 종료되어도 데이터는 삭제되지 않고 남아있게 된다.
이를 이용해 앱이 다시 시작되어도 동일한 데이터를 유지할 수 있게 된다.( 로그인 및 여러 상태 정보 )
예를들어 안드로이드라면 data/data/[package-name]/shared_prefs
폴더에 XML 파일로 저장된다.
dependencies:
flutter_secure_storage: ^7.0.0
데이터를 안전하게 저장하기 위해서 SecureStorage
를 이용한다.
사용자의 토큰이나 비밀번호같은 정보가 저장된다. (ex. 계정 정보 저장)
앱 개발을 한다면 앱 실행시 가장 먼저 이러한 저장소에서 토큰과 같은 상태 정보를 검사해야한다.
주로 스플래쉬 페이지에서 토큰을 검사하고 메인 페이지 데이터를 다운받은 뒤 다음 화면을 렌더링한다.
따라서 최초 스플래쉬 페이지에서는 동기적으로 토큰을 먼저 다운 및 확인을 한다.
// 어디서든 컨트롤러를 호출할수 있도록 프로바이더에 등록
final userControllerProvider = Provider<UserController>((ref) {
return UserController(ref);
});
class UserController {
// GlobalKey를 이용해서 라우팅
final mContext = navigatorKey.currentContext;
final Ref ref;
UserController(this.ref);
Future<void> login(String username, String password) async {
LoginReqDTO loginReqDTO =
LoginReqDTO(username: username, password: password);
ResponseDTO responseDTO = await UserRepository().fetchLogin(loginReqDTO);
if (responseDTO.code == 1) {
// 1. 토큰을 휴대폰에 저장 -> SecureStorage
await secureStorage.write(key: "jwt", value: responseDTO.token);
// 2. 로그인 상태 등록 -> 세션 관리
ref
.read(sessionProvider)
.loginSuccess(responseDTO.data, responseDTO.token!);
// 3. 화면 이동 -> GlobalKey 이용
Navigator.popAndPushNamed(mContext!, Move.postHomePage);
} else {
showToast('로그인 실패 !!');
}
}
}
Future<ResponseDTO> fetchLogin(LoginReqDTO loginReqDTO) async {
try {
// 1. 통신 시작
Response response = await dio.post("/login", data: loginReqDTO.toJson());
// 2. DTO 파싱
ResponseDTO responseDTO = ResponseDTO.fromJson(response.data);
responseDTO.data = User.fromJson(responseDTO.data);
// 3. 토큰 받기
final authorization = response.headers["authorization"];
if (authorization != null) {
responseDTO.token = authorization.first;
}
return responseDTO;
} catch (e) {
return ResponseDTO(code: -1, msg: "유저네임 혹은 비번이 틀렸습니다");
}
}
서버로 로그인 데이터를 보내서 로그인을 성공하게 되면 서버에서는 로그인정보와 토큰을 리턴한다.
레파지토리는 통신과 파싱을 하고 컨트롤러에서 반환 받은 토큰을 secureStorage
에 저장한다.
로그인 상태 등록은 밑에서 이어 설명한다.
앱 실행시 자동로그인을 구현하기 위해서는 로그인시 로그인 데이터를 기기에 저장해두어야 한다.
이를 위해서 SecureStorage
를 이용했고 앱 실행시에는 이 저장소에서 로그인과 관련된 데이터를 가져와야 한다.
SecureStorage
에 JWT를 저장해두었는데 스플래쉬 페이지에서 이 토큰이 유효한지 확인을 먼저 해야한다.
토큰을 검사하기 위해서 렌더링 전에 서버에 토큰을 날려서 유효한지 확인을 받는다.
SessionUser sessionUser = await UserRepository().fetchJwtVerify();
Future<SessionUser> fetchJwtVerify() async {
SessionUser sessionUser = SessionUser();
// 디바이스에서 토큰 가져옴
String? deviceJwt = await secureStorage.read(key: "jwt");
if (deviceJwt != null) {
try {
Response response = await dio.get("/jwtToken",
options: Options(headers: {"Authorization": deviceJwt}));
ResponseDTO responseDTO = ResponseDTO.fromJson(response.data);
responseDTO.token = deviceJwt;
responseDTO.data = User.fromJson(responseDTO.data);
if (responseDTO.code == 1) {
sessionUser.loginSuccess(responseDTO.data, responseDTO.token!);
} else {
sessionUser.logoutSuccess();
}
return sessionUser;
} catch (e) {
Logger().d("에러 이유 : $e");
sessionUser.logoutSuccess();
return sessionUser;
}
} else {
sessionUser.logoutSuccess();
return sessionUser;
}
}
서버에 요청을 보낼때 바디데이터는 없고 헤더만 있으므로 get요청을 한다.
위에서 다시 토큰을 응답 받았는데 자동로그인 기간을 갱신하는 등의 작업을 추가할 수 있다.
토큰이 유효하지 않다면 logoutSuccess
를 호출해서 모든 로그인정보를 제거한다.
세션을 저장하는 스토어를 프로바이더로 만들어 어디서든 접근할 수 있도록 한다.
final sessionProvider = Provider<SessionUser>((ref) {
return SessionUser();
});
// 최초 앱이 실행될 때 초기화 되어야 함.
// 1. JWT 존재 유무 확인 (I/O) - 디바이스
// 2. JWT로 회원정보 받아봄 (I/O) - 서버
// 3. OK -> loginSuccess() 호출
// 4. FAIL -> loginPage로 이동 or 유저 데이터가 없는 MainPage
class SessionUser {
User? user;
String? jwt;
bool? isLogin;
void loginSuccess(User user, String jwt) {
this.user = user;
this.jwt = jwt;
isLogin = true;
}
Future<void> logoutSuccess() async {
user = null;
jwt = null;
isLogin = false;
// I/O가 발생하는 모든 접근은 비동기로 수행
await secureStorage.delete(key: "jwt");
Logger().d("세션 종료 및 디바이서 jwt 삭제");
}
}
로그인 상태에따라 메인화면의 정보나 페이지가 달라질 수 있으므로 사용자 경험을 좋게 하기 위해 이러한 작업은 동기적으로 수행되어야 한다. 즉 모든 상태정보의 확인이 끝난뒤 화면을 렌더링한다.
GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// 1. secure Storage 안에 jwt 확인
// 2. jwt로 회원정보를 가져옴
// 3. SessionUser 동기화
// 서버에 요청을 보내서 검사했지만 프론트에서 토큰으로 세션을 만드는 방법도 있음
SessionUser sessionUser = await UserRepository().fetchJwtVerify();
runApp(
ProviderScope(
// 세션을 업데이트
overrides: [sessionProvider.overrideWithValue(sessionUser)],
child: const MyApp(),
),
);
}
class MyApp extends ConsumerWidget {
const MyApp({super.key});
Widget build(BuildContext context, WidgetRef ref) {
SessionUser sessionUser = ref.read(sessionProvider);
return MaterialApp(
navigatorKey: navigatorKey, // 글로벌키를 이용하면 어디서든 위젯트리에 접근가능
debugShowCheckedModeBanner: false,
// 자동로그인 기능 -> 사용자 경험을 좋게 만든다.
initialRoute: sessionUser.isLogin! ? Move.postHomePage : Move.loginPage,
routes: getRouters(),
);
}
}
getRouters()
-> 간단히 페이지 라우팅
class Move {
static String postHomePage = "/post/home";
static String postWritePage = "/post/write";
static String joinPage = "/join";
static String loginPage = "/login";
static String userDetailPage = "/user/detail";
}
Map<String, Widget Function(BuildContext)> getRouters() {
return {
Move.joinPage: (context) => JoinPage(),
Move.loginPage: (context) => LoginPage(),
Move.postHomePage: (context) => PostHomePage(),
Move.postWritePage: (context) => PostWritePage(),
Move.userDetailPage: (context) => const UserDetailPage(),
};
}