본문 바로가기

Flutter/개발

[flutter] 학생 조퇴 일지 기록 프로그램(firebase)

Firebase로 학생의 학번을 조회하고, 그 학생의 조퇴기록을 firebase에 저장하는 프로그램을 제작하였다.

 

#)

처음부터 글을 작성할 목적으로 제작한 프로그램이 아니였어서, 과정적인 부분은 포함되어있지 않습니다.

궁금하신 부분은 질문주시면 답변해드리겠습니다.

 

0. 개요

1) Flutter와 Firebase를 연동한다.

2) Flutter에서 Firebase를 이용하여 구글 로그인을 구현한다.

3) Firebase storage에 존재하는 이미지 파일을 불러낸다.

4) Firestore Database에서 정보를 조회하고, 데이터(조퇴 시간)을 원하는대로 추가한다.

1. 배경지식

<flutter/ firebase 연동>

https://learncom1234.tistory.com/36?category=965073

 

flutter Firebase 사용법(안드로이드 스튜디오)

1. 우선 안드로이드 스튜디오로 새로운 app을 만든뒤 Firebase로 접속한다. Firebase Firebase는 고품질 앱을 빠르게 개발하고 비즈니스를 성장시키는 데 도움이 되는 Google의 모바일 플랫폼입니다. firebas

learncom1234.tistory.com

<Firebase_storage 사용법>

https://firebase.flutter.dev/docs/storage/usage/

 

Using Cloud Storage | FlutterFire

To start using the Cloud Storage package within your project, import it at the top of your project files:

firebase.flutter.dev

<Flutter 구글 로그인>

https://pub.dev/packages/google_sign_in

 

google_sign_in | Flutter Package

Flutter plugin for Google Sign-In, a secure authentication system for signing in with a Google account on Android and iOS.

pub.dev

 

 

2. Dependencies

dependencies:
  flutter:
    sdk: flutter
  firebase_auth: ^3.0.2
  firebase_core: ^1.5.0
  firebase_storage: ^10.0.3
  cloud_firestore: ^2.5.1
  google_sign_in: ^5.0.7
  ntp: ^1.0.8

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2

 

3. Code

더보기

<main.dart>

import 'package:flutter/material.dart';
import 'package:untitled/src/app.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'FlutterDemo',
      theme: ThemeData(
        primaryColor: Colors.blue,
      ),
      home: App(),
    );
  }
}

<src/app.dart>

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:untitled/src/pages/home.dart';

class App extends StatelessWidget {
  const App({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
        future: Firebase.initializeApp(),
        builder: (context, snapshot){
          if(snapshot.hasError){
            return Center(child: Text("firebase load failed ;"));
          }
          if(snapshot.connectionState == ConnectionState.done){
            return Home();
          }
          return CircularProgressIndicator(); //Connection이 완료될 때 까지는 로딩바를 보여준다.
        }
    );
  }
}

<src/pages/login.dart>

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:google_sign_in/google_sign_in.dart';

class LoginWidget extends StatelessWidget {
  const LoginWidget({Key? key}) : super(key: key);

  Future<UserCredential> signInWithGoogle() async { // Google login을 위한 코드. 정형화된 오픈소스이므로 그대로 복사하여도 무방하다.
    // Trigger the authentication flow
    final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn();

    // Obtain the auth details from the request
    final GoogleSignInAuthentication googleAuth = await googleUser!.authentication;

    // Create a new credential
    final credential = GoogleAuthProvider.credential(
      accessToken: googleAuth.accessToken,
      idToken: googleAuth.idToken,
    );

    // Once signed in, return the UserCredential
    return await FirebaseAuth.instance.signInWithCredential(credential);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("SNS login"),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              style: ElevatedButton.styleFrom(
                primary: Colors.orangeAccent,
              ),
              onPressed: (){
                signInWithGoogle();
              },
              child: Text("Google login"),
            ),
          ],
        ),
      )
    );
  }
}

<src/pages/home.dart>

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:untitled/src/pages/login.dart';
import 'package:untitled/src/pages/studentInfo.dart';

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

  @override
  _HomeState createState() => _HomeState();
}

class _HomeState extends State<Home> {
  @override
  Widget build(BuildContext context) {
    TextEditingController studentNum = TextEditingController();
    return Scaffold(
      appBar: AppBar(
        title: Text("Home"),
        actions: [

        ],
      ),
      body: StreamBuilder<User?>(
          stream: FirebaseAuth.instance.authStateChanges(),
          builder: (BuildContext context, AsyncSnapshot<User?> snapshot){
            if(!snapshot.hasData){ // login을 하지 않으면 snapshot에 데이터가 존재하지 않으므로 longinWidget()을 출력한다.
              return LoginWidget();
            }else{
              return Container(
                child: SingleChildScrollView(
                  child: Column(
                    children: [
                      SizedBox(height:10.0,),
                      TextField(
                        decoration: InputDecoration(
                          border: OutlineInputBorder(),
                          labelText: 'Student number',
                        ),
                        controller: studentNum,
                      ),
                      SizedBox(height: 10.0,),
                      ElevatedButton(
                          onPressed: (){
                            if(isInt(studentNum.text)) {//검색한 내용이 숫자이면, StudentInfoPage()에 검색한 숫자를 전달한다.
                              Navigator.push(
                                context,
                                MaterialPageRoute(
                                    builder: (context) => StudentInfoPage(info: int.parse(studentNum.text))),);
                            }
                          },
                          child: Icon(Icons.search)
                      ),
                      SizedBox(height: 20.0,),
                    ],
                  ),
                )
              );
            }
          },
      ),
    );
  }
}

