Dandy Now!
  • [Flutter] "Do it! 플러터 앱 프로그래밍" - 오픈 API를 활용한 여행 정보 앱 프로젝트 | 파이어베이스 설정, 로인인/회원가입 기능
    2022년 02월 26일 23시 25분 12초에 업로드 된 글입니다.
    작성자: DandyNow
    728x90
    반응형

    "조준수. (2021). Do it! 플러터 앱 프로그래밍. 이지스퍼블리싱", 14장 오픈 API를 활용한 여행 정보 앱 만들기를 실습했다. 파이어베이스 설정부터 로인인/회원가입 기능까지 구현하였다. 이 과정에서 package name 불일치, multidex 문제가 있어 해결하였다.

     

    Do it! 플러터 앱 프로그래밍

    플러터 기본 & 고급 위젯은 물론오픈 API와 파이어베이스를 이용한 앱 개발부터 배포까지!플러터 SDK 2.x 버전을 반영한 개정판!이 책은 플러터의 기초부터 고급 활용법까지 다루어 다양한 영역에

    book.naver.com

     

    여행 정보 앱 프로젝트 시작

    프로젝트 만들기

    패키지 이름은 구글 플레이에서 고유하다. 따라서 기존에 등록된 패키지명과 중복되면 앱을 등록할 수 없다. 한 번 정하면 앱 출시 후 변경할 수 없다.

    com.회사명(또는 닉네임).프로젝트명

     

    파이어베이스 설정하기

    1. 파이어베이스 콘솔에서 새 프로젝트 만들기
    2. 새 프로젝트에 안드로이드 앱 추가하기
      • 플러터 프로젝트 생성 시 지정했던 패키지 이름 사용
      • google-services.json 내려받아서 안드로이드 프로젝트의 app 폴더에 넣기
    3. 안드로이드 프로젝트에서 android/build.gradle에 클래스 경로 추가하기
    4. 안드로이드 프로젝트에서 android/app/build.gradle에 구글 서비스 플러그인 추가하기
    5. 실시간 데이터베이스 추가하기
      • 읽기와 쓰기 규칙 true로 수정하기
      • 데이터베이스 URL 복사해 놓기
    6. 애드몹에 안드로이드 앱 추가하기
      • 앱 ID 복사해 놓기
      • 안드로이드 프로젝트에서 AndroidManifest.xml 파일에 메타 데이터 추가하기(앱 ID 이용)

     

    인트로와 로그인 화면 만들기

    패키지 버전을 생략하면 최신 버전으로 내려받는다. 하지만 API가 변경되었을 경우 대응하기 어렵고 안정화되지 않아서 버그가 있을 수 있다. 따라서 검증된 버전을 명시해서 사용하는 것이 좋다.

    # pubspec.yaml
    dependencies:
      flutter:
        sdk: flutter
      cupertino_icons: ^1.0.2
      # 이하 추가
      firebase_core: ^1.3.0
      firebase_analytics: ^8.1.2
      firebase_database: ^7.1.1
      firebase_messaging: ^10.0.1
      google_mobile_ads: ^0.13.0
      http: ^0.13.1
      sqflite: ^2.0.0+3
      path: ^1.8.0
      shared_preferences: ^2.0.5
      crypto: ^3.0.1
      google_maps_flutter: ^2.0.3

     

    // main.dart
    import 'package:flutter/material.dart';
    import 'signPage.dart';
    import 'login.dart';
    import 'package:google_mobile_ads/google_mobile_ads.dart';
    
    void main() {
      WidgetsFlutterBinding.ensureInitialized();
      MobileAds.instance.initialize();
      runApp(MyApp());
    }
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: '부버의 여행',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          initialRoute: '/',
          routes: {
            '/': (context) => LoginPage(),
            '/sign': (context) => SignPage(),
          },
        );
      }
    }

     

    // login.dart
    import 'dart:async';
    import 'dart:convert';
    import 'dart:math';
    
    import 'package:crypto/crypto.dart';
    import 'package:firebase_database/firebase_database.dart';
    import 'package:flutter/material.dart';
    import 'data/user.dart';
    
    // 로그인 화면 만들기
    class LoginPage extends StatefulWidget {
      @override
      State<StatefulWidget> createState() => _LoginPage();
    }
    
    class _LoginPage extends State<LoginPage> with SingleTickerProviderStateMixin {
      FirebaseDatabase? _database;
      DatabaseReference? reference;
      String _databaseURL = '###파이어베이스 실시간 데이터베이스 URL###';
    
      double opacity = 0;
      AnimationController? _animationController;
      Animation? _animation;
      TextEditingController? _idTextController;
      TextEditingController? _pwTextController;
    
      @override
      void initState() {
        super.initState();
    
        _idTextController = TextEditingController();
        _pwTextController = TextEditingController();
    
        _animationController =
            AnimationController(duration: Duration(seconds: 3), vsync: this);
        _animation =
            Tween<double>(begin: 0, end: pi * 2).animate(_animationController!);
        _animationController!.repeat();
        Timer(Duration(seconds: 2), () {
          setState(() {
            opacity = 1; // 페이지 생성 후 2초 후 타이머 시작
          });
        });
    
        _database = FirebaseDatabase(databaseURL: _databaseURL);
        reference = _database?.reference().child('user');
      }
    
      @override
      void dispose() {
        _animationController?.dispose();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Container(
            child: Center(
              child: Column(
                children: <Widget>[
                  AnimatedBuilder(
                    animation: _animationController!,
                    builder: (context, widget) {
                      return Transform.rotate(
                        angle: _animation?.value,
                        child: widget,
                      );
                    },
                    child: Icon(
                      Icons.airplanemode_active,
                      color: Colors.deepOrangeAccent,
                      size: 80,
                    ),
                  ),
                  SizedBox(
                    height: 100,
                    child: Center(
                      child: Text(
                        '모두의 여행',
                        style: TextStyle(fontSize: 30),
                      ),
                    ),
                  ),
                  AnimatedOpacity(
                    opacity: opacity,
                    duration: Duration(seconds: 1),
                    child: Column(
                      children: <Widget>[
                        SizedBox(
                          width: 200,
                          child: TextField(
                            controller: _idTextController,
                            maxLines: 1,
                            decoration: InputDecoration(
                                labelText: '아이디', border: OutlineInputBorder()),
                          ),
                        ),
                        SizedBox(
                          height: 20,
                        ),
                        SizedBox(
                          width: 200,
                          child: TextField(
                            controller: _pwTextController,
                            obscureText: true,
                            maxLines: 1,
                            decoration: InputDecoration(
                                labelText: '비밀번호', border: OutlineInputBorder()),
                          ),
                        ),
                        Row(
                          children: <Widget>[
                            MaterialButton(
                                onPressed: () {
                                  Navigator.of(context).pushNamed('/sign');
                                },
                                child: Text('회원가입')),
                            MaterialButton(
                                onPressed: () {
                                  if (_idTextController?.value.text.length == 0 ||
                                      _pwTextController?.value.text.length == 0) {
                                    makeDialog('빈칸이 있습니다');
                                  } else {
                                    reference!
                                        .child(_idTextController!.value.text)
                                        .onValue
                                        .listen((event) {
                                      if (event.snapshot.value == null) {
                                        makeDialog('아이디가 없습니다');
                                      } else {
                                        reference!
                                            .child(_idTextController!.value.text)
                                            .onChildAdded
                                            .listen((event) {
                                          User user =
                                              User.fromSnapshot(event.snapshot);
                                          // 사용자가 입력한 비밀번호를 utf8로 인코딩한 후 sha1로 변경한다.
                                          // SHA-1은 해시 알고리즘을 이용해 특정 단어를 무작위 텍스트로 바꿔주는 함수이다.
                                          // 해시 알고리즘을 사용하면 문자열을 짧은 길이의 키로 변환하고 고속으로 검색할 수 있게 해준다.
                                          // 또한, 서버에 암호를 저장할 때 원문을 저장하지 않기 때문에 보안에도 좋다.
                                          var bytes = utf8.encode(
                                              _pwTextController!.value.text);
                                          var digest = sha1.convert(bytes);
                                          // ---------------------------------
                                          if (user.pw == digest.toString()) {
                                            Navigator.of(context)
                                                .pushReplacementNamed('/main',
                                                    arguments: _idTextController!
                                                        .value.text);
                                          } else {
                                            makeDialog('비밀번호가 틀립니다');
                                          }
                                        });
                                      }
                                    });
                                  }
                                },
                                child: Text('로그인'))
                          ],
                          mainAxisAlignment: MainAxisAlignment.center,
                        )
                      ],
                    ),
                  )
                ],
                mainAxisAlignment: MainAxisAlignment.center,
              ),
            ),
          ),
        );
      }
    
      void makeDialog(String text) {
        showDialog(
            context: context,
            builder: (context) {
              return AlertDialog(
                content: Text(text),
              );
            });
      }
    }

     

    // signPage.dart
    import 'dart:convert';
    
    import 'package:crypto/crypto.dart';
    import 'package:firebase_database/firebase_database.dart';
    import 'package:flutter/material.dart';
    import 'data/user.dart';
    
    class SignPage extends StatefulWidget {
      @override
      State<StatefulWidget> createState() => _SignPage();
    }
    
    class _SignPage extends State<SignPage> {
      FirebaseDatabase? _database;
      DatabaseReference? reference;
      String _databaseURL = '###파이어베이스 실시간 데이터베이스 URL###';
    
      TextEditingController? _idTextController;
      TextEditingController? _pwTextController;
      TextEditingController? _pwCheckTextController;
    
      @override
      void initState() {
        super.initState();
        _idTextController = TextEditingController();
        _pwTextController = TextEditingController();
        _pwCheckTextController = TextEditingController();
    
        _database = FirebaseDatabase(databaseURL: _databaseURL);
        reference = _database?.reference().child('user');
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text('회원가입'),
          ),
          body: Container(
            child: Center(
              child: Column(
                children: <Widget>[
                  SizedBox(
                    width: 200,
                    child: TextField(
                      controller: _idTextController,
                      maxLines: 1,
                      decoration: InputDecoration(
                          hintText: '4자 이상 입력해주세요',
                          labelText: '아이디',
                          border: OutlineInputBorder()),
                    ),
                  ),
                  SizedBox(
                    height: 20,
                  ),
                  SizedBox(
                    width: 200,
                    child: TextField(
                      controller: _pwTextController,
                      obscureText: true,
                      maxLines: 1,
                      decoration: InputDecoration(
                          hintText: '6자 이상 입력해주세요',
                          labelText: '비밀번호',
                          border: OutlineInputBorder()),
                    ),
                  ),
                  SizedBox(
                    height: 20,
                  ),
                  SizedBox(
                    width: 200,
                    child: TextField(
                      controller: _pwCheckTextController,
                      obscureText: true,
                      maxLines: 1,
                      decoration: InputDecoration(
                          labelText: '비밀번호확인', border: OutlineInputBorder()),
                    ),
                  ),
                  SizedBox(
                    height: 20,
                  ),
                  MaterialButton(
                    onPressed: () {
                      if (_idTextController!.value.text.length >= 4 &&
                          _pwTextController!.value.text.length >= 6) {
                        if (_pwTextController!.value.text ==
                            _pwCheckTextController!.value.text) {
                          var bytes = utf8.encode(_pwTextController!.value.text);
                          var digest = sha1.convert(bytes);
                          reference!
                              .child(_idTextController!.value.text)
                              .push()
                              .set(User(
                                      _idTextController!.value.text,
                                      digest.toString(),
                                      DateTime.now().toIso8601String())
                                  .toJson())
                              .then((_) {
                            Navigator.of(context).pop();
                          });
                        } else {
                          makeDialog('비밀번호가 틀립니다');
                        }
                      } else {
                        makeDialog('길이가 짧습니다');
                      }
                    },
                    child: Text(
                      '회원가입',
                      style: TextStyle(color: Colors.white),
                    ),
                    color: Colors.blueAccent,
                  )
                ],
                mainAxisAlignment: MainAxisAlignment.center,
              ),
            ),
          ),
        );
      }
    
      void makeDialog(String text) {
        showDialog(
            context: context,
            builder: (context) {
              return AlertDialog(
                content: Text(text),
              );
            });
      }
    }

     

    // data/user.dart
    import 'package:firebase_database/firebase_database.dart';
    
    // 데이터 구조 만들기
    class User {
      String id;
      String pw;
      String createTime;
    
      User(this.id, this.pw, this.createTime);
    
      User.fromSnapshot(DataSnapshot snapshot)
          : id = snapshot.value['id'],
            pw = snapshot.value['pw'],
            createTime = snapshot.value['createTime'];
    
      toJson() {
        return {
          'id': id,
          'pw': pw,
          'createTime': createTime,
        };
      }
    }

     

    [그림 1] 로그인/회원가입 화면

     

    간편 로그인 기능

    유명한 서비스에 가입된 계정으로 로그인할 수 있는데 firebase_auth 패키지를 이용하면 된다.

     


     

    에러 해결

    package name 불일치 문제

    > No matching client found for package name 'com.example.flutter_modu_tour'

     

    위 에러에 대하여 google-services.json, build.gradle을 아래와 같이 각각 수정하여 해결했다.

    // android/app/google-services.json
    "android_client_info": {
      "package_name": "com.sewol.buber_tour"
    }
    // android/app/build.gradle
    defaultConfig {
        applicationId "com.sewol.buber_tour"

     

    multidex 문제

    Flutter multidex handling is disabled. If you wish to let the tool configure multidex, use the │
    --mutidex flag.

     

    위 에러에 대하여 build.gradle을 아래와 같이 수정하여 해결했다.

    // android/app/build.gradle
    defaultConfig {
        applicationId "com.sewol.buber_tour"
        minSdkVersion 20
        targetSdkVersion flutter.targetSdkVersion
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
        multiDexEnabled true // 추가
    }
    
    dependencies {
        implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
        implementation platform('com.google.firebase:firebase-bom:29.1.0')
        implementation 'com.google.firebase:firebase-analytics-ktx'
        implementation 'com.android.support:multidex:1.0.3' // 추가
    }

     

    728x90
    반응형
    댓글