Flutter

[Flutter] Instagram Clone Coding (인스타그램 클론코딩) 2 (등록화면)

모리선생 2023. 7. 27. 12:50
728x90

지난 포스팅을 작서 완료하고 주말간 극심한 위염에 걸려서 계속 앓아 누웠다. 이게 위염인지 아니면 식중독인지 잘은 모르는 상황이긴 하지만 금일 조금씩 무언가를 섭취하면서 소화를 할 수 있는거 보면 일단 위장기관이 확실히 문제였던 것은 맞는것 같다.

 

여러분들도 건강 항상 조심하고 너무 아프다 싶으면 빠르게 병원을 찾도록 하자. 몇일간 배앓이가 너무 심해서 정말 정신을 놓을 정도였다.

여튼 지난번까지의 내용을 보면 일단 준비를 하고, 그 다음에 pubspec.yaml에 들어갈 내용 그리고 추가적인 Asset등을 등록 해두었다. 만약 이번 포스팅을 보는 것이 처음이라면 이전 포스팅을 한번 보고 오기를 바란다.

https://riris01.tistory.com/75

 

[Flutter] Instagram Clone Coding (인스타그램 클론코딩) 1

지난번에 포스팅 했던 바와 같이 인스타그램 클론 코딩을 GetX로 시도를 해보려고 하다가 여러가지 단점들을 발견하여 중간에 중단했던적이 있었다. 물론 본인의 실력이 낮았기에 이러한 결론이

riris01.tistory.com

 

자 그럼 오늘할 작업은 Sign up 화면을 만드는 것이다. 일단 모든 화면의 경우에는 사용자 정보를 등록하고 사용하는 것이 요즘 앱에는 자주 보일 것이다.

 

이 사용자를 모집한다는 것은 여러가지 의미가 있지만 주로 이러한 의미에서 사용자의 등록을 유도하는 것으로 보인다.

  1. 사용자의 경향성 추적
  2. 문제 되는 사용자의 제재 혹은 제한
  3. 정보의 별도 관리 및 앱 사용상의 편의성 제공

익명성으로 작성을 해도 되지 않느냐라고 하지만 익명성을 추구하면서 사용한다면 아무래도 자신의 예전에 썼던 내용을 다시 보고 싶거나 아니면 누가 적었는지 모르는 글을 관리하기 힘들다던가의 문제가 생긴다. 또한 이게 글을 관리하기 힘들다면 사용자의 측면에서도 이 앱이 좋은 정보를 계속해서 전달하고 있는건지 의구심이 생길 수 있다.

 

이러한 측면에서 정보를 등록하고 "관리"할 수 있는 상황에서 앱을 사용하도록 유도하는게 주요 목적이라고 생각을 한다.

 

자, 그렇다면 일단 Sign Up 페이지를 어떻게 구성을 했는지 전체 구문을 먼저 확인을 해보자.

 

자 일단 lib/screens/singup_screen.dart라는 파일을 만들고 다음의 구문을 복사해서 넣자. 고대로 따라 쳐봐도 된다.

import 'dart:typed_data';

import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:instagram_flutter/resources/auth_methods.dart';
import 'package:instagram_flutter/responsive/mobile_screen_layout.dart';
import 'package:instagram_flutter/responsive/responsive_layout_screen.dart';
import 'package:instagram_flutter/responsive/web_screen_layout.dart';
import 'package:instagram_flutter/screens/login_screen.dart';

import '../utils/colors.dart';
import '../utils/utils.dart';
import '../widgets/text_field_input.dart';

class SignupScreen extends StatefulWidget {
  const SignupScreen({super.key});

  @override
  State<SignupScreen> createState() => _SignupScreenState();
}

class _SignupScreenState extends State<SignupScreen> {
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  final TextEditingController _bioController = TextEditingController();
  final TextEditingController _usernameController = TextEditingController();
  Uint8List? _image;
  bool _isLoading = false;

  @override
  void dispose() {
    super.dispose();
    _emailController.dispose();
    _passwordController.dispose();
    _bioController.dispose();
    _usernameController.dispose();
  }

  void selectImage() async {
    Uint8List im = await pickImage(ImageSource.gallery);
    setState(() {
      _image = im;
    });
  }

