본문 바로가기

Flutter/12 Clone 'Used Goods app'

[Flutter] Clone - 당근마켓18(Phone Auth)

이번에는 전화인증에 대해서 구현해보겠습니다.

개발환경 : 윈도우11, 안드로이드 스튜디오, flutter 3.0.1

 

 

화면 구성 및 흐름은 아래와 같습니다.

 

 

 

전화 인증을 사용하기 위해서는 Firebase 사이트에서 몇가지 설정을 해야 한다.

 

 

 

전화 인증 선택

 

 

 

테스트용 전화번호를 2개 정보 추가한다

 

 

 

추가된 테스트용 전화번호 및 인증 코드

 

 

 

전화 인증이 완료되면 상태는 Enabled 입니다.

 

 

 

추가된 패키지는 

 

firebase_auth: ^3.6.0

 

 

android/app/build.gradle - dependencies 추가

 

    // Declare the dependency for the Firebase Authentication library
    // When using the BoM, you don't specify versions in Firebase library dependencies
    implementation 'com.google.firebase:firebase-auth'

 

 

./src/states/user_state.dart - Getx 관련 부분 수정

 

class UserController extends GetxController {
  static UserController get to => Get.find();

// firebase_auth 내부 클래스인 User 사용
  final _user = Rxn<User?>();

// setter 설정
  Rxn<User?> get user => _user;

// 스트림으로 처리하여 사용자의 상태가 변경되면 자동으로 호출되어
// 사용자 정보를 갱신한다
// 미들웨어에서 user 정보를 기준으로 '/auth' 또는 '/' 으로 분기처리됨
  void initUser() {
    FirebaseAuth.instance.authStateChanges().listen((user) {
      _user.value = user;
      logger.d('(initUser)user status - $user');
      Get.offAllNamed('/');
    });
  }

// 인스턴스가 생성된 이후 onReady() 호출됨
  @override
  void onReady() {
    // TODO: implement onReady
    super.onReady();
    initUser();
    logger.d('(onReady)user status - $user');
  }
}

 

 

./src/middleware/check_auth.dart - UserController.to.user 상태에 따라 인증화면 또는 bypass 한다(홈화면으로 이동).

 

@override
RouteSettings? redirect(String? route) {
  if(UserController.to.user.value != null) {
    isAuthenticated = true;
  } else {
    isAuthenticated = false;
  }

  if (isAuthenticated == false) {
    return const RouteSettings(name: '/auth');
  }
  return null;
}

 

 

./src/screens/start/auth_page.dart

 

import 'package:extended_image/extended_image.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_multi_formatter/flutter_multi_formatter.dart';
import 'package:shared_preferences/shared_preferences.dart';

import '../../constants/common_size.dart';
import '../../constants/shared_pref_key.dart';
import '../../states/user_state.dart';
import '../../utils/logger.dart';

class AuthPage extends StatefulWidget {
  const AuthPage({Key? key}) : super(key: key);

  @override
  State<AuthPage> createState() => _AuthPageState();
}

const _duration_300 = Duration(microseconds: 300);
const _duration_1000 = Duration(seconds: 1);

class _AuthPageState extends State<AuthPage> {
  final inputBorder = const OutlineInputBorder(
    borderSide: BorderSide(color: Colors.grey),
  );

  // 초기값을 010 으로 시작하게 설정
  final TextEditingController _phoneNumberController = TextEditingController(text: "010 5555 5555");

  final TextEditingController _codeController = TextEditingController();

  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();

  // 인증단계 flag
  VerificationStatus _verificationStatus = VerificationStatus.none;

  String? _verificationId;
  int? _forceResendingToken;