bool isInt(String str){ // flutter에는 숫자임을 확인하는 기능이 따로 존재하지 않으므로, 함수를 직접 선언하여야한다.
  return int.tryParse(str) != null;
}

<src/pages/studentInfo.dart>

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:firebase_storage/firebase_storage.dart';

class StudentInfoPage extends StatefulWidget {
  const StudentInfoPage({Key? key, required this.info}) : super(key: key);

  final int info;

  @override
  _StudentInfoPageState createState() => _StudentInfoPageState(info: info);
}

class _StudentInfoPageState extends State<StudentInfoPage> {

  int? info;
  String profileImgUrl = "";
  _StudentInfoPageState({this.info});
  var ref;

  @override
  void initState(){
    super.initState();
    prepareProfile();
  }

  Future<void> prepareProfile() async{ // Firebase storage에서 특정한 이름을 가진 파일을 불러온다.
    ref = FirebaseStorage.instance
        .ref()
        .child('profile')
        .child('/profile${info.toString()}.png');
    print('profile/profile${info.toString()}.png');
    String url = await ref.getDownloadURL();
    setState(() { // 이와 같이 setState를 활용하여야만, 사진이 받아졌을 때 그 사진이 화면상에 출력된다.
      profileImgUrl = url;
    });
  }

  @override
  Widget build(BuildContext context) {

    return Scaffold(
      appBar: AppBar(
        title: Text('${widget.info}\'s information'),
      ),
      body: Column(
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceAround,
            children: [
              CircleAvatar(
                radius: 50.0,
                backgroundColor: Colors.white,
                backgroundImage: NetworkImage(profileImgUrl!),
              ),
              Text('num : ${widget.info}'),
            ],
          ),
          ElevatedButton(
              onPressed: () async {
                String profile = "";
                await FirebaseFirestore.instance // 'users' collection에서 info(학번)과 동일한 데이터를 가진 문서의 id를 profile에 저장한다.
                    .collection('users')
                    .where('num', isEqualTo: info)
                    .get()
                    .then((QuerySnapshot ds) {
                      ds.docs.forEach((doc) => profile = doc.id);
                    });
                DateTime date = DateTime.parse(Timestamp.now().toDate().toString());
                String dateKey = date.toString().substring(0,10); // 해당 날짜를 문서의 이름으로 사용 (ex : '2021-08-28')

                await FirebaseFirestore.instance // 학생의 문서에서 'leave early' collection의 'date' 문서를 update한다.
                    .collection('users')
                    .doc(profile)
                    .collection('leave early')
                    .doc('date')
                    .update({dateKey : date});

                showSnackBar(context, '${info}님의 조퇴 처리가 완료되었습니다.\n ${dateKey} : ${date}');
              },
              child: Text('조퇴 처리'),
          )
        ],
      ),
    );
  }
}

void showSnackBar(BuildContext context, String msg){
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text('$msg',
        textAlign: TextAlign.center,),
      duration: Duration(seconds: 3),
      backgroundColor: Colors.blue,
    ),
  );
}

 

4. 구동 화면

<Home 화면>

구글 로그인 이후, Home 화면에는 Textfield만 나타난다.

<TextField에 학번 입력(firestore database에 미리 입력해둔 학생의 학번이다)>

Textfield에 학번을 입력하고 검색버튼을 누르면, 해당 학번을 가진 문서를 firestore database에서 조회하고, 그 문서를 토대로 학생의 정보창을 띄운다.

<조퇴처리 버튼을 누르면 snackbar와 함께 firestore database에 조퇴 시간이 기록된다.>

필자가 즐겨하는 게임인 메이플스토리와 관련된 사진을 다수 사용하였다. 처음에는 학생의 사진이 빈상태로 화면이 나타나지만, 짧은 시간이 지나면 setState()에 의해 다시 화면이 build되면서 사진이 정상적으로 나타난다. 학생의 조퇴 버튼을 누르면 아래에 snackbar를 띄우며 firestore storage에 날짜에 따른 시간을 기록한다.

<Firestore database에 내가 지정한 방식으로 timestamp가 찍혀있는 모습을 확인할 수 있다.>

 

5. 마치며

Flutter와 Firebase를 연동시키는 내용은 마느아님의 티스토리에 잘 설명되어있으니 참고하면 좋을 것 같다.

중간중간 벽에 막혔던 부분이다.

 

1. 구글로그인을 구현했을 때 로그인이 되었다는 내용이 제대로 나타나지 않고 화면이 그대로 멈춰있었다.

 

[ERROR:flutter/lib/ui/ui_dart_state.cc(177)] Unhandled Exception: PlatformException(sign_in_failed, com.google.android.gms.common.api.ApiException: 10: , null, null)

해당 에러는 Firebase와 연동할 때 SHA 키 입력을 건너뛰는 경우에 발생한다. SHA key를 찾아서 firebase의 프로젝트 설정에 입력하면 정상 작동한다.

 

2. async, await하는데 문제가 발생한다

 

Flutter가 상당히 깐깐한지, 적당한 양식을 지키지 않으면 Future와 async, await을 사용하는데 애로사항이 생긴다. 필자의 경우 Widget build 의 위 부분에 Future<T> 의 함수를 새로 선언하고, 그 안에서 await해야 할 명령어를 사용하였다.

 

3. null safety가 계속 오류라고 말하여 처리가 까다롭다

 

이 부분은 정말로 어쩔 수 없는 것 같다... 필자의 경우에는 경고창을 열심히 해석해서 어떤 부분이 null값이 올 수 있는지를 하나하나 따져가면서 했다. 아무래도, 이런 방식의 코딩이 익숙치 않다보니 적응에 시간이 다소 필요할 것 같다.