  void signUpUser() async {
    setState(() {
      _isLoading = true;
    });
    String res = await AuthMethods().signUpUser(
      email: _emailController.text,
      password: _passwordController.text,
      username: _usernameController.text,
      bio: _bioController.text,
      file: _image!,
    );
    setState(() {
      _isLoading = false;
    });
    if (res != 'success') {
      showSnackBar(res, context);
    } else {
      Navigator.of(context).pushReplacement(
        MaterialPageRoute(
          builder: (context) => const ResponsiveLayout(
            mobileScreenLayout: MobileScreenLayout(),
            webScreenLayout: WebScreenLayout(),
          ),
        ),
      );
    }
  }

  void navigateToLogin() {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => const LoginScreen(),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Container(
          padding: EdgeInsets.symmetric(horizontal: 32),
          width: double.infinity,
          child:
              Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
            Flexible(child: Container(), flex: 2),
            //svg image
            SvgPicture.asset(
              'assets/ic_instagram.svg',
              color: primaryColor,
              height: 64,
            ),
            const SizedBox(height: 64),
            // circular widget to accept and show our selected file
            Stack(
              children: [
                _image != null
                    ? CircleAvatar(
                        radius: 64,
                        backgroundImage: MemoryImage(_image!),
                      )
                    : const CircleAvatar(
                        radius: 64,
                        backgroundImage: NetworkImage(
                            'https://thumbs.dreamstime.com/b/default-avatar-profile-icon-vector-social-media-user-photo-183042379.jpg'),
                      ),
                Positioned(
                  bottom: -10,
                  left: 80,
                  child: IconButton(
                    onPressed: selectImage,
                    icon: const Icon(
                      Icons.add_a_photo,
                    ),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 64),
            //Text field input for username
            TextFieldInput(
              textEditingController: _usernameController,
              hintText: 'Enter your name',
              textInputType: TextInputType.text,
            ),
            const SizedBox(height: 24),
            //text field input for email
            TextFieldInput(
              textEditingController: _emailController,
              hintText: 'Enter your email',
              textInputType: TextInputType.emailAddress,
            ),
            const SizedBox(height: 24),
            //text field input for password
            TextFieldInput(
              textEditingController: _passwordController,
              hintText: 'Enter your password',
              textInputType: TextInputType.text,
              isPass: true,
            ),
            const SizedBox(height: 24),
            // text field input for bioController
            TextFieldInput(
              textEditingController: _bioController,
              hintText: 'Enter your bio',
              textInputType: TextInputType.text,
            ),
            const SizedBox(height: 24),
            //button login
            InkWell(
              onTap: signUpUser,
              child: Container(
                child: _isLoading
                    ? const Center(
                        child: CircularProgressIndicator(
                          color: primaryColor,
                        ),
                      )
                    : const Text('Sign up'),
                width: double.infinity,
                alignment: Alignment.center,
                padding: const EdgeInsets.symmetric(vertical: 12),
                decoration: const ShapeDecoration(
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.all(
                      Radius.circular(4),
                    ),
                  ),
                  color: blueColor,
                ),
              ),
            ),
            const SizedBox(
              height: 12,
            ),
            Flexible(child: Container(), flex: 2),

            //Transitioning to signing up
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Container(
                  child: Text("Don't have an account?"),
                  padding: EdgeInsets.symmetric(
                    vertical: 8,
                  ),
                ),
                GestureDetector(
                  onTap: navigateToLogin,
                  child: Container(
                    child: Text(
                      "Login.",
                      style: TextStyle(
                        fontWeight: FontWeight.bold,
                      ),
                    ),
                    padding: EdgeInsets.symmetric(
                      vertical: 8,
                    ),
                  ),
                ),
              ],
            )
          ]),
        ),
      ),
    );
  }
}

딱 처음 보는 순간 모든게 이해가 되는 사람이라면, 이 글을 더 이상 읽지 않아도 된다.

 

뭔가 본적이 있는데? 라는 생각이 드는 사람의 경우에는 그렇다. 여러분은 fire_Auth 혹은 firebase를 한번 정도는 사용해본 분들일 것이다. 예습 그리고 복습을 참 열심히 하는 플린이 분들이다.

 

전혀 모르겠다? 그렇다면 한번도 로그인 부분을 완성 시켜보시지 않은 분들이다. 이런 분들은 이제 구석구석 뜯어보면서 어떤식으로 화면이 구성되어 있는지 함께 확인을 해보면 된다.

 

코드 리뷰

자 일단 우리는 StatefulWidget을 사용해줄 것이다. 해당 화면은 '동적'인 화면이다. 정보를 입력받고 해당 정보를 서버로 전달을 하며 서버가 잘 입력이 되었다는 것을 알려줘야한다. 즉, '움직이는' 화면이다.