  @override
  Widget build(BuildContext context) {
    debugPrint(">>> build from AuthPage");

    return LayoutBuilder(
      builder: (context, constraints) {
        Size size = MediaQuery.of(context).size;

        // 인증 단계에서는 모든 클릭은 무시하게 처리
        return IgnorePointer(
          // 인증 단계에서는 화면의 모든 터치를 무시한다
          ignoring: _verificationStatus == VerificationStatus.verifying,
          child: Form(
            key: _formKey,
            child: Scaffold(
              appBar: AppBar(
                title: Text(
                  '전화번호 로그인',
                  style: Theme.of(context).appBarTheme.titleTextStyle,
                ),
              ),
              body: Padding(
                padding: const EdgeInsets.all(padding_16),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: <Widget>[
                    Row(
                      crossAxisAlignment: CrossAxisAlignment.center,
                      children: <Widget>[
                        ExtendedImage.asset(
                          'assets/imgs/padlock.png',
                          width: size.width * 0.15,
                          height: size.width * 0.15,
                        ),
                        const SizedBox(
                          width: padding_08,
                        ),
                        const Text('사과마켓은 휴대폰 번호로 가입해요 \n번호는 안전하게 보관되며 \n어디에도 공개되지 않아요.'),
                      ],
                    ),
                    const SizedBox(
                      height: padding_16,
                    ),
                    TextFormField(
                      controller: _phoneNumberController,
                      // 숫자자판이 나오게 설정
                      keyboardType: TextInputType.phone,
                      // 입력된 값의 형식을 지정, 지정된 형식 이상으로 입력되지 않음
                      inputFormatters: [MaskedInputFormatter('000 0000 0000')],
                      decoration: InputDecoration(
                        hintText: '전화번호 입력',
                        hintStyle: TextStyle(color: Theme.of(context).hintColor),
                        focusedBorder: inputBorder,
                        border: inputBorder,
                      ),
                      validator: (phoneNumber) {
                        // 전화번호 형식 검증
                        if (phoneNumber != null && phoneNumber.length == 13) {
                          return null;
                        } else {
                          return '전화번호 입력 오류입니다';
                        }
                      },
                    ),
                    const SizedBox(
                      height: padding_16,
                    ),
                    TextButton(
                        onPressed: () async {
                          debugPrint('_verificationStatus: $_verificationStatus');
                          _getAddress();
                          FocusScope.of(context).unfocus();
                          if (_verificationStatus == VerificationStatus.codeSending) {
                            return;
                          }
                          if (_formKey.currentState != null) {
                            bool passed = _formKey.currentState!.validate();
                            if (passed) {
                              var phoneNum = _phoneNumberController.text;
                              phoneNum = phoneNum.replaceAll(' ', '');
                              phoneNum = phoneNum.replaceFirst('0', '');
                              phoneNum = '+82$phoneNum';

                              setState(() {
                                // 인증단계 코드 전송중~~
                                _verificationStatus = VerificationStatus.codeSending;
                              });
// 중요 메인 로직 1/2
                              FirebaseAuth auth = FirebaseAuth.instance;
                              await auth.verifyPhoneNumber(
                                phoneNumber: phoneNum,
                                forceResendingToken: _forceResendingToken,
                                verificationCompleted: (PhoneAuthCredential credential) async {
                                  // ANDROID ONLY!
                                  // login 이 정상적으로 완료되었는지 확인 코드 추가
                                  logger.d('전화번호 인증 완료 [$credential]');
                                  // 로그인이 정상완료되면 user 정보가 변경되어 스트림이 자동 호출됨
                                  await auth.signInWithCredential(credential);
                                },
                                // 기본값이 30초인데, time out 에러가 발생하여 지정해줌
                                timeout: const Duration(seconds: 30),
                                codeAutoRetrievalTimeout: (String verificationId) {
                                  // 현재는 아무것도 안한다.
                                },
                                codeSent: (String verificationId, int? forceResendingToken) async {
                                  setState(() {
                                    // 인증단계 전송완료~~, 전송이 완료되면 여기로 이동,
                                    _verificationStatus = VerificationStatus.codeSent;
                                  });
                                  // verificationId 값이 널이면 오류
                                  _verificationId = verificationId;
                                  // forceResendingToken 값은 첫번째 전송에서는 널, 그 이후는 값있음
                                  _forceResendingToken = forceResendingToken;
                                },
                                verificationFailed: (FirebaseAuthException error) {
                                  logger.d(error.message);
                                  setState(() {
                                    _verificationStatus = VerificationStatus.none;
                                  });
                                },
                              );
// 중요 메인 로직 1/2
                            } else {
                              setState(() {
                                _verificationStatus = VerificationStatus.none;
                              });
                            }
                          }
                          debugPrint('_verificationStatus: $_verificationStatus');
                        },
                        // 검증상태에 따라서 버튼의 문자열을 변경하여 동작중임을 표시함
                        child: _verificationStatus == VerificationStatus.codeSending
                            ? const SizedBox(
                                height: 26,
                                width: 26,
                                child: CircularProgressIndicator(color: Colors.white))
                            : const Text('인증문자 발송')),
                    const SizedBox(height: padding_16 * 2),
                    AnimatedOpacity(
                      // 투명도를 설정하는 위젯
                      duration: _duration_300,
                      opacity: (_verificationStatus == VerificationStatus.none) ? 0.0 : 1.0,
                      child: AnimatedContainer(
                        // StatelessWidget 은 적용할수 없음, StatefulWidget 만 적용가능
                        duration: _duration_1000,
                        // 에니메이션이 조금 부자연스러워서 추가함
                        curve: Curves.easeInOut,
                        // 인증단계에 따라서 화면에 표시 여부를 정할수 있음
                        height: getVerificationHeight(_verificationStatus),
                        child: TextFormField(
                          controller: _codeController,
                          keyboardType: TextInputType.phone,
                          // 입력된 값의 형식을 지정, 지정된 형식 이상으로 입력되지 않음
                          inputFormatters: [MaskedInputFormatter('000000')],
                          decoration: InputDecoration(
                            hintText: '인증문자 입력',
                            hintStyle: TextStyle(color: Theme.of(context).hintColor),
                            focusedBorder: inputBorder,
                            border: inputBorder,
                          ),
                        ),
                      ),
                    ),
                    const SizedBox(height: padding_16),
                    AnimatedContainer(
                      duration: _duration_1000,
                      // 인증단계에 따라서 화면에 표시 여부를 정할수 있음
                      height: getVerificationBtnHeight(_verificationStatus),
                      child: TextButton(
                          onPressed: () {
                            debugPrint('_verificationStatus(onPressed): $_verificationStatus');
                            FocusScope.of(context).unfocus();
                            // 인증 진행중
// 중요 메인 로직 2/2
                            attemptVarify(context);
// 중요 메인 로직 2/2
                            debugPrint('_verificationStatus(onPressed): $_verificationStatus');
                          },
                          // 검증상태에 따라서 버튼의 문자열을 변경하여 동작중임을 표시함
                          child: _verificationStatus == VerificationStatus.verifying
                              ? const SizedBox(
                                  height: 26,
                                  width: 26,
                                  child: CircularProgressIndicator(color: Colors.white),
                                )
                              : const Text('인증')),
                    ),
                  ],
                ),
              ),
            ),
          ),
        );
      },
    );
  }

  double? getVerificationHeight(VerificationStatus status) {
    switch (status) {
      case VerificationStatus.none:
        return 0.0;
      case VerificationStatus.codeSending:
      case VerificationStatus.codeSent:
      case VerificationStatus.verifying:
      case VerificationStatus.verificationDone:
        return 60.0;
    }
  }

  double? getVerificationBtnHeight(VerificationStatus status) {
    switch (status) {
      case VerificationStatus.none:
        return 0.0;
      case VerificationStatus.codeSending:
      case VerificationStatus.codeSent:
      case VerificationStatus.verifying:
      case VerificationStatus.verificationDone:
        return 48.0;
    }
  }

