[Flutter] MVVM 패턴과 상태 관리 3 : MVVM 구현 (Provider)

민태호·2024년 12월 12일
0

Flutter

목록 보기
11/23
post-thumbnail

MVVM 패턴과 상태관리 라이브러리의 사용을 이해하는 데 초점을 맞춰 model과 repository는 생략하고, service도 역할만 가상으로 지정하여 간단하게 설계해보았다.
먼저 Provider 상태관리 라이브러리를 사용하였다.

auth_service.dart

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 : 사용자의 로그인 상태를 확인하는 역할


login_view_model.dart

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();
  }
}
  1. 로그인을 시도하면 상태를 loading으로 전환한다.
  2. AuthService내의 함수를 이용하여 인증 요청을 수행한다.
  3. 상태를 smsCodeSent로 전환한다.
  4. 에러/예외 발생 시 상태를 error로 전환한다.

sms_code_view_model.dart

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();
  }
}
  1. 인증을 시도하면 상태를 loading으로 전환한다.
  2. AuthService내의 함수를 이용하여 인증번호로 인증 요청을 수행한다.
  3. 성공 시 상태를 verified로 전환한다.
  4. 실패 시 상태를 error로 전환한다.
  5. 에러/예외 발생 시 상태를 error로 전환한다.

main.dart

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을 의존성 주입한다.

login_view.dart

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()을 실행한다.

sms_code_view.dart

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와 동일한 형식

실행 화면

자동로그인 true자동로그인 false
profile
Flutter Developer

0개의 댓글