class SignupScreen extends StatefulWidget {
  const SignupScreen({super.key});
  
  (...)

 

그리고 각각의 입력을 하는데 있어서 어떤 Controller가 사용이 될지 정의를 내려줘야 한다. TextEditingController()를 모두 사용하지만, 해당 정보가 각각 어떤 변수명으로 정의가 되어서 서버에 전달을 될지는 할당이 되어야 되는 상황이다. 그래서 다음과 같이 각각의 TextEditingController()의 "이름-기능(컨트롤러)"의 할당을 하였다.

 

class _SignupScreenState extends State<SignupScreen> {
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  final TextEditingController _bioController = TextEditingController();
  final TextEditingController _usernameController = TextEditingController();
  
  (...)

자 그 밑에 보면 Uint8List라는게 있다. 이건 "8비트의 부호 없는 정수인 고정된 길이의 리스트"라고 정의는 되어 있는데, 이건 이해하기 힘드니까, 이미지를 불러오기 위한 하나의 "포장"하는 방법이라고 이해를 하면 편하다.  그리고 _isLoading을 적어 놓았는데, 이는 CircularProgressiveBar의 초기 상태를 false라고 두어 이 후 등록을 진행할때 true 상태로 변화를 하여, 작동할 수 있도록 하기 위한 초기값이다.

 

그 다음 부분은 SignUpUser에 대해서 어떠한 행동을 취할 것인가에 대한 부분이라고 생각을 하면된다. 자, signUpUser() 함수를 만든 다음에 async 처리를 해주었다. 그 이유는 알다싶이 비동기화식 행동이 앱에게 필요하기 때문이다. 이 말인 즉슨, 필요한 정보를 넣고 엔터를 누를 때까지 대기 상태에 있다가 엔터를 누르는 순간 '원하는 행동'이 이루어져야한다. 이는 다른 말로 말하자면 지금 비동기화 (=async)하는 부분이 future에 어떤 식으로 영향을 미칠지 알고 그 행동을 대기하는 형태라고 보면 된다. 음 이해하기 어렵다면, 당장 일어나지 않은 일에 대해서는 비동기화 방식이 사용된다라고만 이해를 하자. 그렇게 하는게 심신에 편하다.

릴렉스~ 코딩은 길게 보고 꾸준히 공부해야하니 포기말자.

여튼, 자 이렇게 컨셉을 잡고 다음의 내용을 보자면 이렇다.

  1. setState의 경우에는 isLoading = true로 변경한다. 이 경우는 signup이 진행될때를 의미한다.
  2. String res = await AuthMethods().signUpUser 이하 구문은 signUpUser  메서드를 호출한다. 이를 통해 이메일, 비밀번호, 유저 이름, 바이오, 그리고 이미지 등을 받아 새로운 사용자를 등록하는데 정보를 사용한다. 그리고 이건 등록된 결과를 문자열 혀태로 반환을 한다. 
  3. setState()는 정보가 입력되었을 시에는 isLoading을 false로 변경을 한다. 
  4. 근데 만약 _isLoading이 true가 아니라면 오류 메시지를 보여준다.
  5. 하지만 res != 'success'라면 Navigator.of(context).pushReplacement(...)를 불러서 다른 페이지로의 이동을 진행한다. ResponsiveLayout 위젯을 만들어서 위젯에 따라 반응형 레이아웃을 보여주는 역할을 할 것이다. 이것 때문에 우리가 챕터 1에서 ScreenLayout()기준을 설정하였다.
void signUpUser() async {
    setState(() {
      _isLoading = true;
    });
    String res = await AuthMethods().signUpUser(
      email: _emailController.text,
      password: _passwordController.text,
      username: _usernameController.text,
      bio: _bioController.text,
      file: _image!,
    );
    setState(() {
      _isLoading = false;
    });
    if (res != 'success') {
      showSnackBar(res, context);
    } else {
      Navigator.of(context).pushReplacement(
        MaterialPageRoute(
          builder: (context) => const ResponsiveLayout(
            mobileScreenLayout: MobileScreenLayout(),
            webScreenLayout: WebScreenLayout(),
          ),
        ),
      );
    }
  }

자 근데 만약 우리가 SignUp을 하려고 했다가 문득 아이디가 원래 존재했다는 사실을 알게 된다면???? 이렇게 별도의 Sign in 기능을 밑에 넣어주면 된다. (물론 예시로 적은 것에는 Sign Up으로 되어있지만, 이런 느낌으로 앱 제일 하단에 조그맣게 넣어주면 된다는 것이다.)

자 그러기 위해서 우리가 넣어야할 것은, Login 기능을 불러올 수 있는 메서드 일 것이다.

void navigateToLogin() {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => const LoginScreen(),
      ),
    );
  }