// 중요 메인 로직 2/2
  void attemptVarify(BuildContext context) async {
    setState(() {
      // 인증 진행중
      _verificationStatus = VerificationStatus.verifying;
    });
    debugPrint('_verificationStatus(attemptVarify): $_verificationStatus');

    try {
    // 인증 핵심 부분
      PhoneAuthCredential credential = PhoneAuthProvider.credential(
          verificationId: _verificationId!, smsCode: _codeController.text);
      // Sign the user in (or link) with the credential
      // 로그인이 정상완료되면 user 정보가 변경되어 스트림이 자동 호출됨
      await FirebaseAuth.instance.signInWithCredential(credential);
    } catch (e) {
      logger.e('verification failed !!!');
      SnackBar snackBar = const SnackBar(content: Text('입력하신 코드 오류입니다'));
      ScaffoldMessenger.of(context).showSnackBar(snackBar);
    }

    setState(() {
      // 인증 완료
      _verificationStatus = VerificationStatus.verificationDone;
    });

    debugPrint('_verificationStatus(attemptVarify): $_verificationStatus');
  }

  _getAddress() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    String address = prefs.getString(SHARED_ADDRESS) ?? '';
    double lat = prefs.getDouble(SHARED_LAT) ?? 0.0;
    double lon = prefs.getDouble(SHARED_LON) ?? 0.0;
    debugPrint('get Address: [$address] [$lat] [$lon]');
  }
}

enum VerificationStatus { none, codeSending, codeSent, verifying, verificationDone }

 

 

 

./src/screens/home/home_screen.dart - 로그아웃 함수 변경

 

appBar: AppBar(
  // centerTitle: true,
  title: Text('밀라노', style: Theme.of(context).appBarTheme.titleTextStyle),
  actions: [
    IconButton(
      onPressed: () {
        // 로그아웃하면 '/auth' 로 이동
        // UserController.to.setUserAuth(false);
        // user 상태가 변하면서 스트림이 자동 호출되면서 로그아웃됨
        FirebaseAuth.instance.signOut();
      },
      icon: const Icon(Icons.logout),
    ),
    IconButton(
      onPressed: () {},
      icon: const Icon(CupertinoIcons.search),
    ),
    IconButton(
      onPressed: () {},
      icon: const Icon(CupertinoIcons.text_justify),
    ),
  ],
),