위 게시물에서 이어집니다.
1. 로그인
1) LoginPage
login_body.dart
const SizedBox(height: largeGap),
CustomElevatedButton(
text: "로그인",
click: () {
gvm.login(_username.text.trim(), _password.text.trim());
},
),
2) sessionGVM (로그인 메서드)
session_gvm.dart
class SessionUser {
int? id;
String? username;
String? accessToken;
bool? isLogin;
SessionUser({this.id, this.username, this.accessToken, this.isLogin = false});
}
// GVM -> Global View Model
class SessionGVM extends Notifier<SessionUser> {
// main.dart에서 설정한 navigatorKey 를 가져온다.
final mContext = navigatorKey
.currentContext!;
UserRepository userRepository = const UserRepository();
@override
SessionUser build() {
return SessionUser(
id: null, username: null, accessToken: null, isLogin: false);
}
Future<void> login(String username, String password) async {
// Map 안 쓰는 이유 -> Dart는 타입 추론이 가능
final requestBody = {
"username": username,
"password": password,
};
// 구조 분해 할당으로 responseBody와 accessToken 모두 반환
final (responseBody, accessToken) =
await userRepository.findByUsernameAndPassword(requestBody);
if (!responseBody["success"]) {
// 로그인 실패
ScaffoldMessenger.of(mContext!).showSnackBar(
SnackBar(content: Text("로그인 실패 : ${responseBody["errorMessage"]}")),
);
return;
}
// 로그인 코드 아래쪽에 작성
// 화면 전환
Navigator.popAndPushNamed(mContext, "/post/list");
}
}
final sessionProvider = NotifierProvider<SessionGVM, SessionUser>(() {
return SessionGVM();
});
- final requestBody = { "username": username, "password": password, };
- Map<String, String> 생략 가능한 이유 → Dart는 타입 추론이 가능하기 때문입니다.
- final은 불변이라 붙였습니다.
- login 요청 시 돌아오는 response 예시
HTTP/1.1 200 OK
// 이외 header 생략
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJpbWdVcmwiOiIvaW1hZ2VzLzEucG5nIiwic3ViIjoibWV0YWNvZGluZyIsImlkIjoxLCJleHAiOjE3MzU5Njc1NzQsInVzZXJuYW1lIjoic3NhciJ9.-xTRkcd_0AE2lEpkj0Q4e7ooDpzVIPAtQlV4H8OPUHZGWQJfAY5i1xDEl-BJ8PWUvr4B5R6l5SOw-R7xd-friQ
Content-Type: application/json;charset=UTF-8
Content-Length: 166
{
"success" : true,
"response" : {
"id" : 1,
"username" : "ssar",
"imgUrl" : "/images/1.png"
},
"status" : 200,
"errorMessage" : null
}
- success로 로그인 성공 여부 판별이 가능합니다.
로그인 코드
// 1. 토큰을 Storage 저장 -> flutter_secure_storage 라이브러리 필요
await secureStorage.write(
key: "accessToken", value: accessToken); // I/O -> 오래 걸린다. -> await
// 2. SessionUser 갱신
Map<String, dynamic> data = responseBody["response"];
state = SessionUser(
id: data["id"],
username: data["username"],
accessToken: accessToken,
isLogin: true,
);
// 3. Dio 토큰 세팅, dio는 메모리 저장이라 await 필요 없음 -> 껐다 키면 없어지는 데이터
dio.options.headers["Authorization"] = accessToken;
- 토큰을 Storage에 저장합니다.
- flutter_secure_storage 라이브러리로 SecureStorage 안에 토큰 내용을 저장합니다.
- I/O 과정이라 오래 걸리기 때문에 await를 걸어줘야 합니다.
- SessionUser(상태)를 갱신합니다.
- 토큰은 헤더에 저장되기 때문에 dio.options.headers 로 저장합니다.
3) user_repository.dart
class UserRepository {
const UserRepository();
// Login관련 메서드, 구조 분해 할당 적용
Future<(Map<String, dynamic>, String)> findByUsernameAndPassword(
Map<String, dynamic> data) async {
Response response = await dio.post("/login", data: data);
Map<String, dynamic> body = response.data;
// 로그인 실패 시 토큰이 null이기 때문에 return에서 문제 생기지 않도록 초기화
String accessToken = "";
// 로그인 실패 시 토큰 없어서 예외가 발생하기 때문에 try catch 로
try {
accessToken = response
.headers["Authorization"]![0];
} catch (e) {}
// body와 토큰 동시에 보내기 위해 구조 분해 할당 사용
return (body, accessToken);
}
}
- dio.post("/login", data: data)
- dio 메서드를 이용해 /login으로 post 요청을 보냅니다.
- accessToken = response.headers["Authorization"]![0];
- 토큰이 null일 경우 예외가 발생하므로 try catch로 감싸줍니다.
- [0]이라고 적어야 하는 이유
response.headers = {
"Authorization": [
"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9...",
"Bearer anotherTokenHere..."
]
};
// 첫 번째 토큰 값
String accessToken1 = response.headers["Authorization"]![0];
// 두 번째 토큰 값
String accessToken2 = response.headers["Authorization"]![1];
- return (body, accessToken);
- 구조 할당 분해로 두 데이터를 동시에 반환합니다.
- String accessToken = null일 경우 예외가 발생하므로 String accessToken = ""; 이렇게 미리 초기화합니다.
4) user_repository_test.dart
void main() async {
TestRepository testRepository = TestRepository();
await testRepository.testFindByUsernameAndPassword();
// await testRepository.testSave();
}
class TestRepository {
UserRepository userRepository = const UserRepository();
// 회원 가입 메서드 생략
Future<void> testSave() async {
}
Future<void> testFindByUsernameAndPassword() async {
// 1. given
final requestBody = {
"username": "ssar1",
"password": "1234",
};
// 2. when
(Map<String, dynamic>, String) responseBody =
await userRepository.findByUsernameAndPassword(requestBody);
// 3. then -> Logger
}
}
2. 자동 로그인
main.dart
// Stack의 가장 위 context를 알고 있다.
GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
navigatorKey: navigatorKey,
// context가 없는 곳에서 context를 사용할 수 있는 방법
debugShowCheckedModeBanner: false,
home: SplashPage(),
routes: {
"/login": (context) => const LoginPage(),
"/join": (context) => const JoinPage(),
"/post/list": (context) => PostListPage(),
"/post/write": (context) => const PostWritePage(),
},
theme: theme(),
);
}
}
1) SplashPage
- home으로 설정되어 앱 실행 시 바로 열리는 페이지
class SplashPage extends ConsumerWidget {
const SplashPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.read(sessionProvider.notifier).autoLogin();
return Scaffold(
body: // 앱 최초 로딩 화면 생략
);
}
}
2) sessionGVM (자동 로그인 메서드)
session_gvm.dart
class SessionUser {
// 생략
}
class SessionGVM extends Notifier<SessionUser> {
// 0. 이 메서드가 실행되는 시점에는 SessionUser가 있을 수가 없다.
Future<void> autoLogin() async {
// 1. 디바이스에서 토큰 가져오기 (오래 걸리는 작업)
String? accessToken = await secureStorage.read(key: "accessToken");
// 토큰 없을 경우 로그인 화면으로
if (accessToken == null) {
Navigator.popAndPushNamed(mContext, "/login");
return;
}
// 2. 로그인 통신
Map<String, dynamic> responseBody =
await userRepository.autoLogin(accessToken);
// 로그인 실패 시
if (!responseBody["success"]) {
Navigator.popAndPushNamed(mContext, "/login");
return;
}
// 3. 로그인 성공 시 SessionUser 상태 업데이트
Map<String, dynamic> data = responseBody["response"];
state = SessionUser(
id: data["id"],
username: data["username"],
accessToken: accessToken,
isLogin: true);
dio.options.headers["Authorization"] = accessToken;
// 4. 화면 이동
Navigator.popAndPushNamed(mContext, "/post/list");
}
}
- 디바이스에서 토큰 가져오기
- String? accessToken = await secureStorage.read(key: "accessToken")
- 오래 걸리는 작업(I/O)이므로 await를 걸어줍니다.
I/O (Input/Output) 작업이란 데이터를 읽거나 쓰는 작업으로, 주로 파일 시스템, 네트워크, 데이터베이스와 같은 외부 자원과의 상호 작용을 포함합니다
3) user_repository.dart
class UserRepository {
const UserRepository();
Future<Map<String, dynamic>> autoLogin(String accessToken) async {
Response response = await dio.post(
"/auto/login",
options: Options(headers: {"Authorization": accessToken}),
);
Map<String, dynamic> body = response.data;
return body;
}
}
- dio.post( "/auto/login", options: Options( headers: {"Authorization": accessToken} ) );
- body에 보낼 data는 없습니다.
- 토큰을 보내서 검증받아야 하기 때문에 header에 토큰 정보는 담아서 보냅니다.
3. 로그아웃
1) 로그아웃 버튼에서 onPressed 설정을 하면 됩니다.
custom_navigator.dart
TextButton(
onPressed: () {
gvm.logout();
},
child: const Text(
"로그아웃",
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black54,
),
),
)
2) sessionGVM (로그아웃 메서드)
session_gvm.dart
Future<void> logout() async {
// 1. 디바이스 토큰 삭제
await secureStorage.delete(key: "accessToken");
// 2. 상태 갱신 (SessionUser 정보 삭제)
state = SessionUser( );
// 3. dio 갱신
dio.options.headers["Authorization"] = "";
// 4. 화면 이동
Navigator.popAndPushNamed(mContext, "/login");
}
상태 갱신
// 생성자
SessionUser({this.id, this.username, this.accessToken, this.isLogin = false});
// 상태 갱신
state = SessionUser( );
- 생성자에 매개변수를 넣지 않았으니 id, username, accessToken은 null이 되고 isLogin 은 디폴트값인 false가 들어갑니다.
Share article