그래서 이렇게 void 선언을 하고 navigateToLogin()을 넣어 주었다. 이 메서드의 능력은 LoginScreen()으로 이동을 시킬 수 있다는 것이다.

 

자 여기까지가 거의 반절을 적은 것이다. 이런식으로 우리는 .dart 파일을 58,000개를 만들어야한다.

이쉑....

아니다.. 정확히는... 20여개 정도다. 여튼 많기는 많다 근데 해야한다. 왜? 우리는 앱을 만들기로 했으니까.

 

자 나머지 부분을 한번 만들어보자. 이제 부터는 신나는 UI 부분이다. 일단 Scaffold(...)를 만들어 준다 그리고 SafeArea()를 적용을 하여 그 안에 Container를 위치시킨다. 자 여기서 SafeArea()를 사용하는 이유는 각 휴대폰 혹은 플랫폼마다 마진이라던지 공간등이 있다. 근데 이걸 매번 플랫폼 마다 다르게 마진을 계산하여서 Padding을 적용할 수는 없다 (세상에 휴대폰이 얼마나 많은데...). 그런 의미에서 UI가 화면 내부에 잘 적용될 수 있도록 SafeArea()라고 하는 위젯이 존재하는 것이다. 아 근데 이 부분을 설명안했다. 우리가 만드려고 하는 화면은 어떻게 구성이 되어 있는가?

이렇다. 순서별로 보면 

- 로고

- 선택한 사진 (프로필용 사진)

- 자신의 정보

- Sign up 버튼

- Login으로가는 버튼

 

지금 우리가 작업하려는 부분은 저 선택한 사진을 넣는 부분이다. 그렇다면 당연히 여기서는 들어가야할 것이 CircularAvatar()위젯을 사용하여 저렇게 동그란 사진이 들어가도록 하는 것이다. 그리고 여기에서는 삼항연산자를 사용하여서 사진을 찍었을때 선택되는 사진이 아니라면 기본 사진을 세팅 해줄 수 있도록 하여야 한다. 

 

padding이라던가 위치를 잡는 부분등에 대해서는 스스로 숫자들을 변경해보면서 한번 확인 해보기를 바란다. 그러면서 위치가 어떻게 변화되는지 보는 것이 좋다. 

 

