MVVM 패턴과 상태관리 라이브러리의 사용을 이해하는 데 초점을 맞춰 model과 repository는 생략하고, service도 역할만 가상으로 지정하여 간단하게 설계해보았다.
먼저 Provider
상태관리 라이브러리를 사용하였다.
class AuthService {
// 인증 번호 요청
Future<void> requestPhoneVerification({required String phoneNumber}) async {
return;
}
// 인증번호 인증
Future<bool> verifySMSCode({required String smsCode}) async {
return true;
}
// 로그인 상태 확인
Future<bool> isLoggedIn() async {
return true;
}
}
requestPhoneVerification
: 사용자에게 인증 요청을 보내는 역할
verifySMSCode
: 인증코드로 인증을 시도하는 역할
isLoggedIn
: 사용자의 로그인 상태를 확인하는 역할
import 'package:flutter/material.dart';
import 'package:flutter_velog/services/auth_service.dart';
// viewmodel 상태 enum
enum LoginViewState { idle, error, loading, smsCodeSent, loggedIn }
class LoginViewModel extends ChangeNotifier {
final AuthService authService;
LoginViewModel({required this.authService});
// 사용자 입력 전화번호
String phoneNumber = '';
// viewmodel 상태
LoginViewState state = LoginViewState.idle;
// 로그인 상태 점검
Future<void> verifyLoginStatus() async {
if (await authService.isLoggedIn()) {
state = LoginViewState.loggedIn;
notifyListeners();
}
}
// 로그인 시도
Future<void> phoneNumberLogin() async {
// 로딩
state = LoginViewState.loading;
notifyListeners();
// 인증요청 시도
try {
await authService.requestPhoneVerification(phoneNumber: phoneNumber);
// 인증화면전환
state = LoginViewState.smsCodeSent;
} catch (e) {
// 인증요청 에러
state = LoginViewState.error;
} finally {
notifyListeners();
}
}
}
class LoginViewModel extends ChangeNotifier {
final AuthService authService;
LoginViewModel({required this.authService});
...
}
AuthService
를 인자로 받아서 사용할 수 있도록 하였다.
enum LoginViewState { idle, error, loading, smsCodeSent, loggedIn }
LoginViewModel
의 상태를 enum
으로 관리하도록 하였다.
idle
: 평상시, 상태 없음
error
: 에러 발생
loading
: 로딩
smsCodeSent
: 인증 요청
loggedIn
: 화면 첫 진입 시 로그인 여부 확인 후 자동 로그인
Future<void> verifyLoginStatus() async {
if (await authService.isLoggedIn()) {
state = LoginViewState.loggedIn;
}
}
화면 첫 진입 시 사용자의 로그인 여부를 확인할 수 있다.
Future<void> phoneNumberLogin() async {
// 1
state = LoginViewState.loading;
notifyListeners();
try {
// 2
await authService.requestPhoneVerification(phoneNumber: phoneNumber);
// 3
state = LoginViewState.smsCodeSent;
} catch (e) {
// 4
state = LoginViewState.error;
} finally {
// 5
notifyListeners();
}
}
loading
으로 전환한다.AuthService
내의 함수를 이용하여 인증 요청을 수행한다.smsCodeSent
로 전환한다.error
로 전환한다.import 'package:flutter/material.dart';
import 'package:flutter_velog/services/auth_service.dart';
enum SmsCodeViewState { idle, error, loading, verified }
class SmsCodeViewModel extends ChangeNotifier {
final AuthService authService;
SmsCodeViewModel({required this.authService});
// 사용자 요청 인증코드
String smsCode = '';
// viewmodel 상태
SmsCodeViewState state = SmsCodeViewState.idle;
Future<void> verifySMSCode() async {
// 로딩
state = SmsCodeViewState.loading;
notifyListeners();
// 인증 시도
try {
bool success = await authService.verifySMSCode(smsCode: smsCode);
if (success) {
// 성공 시 화면전환
state = SmsCodeViewState.verified;
} else {
// 실패 시 에러
state = SmsCodeViewState.error;
}
} catch (e) {
// 인증 에러
state = SmsCodeViewState.error;
} finally {
notifyListeners();
}
}
}
enum SmsCodeViewState { idle, error, loading, verified }
SmsCodeViewModel
의 상태를 enum
으로 관리하도록 하였다
idle
: 평상시, 상태 없음
error
: 에러 발생
loading
: 로딩
verified
: 인증 됨
Future<void> verifySMSCode() async {
// 1
state = SmsCodeViewState.loading;
notifyListeners();
try {
// 2
bool success = await authService.verifySMSCode(smsCode: smsCode);
if (success) {
// 3
state = SmsCodeViewState.verified;
} else {
// 4
state = SmsCodeViewState.error;
}
} catch (e) {
// 5
state = SmsCodeViewState.error;
} finally {
// 6
notifyListeners();
}
}
loading
으로 전환한다.AuthService
내의 함수를 이용하여 인증번호로 인증 요청을 수행한다.verified
로 전환한다.error
로 전환한다.error
로 전환한다.import 'package:flutter/material.dart';
import 'package:flutter_velog/services/auth_service.dart';
import 'package:flutter_velog/viewmodels/login_view_model.dart';
import 'package:flutter_velog/views/login_view.dart';
import 'package:provider/provider.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter MVVM',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: ChangeNotifierProvider(
create: (_) => LoginViewModel(
authService: AuthService(),
),
child: const LoginView(),
),
);
}
}
home: ChangeNotifierProvider(
create: (_) => LoginViewModel(
authService: AuthService(),
),
child: const LoginView(),
)
LoginView
를 생성할 때 ChangeNotifierProvider
를 이용하여 LoginViewModel
을 의존성 주입한다.
class LoginView extends StatelessWidget {
const LoginView({super.key});
final String phoneNumberString = "휴대폰 번호";
final String loginString = "로그인";
final String errorMessage = "전화번호를 확인해주세요요";
Widget build(BuildContext context) {
final loginViewModel = Provider.of<LoginViewModel>(context);
WidgetsBinding.instance.addPostFrameCallback(
(_) async {
// 로그인 상태 검사
await loginViewModel.verifyLoginStatus();
if (loginViewModel.state == LoginViewState.loggedIn) {
// 홈화면 이동
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const HomeView()));
}
} else if (loginViewModel.state == LoginViewState.smsCodeSent) {
if (context.mounted) {
// 인증번호 입력 이동
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ChangeNotifierProvider(
create: (_) => SmsCodeViewModel(
authService: loginViewModel.authService),
child: const SmsCodeView(),
)));
}
}
},
);
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
loginViewModel.state == LoginViewState.loading
? const CircularProgressIndicator()
: TextField(
onChanged: (value) => loginViewModel.phoneNumber = value,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10)),
hintText: phoneNumberString),
),
const SizedBox(height: 10),
if (loginViewModel.state == LoginViewState.error)
Text(
errorMessage,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 10),
SizedBox(
width: double.maxFinite,
child: FilledButton(
onPressed: () => loginViewModel.phoneNumberLogin(),
child: Text(loginString)))
],
),
);
}
}
final loginViewModel = Provider.of<LoginViewModel>(context);
주입된 LoginViewModel
을 가져온다.
WidgetsBinding.instance.addPostFrameCallback( ...
화면 전환과 같은 작업은 addPostFrameCallback
을 사용하여 화면이 전부 빌드된 후에 실행되도록 한다.
if (loginViewModel.state == LoginViewState.loggedIn) {
// 홈화면 이동
if (context.mounted) {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const HomeView()));
}
} else if (loginViewModel.state == LoginViewState.smsCodeSent) {
if (context.mounted) {
// 인증번호 입력 이동
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => ChangeNotifierProvider(
create: (_) => SmsCodeViewModel(
authService: loginViewModel.authService),
child: const SmsCodeView(),
)));
}
}
LoginViewModel
의 상태를 확인하여 해당하는 화면으로 전환한다.SmsCodeView
로 이동 시 인증 진행 상태를 공유해야 하므로, 사용중인 AuthService
인스턴스를 SmsCodeViewModel
에 주입한다.loginViewModel.state == LoginViewState.loading
? const CircularProgressIndicator()
: TextField(
onChanged: (value) => loginViewModel.phoneNumber = value,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10)),
hintText: phoneNumberString),
)
LoginViewModel
의 상태를 감지하여 loading
상태일 경우 텍스트 입력 필드 대신 로딩을 나타내는 위젯을 생성한다.
if (loginViewModel.state == LoginViewState.error)
Text(
errorMessage,
style: const TextStyle(color: Colors.red),
)
LoginViewModel
의 상태를 감지하여 error
상태일 경우 에러 메시지가 보이도록 한다.
SizedBox(
width: double.maxFinite,
child: FilledButton(
onPressed: () => loginViewModel.phoneNumberLogin(),
child: Text(loginString)))
로그인 버튼 클릭 시 phoneNumberLogin()
을 실행한다.
import 'package:flutter/material.dart';
import 'package:flutter_velog/viewmodels/sms_code_view_model.dart';
import 'package:flutter_velog/views/home_view.dart';
import 'package:provider/provider.dart';
class SmsCodeView extends StatelessWidget {
const SmsCodeView({super.key});
final String verificationCodeString = "인증 번호";
final String verificationString = "인증하기";
final String errorMessage = "인증번호를 확인해주세요";
Widget build(BuildContext context) {
final smsCodeViewModel = Provider.of<SmsCodeViewModel>(context);
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (smsCodeViewModel.state == SmsCodeViewState.verified) {
Navigator.of(context)
.push(MaterialPageRoute(builder: (context) => const HomeView()));
}
});
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
smsCodeViewModel.state == SmsCodeViewState.loading
? const CircularProgressIndicator()
: TextField(
onChanged: (value) => smsCodeViewModel.smsCode = value,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10)),
hintText: verificationCodeString),
),
const SizedBox(height: 10),
if (smsCodeViewModel.state == SmsCodeViewState.error)
Text(
errorMessage,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 10),
SizedBox(
width: double.maxFinite,
child: FilledButton(
onPressed: () => smsCodeViewModel.verifySMSCode(),
child: Text(verificationString)))
],
),
);
}
}
LoginView
와 동일한 형식
![]() | ![]() |
---|