@override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Container(
          padding: EdgeInsets.symmetric(horizontal: 32),
          width: double.infinity,
          child:
              Column(crossAxisAlignment: CrossAxisAlignment.center, children: [
            Flexible(child: Container(), flex: 2),
            //svg image
            SvgPicture.asset(
              'assets/ic_instagram.svg',
              color: primaryColor,
              height: 64,
            ),
            const SizedBox(height: 64),
            // circular widget to accept and show our selected file
            Stack(
              children: [
                _image != null
                    ? CircleAvatar(
                        radius: 64,
                        backgroundImage: MemoryImage(_image!),
                      )
                    : const CircleAvatar(
                        radius: 64,
                        backgroundImage: NetworkImage(
                            'https://thumbs.dreamstime.com/b/default-avatar-profile-icon-vector-social-media-user-photo-183042379.jpg'),
                      ),
                Positioned(
                  bottom: -10,
                  left: 80,
                  child: IconButton(
                    onPressed: selectImage,
                    icon: const Icon(
                      Icons.add_a_photo,
                    ),
                  ),
                ),
              ],
            ),

여기서 가장 중요한 부분이라고 한다면 아무래도 미리 말한 삼항연산자 부분일 것이다. 여기서는 CircleAvatar위젯 내에서 image가 존재하지 않을때는 일단 NetworkImage() 즉, 기본 이미지를 불러오도록 하였고 그 외의 상황 즉, 사진을 선택한 상황에 대해서는 해당 사진이 display 될 수 있도록 조건을 잡았다. 이렇게 함으로써 프로필로 사용할 사진을 선택할 수 있도록 만들었다.

children: [
                _image != null
                    ? CircleAvatar(
                        radius: 64,
                        backgroundImage: MemoryImage(_image!),
                      )
                    : const CircleAvatar(
                        radius: 64,
                        backgroundImage: NetworkImage(
                            'https://thumbs.dreamstime.com/b/default-avatar-profile-icon-vector-social-media-user-photo-183042379.jpg'),
                      ),

자 그럼 저기 아바타 밑의 카메라 모양은 무엇이냐? 그건 바로 사진을 선택할 수 있는 버튼이다. 그 버튼의 기능은???

 void selectImage() async {
    Uint8List im = await pickImage(ImageSource.gallery);
    setState(() {
      _image = im;
    });
  }

이렇게 void selectImage()를 통해 메서드화 해두었다. 이 말인 즉슨 ImageSource.gallery로 부터 pickImage를 사용하여서 사진을 선택할 수 있도록 하는 것이다. pickImage는 image_picker라고 하는 라이브러리의 한 기능으로써 우리가

- 사진을 찍거나

- 사진을 불러올 수 있는 

 

기능을 구현한 라이브러리라고 생각하면 된다. 궁금하다면 (https://pub.dev/packages/image_picker) 여기에 들어가서 한번 내용을 읽어보자.

 

자 그 다음은 박스와 버튼의 나열이다. 사실 이게 복붙의 느낌인지라 하나만 설명하면 될 듯하다.

 

유저 정보를 입력할때 다양한 부분들이 있다고 했다. 유저 이름, 이메일, 비밀번호, 바이오 등등 그렇다면 이런 유저 이름을 입력할 수 있는 칸은? SizedBox()위젯을 사용하여서 만들면 된다. 그럼, 거기에 글을 넣으려면? 우리가 제일 위에서 글을 넣는 기능은 무엇을 사용한다고 했었나? TextEditingController를 썼다. 그걸 쓰면 된다. 그럼 글을 쓴다고 해서 이것이 서버에 자동으로 입력될까? 그렇지 않다. TextInputType.text를 통해서 '글을 작성하였고 이것이 text 형식'입니다 라고 알려주면 이제 서버에 전달할 준비가 된것이다. 그것이 다음과 같은 형태이다.

const SizedBox(height: 64),
            //Text field input for username
            TextFieldInput(
              textEditingController: _usernameController,
              hintText: 'Enter your name',
              textInputType: TextInputType.text,
            ),

이와 동일한 형태의 내용을 email, password, 그리고 bio에 해주면 된다 다만! 코드를 읽다보면 비밀번호 부분에서 isPass: true라고 되어 있을 것이다. 이는 사용자가 입력한 비밀번호를 텍스트로 보여주지 않고 마스킹 처리하여 보여주는데 사용되는 것이다. 예를 들어 "*****"와 같다고 생각하면 된다.

 

자, 이제 버튼을 만들어보자. 기껏 정보를 다 입력했는데, 아무런 반응도 없다면 사용자 입장에서는 황당하다. 그래서 뭘? 이라면서 벙찔 수 있다. 자 우리가 만들 버튼은 동일하게 사각형이지만 기능을 넣을 것인데, _isLoading인 상태에서는 CircularProgressIndicator() 위젯을 사용함으로써 정보가 처리되고 있다는 것을 사용자에게 보여준다. 만약 그게 아니라면 Sign up이라는 텍스트를 보여줌으로써 사용자에게 등록이 되어있다는 것을 보여준다. 그리고 클릭을 진행할시에 잉크가 퍼지는 느낌을 줌으로써 버튼이 눌렸다는 것을 보여준다.

InkWell(
              onTap: signUpUser,
              child: Container(
                child: _isLoading
                    ? const Center(
                        child: CircularProgressIndicator(
                          color: primaryColor,
                        ),
                      )
                    : const Text('Sign up'),

그럼 누르게 되었을때, 이게 정보가 어떻게 처리되어야 하는데?라고 묻는다면, 우리가 위에서 작업했던 signUpUser()메서드가 드디어 작동하게 된다고 생각하면 된다. 이 기능을 통해 firebase 서버에 유저가 입력한 정보가 저장이 된다.

 

원래 오늘 Sign Up과 Sign In까지 모두 진행하려고 했으나, 그렇게 하면 글이 거의 2시간은 읽어야 할 듯하여, 여기서 펜을 놓는다. 다만, 다음부터는 필요 부분에 대해서는 상세하게 설명하고 그 외 부분에 대해서는 위젯에 대한 링크를 달아 놓는 식으로 진행하고자 한다. 스스로 공부하고 찾아보는 습관도 필요하니까.

 

(귀찮은거 절대아니다. 그냥 긴글을 읽는 걸 잘 못하는 성격이다 보니, 그걸 이 글에도 적용하려는 거다.)

 

여튼, 다음에는 Sign In과 더불어 기본 footbar가 있는 화면까지 한번 만들어보자.

 

그럼 바이

 

728x90