Flutter로 프로젝트용 어플 제작

나 안해·2023년 3월 22일
1

MyProject

목록 보기
5/5
post-thumbnail

1. Flutter 작업 시작

  • Figma 디자인에 따른 모바일 어플리케이션 페이지 및 레이아웃 Flutter로 구현

1.1 페이지

네비게이션바(임시)시작화면로그인
회원가입로딩화면메인 화면 (대여 전)
QR 스캔오류보고메뉴
사용자 프로필결제 정보이용 방법문의 내역

추후 작업 요망

  • 챗봇 기능 추가 시 챗봇 위젯 구현 필요
  • 위젯간 데이터 전송 및 상태 관리
  • API 를 통한 request 및 response 처리

2. 기능 구현

2.1 앱 로그인 및 로그아웃 기능 구현

  • REST API를 이용해 fastAPI 서버와 로그인 기능을 연동할 수 있는지 테스트 해보고자 한다.
  • 서버는 POST 방식으로 user_email과 password 키값을 가지는 JSON을 보내면 해당 Token을 똑같은 JSON형태로 돌려준다.
    소스코드

    에러 발생
    INFO: 172.18.0.1:50394 - "POST /users/login HTTP/1.1" 422 Unprocessable Entity

  • 원인 : 데이터를 JSON 형태로 Post 하지 않아서 생긴 문제다. jsonEncode 함수에 Map 형태의 값을 넣어 보내도록 한다.

    -화면을 통해 발급받은 토큰을 확인했다.

만들어놓은 레이아웃에 적용시킨 소스 코드

import 'dart:convert';import 'package:dio/dio.dart';import 'package:flutter/material.dart';import 'package:flutter_project/const/constants.dart';import 'package:flutter_project/screen/1_start.dart';import 'package:flutter_project/screen/3_signup.dart';import 'package:flutter_project/widgets/basic_button.dart';import 'package:flutter_project/widgets/input_box.dart';import 'package:flutter_project/widgets/logo.dart';class Login extends StatefulWidget {
  Login({super.key});  @override  State<Login> createState() => _LoginState();}

class _LoginState extends State<Login> {
  String user_email = '';  String password = '';  bool logoAppear = true;  @override  Widget build(BuildContext context) {
    final bottomInset = MediaQuery.of(context).viewInsets.bottom;    return Scaffold(
      appBar: AppBar(
        title: Text("로그인"),      ),      backgroundColor: MAIN_COLOR,      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 10.0),        child: Container(
          width: MediaQuery.of(context).size.width,          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,            crossAxisAlignment: CrossAxisAlignment.stretch,            children: [
              showLogo(),              InputBox(
                hintText: "아이디",                onTap: () {
                  setState(() {
                    logoAppear = false;                  });                },                onChanged: (String value) {
                  user_email = value;                },              ),              SizedBox(
                height: 10.0,              ),              InputBox(
                hintText: "비밀번호",                onTap: () {
                  setState(() {
                    logoAppear = false;                  });                },                onChanged: (String value) {
                  password = value;                },              ),              Row(
                children: [
                  Checkbox(value: false, onChanged: (value) {}),                  Text('자동 로그인'),                ],              ),              SizedBox(
                height: 30.0,              ),              Row(
                mainAxisAlignment: MainAxisAlignment.center,                children: [
                  Expanded(
                    child: BasicButton(
                      buttonTitle: "로그인",                      onPressed: () {
                        postLoginInfo();                        print('##### 토큰 정보 : ${postLoginInfo().toString()} #####');                      },                    ),                  ),                  SizedBox(
                    width: 10.0,                  ),                  Expanded(
                    child: BasicButton(
                      buttonTitle: "계정 찾기",                      onPressed: () => Navigator.push(
                        context,                        MaterialPageRoute(
                          builder: (context) => Start(),                        ),                      ),                    ),                  ),                ],              ),              BasicButton(
                buttonTitle: "회원가입",                onPressed: () => Navigator.push(
                  context,                  MaterialPageRoute(
                    builder: (context) => SignUp(),                  ),                ),              ),            ],          ),        ),      ),    );  }

  Widget showLogo() {
    if (logoAppear) {
      return Column(
        children: [
          Logo(),          SizedBox(
            height: 50.0,          ),        ],      );    } else {
      return SizedBox(
        height: 20.0,      );    }
  }

  Future<Map> postLoginInfo() async {
    Map loginInfo = {
      'user_email': user_email,      'password': password,    };    print('##### loginInfoMap is ${loginInfo} #####');    final response = await Dio().post(
      'http://10.0.2.2:8000/users/login',      data: jsonEncode(loginInfo),    );    return response.data;  }
}
  • 이메일과 비밀번호의 초기값이 onChanged 함수를 통해 TextFieldForm의 값으로 변경되어 저장된다.

2023.02.28. 로그인, 로그아웃 구현 마무리 작업

  • TextFormField 위젯의 onChanged 함수를 이용하면 이메일과 비밀번호를 작성하는 중에도 상태가 변경되어 상태관리가 어려워진다.
  • 대안으로 onSaved 함수를 이용해 값을 저장하고 TextFormField 위젯을 Form 위젯으로 감싸 GlobalKey() 클래스를 이용해 저장된 값들을 관리한다.
  • Dio를 이용해 응답받은 토큰은 FlutterSecureStorage 라이브러리를 이용해 값을 저장, 로그아웃에 이용한다.
  • showDialog함수를 이용해 로그인 성공, 실패시 알림창이 뜨게하고 알림창을 확인했을 때 Navigator를 이용해 알맞은 스크린으로 이동하도록 한다.
  • obscureText 기능을 통해 비밀번호 입력 시 “*”기호로 보이게 한다.

2.2 앱 로그인 및 로그아웃 기능구현 상세

  • 로그인 성공 혹은 실패 시 API의 응답 데이터가 FutureBuilder의 snapshot에 저장된다. 저장된 데이터의 경우의 수만큼 switch문으로 코드를 작성한다.
  • 비밀번호가 일치하지 않았을 경우
소스코드화면
  • 아이디가 일치하지 않았을 경우
소스코드화면
  • 로그인에 성공했을 경우
소스코드화면

2.3 앱 회원가입 기능 구현

  • 우선 회원가입에 필수 정보인 “이메일”과 “비밀번호”, “비밀번호 재입력” TextFieldForm 을 만든다
  • “비밀번호 재입력” 칸을 이용해 비밀번호를 정확히 입력했는지 검증한다.
  • 이메일, 비밀번호가 null값이 아니고 비밀번호와 재입력 비밀번호가 일치하면 Dio 라이브러리를 통해 서버에 이메일과 비밀번호 값을 JSON 형태로 보낸다.
  • 서버에서 응답받은 값 (null, 회원가입성공, 회원가입실패, 이미 존재하는 이메일) 에 따라 Dialog를 보여준다.
  • 회원가입이 완료되면 Dialog의 ‘확인’버튼을 누르고 로그인 페이지로 이동한다.

2.4 앱(안드로이드) 지도 기능 구현

flutter_naver_map 플러그인을 통해 지도 위에 우산 대여함의 위치를 마킹하고 우산 대여함 아이콘을 클릭하면 우산 대여 화면으로 넘어가도록 한다.

네이버 API 사용 방법

  • 네이버 클라우드 콘솔에 접속해 지도 API Key를 발급받는다.
  • 현재 진행 중인 플러터 프로젝트 명을 그대로 입력한다.
  • 클라이언트ID를 진행중인 프로젝트의 AndroidManifest.xml 파일에 입력한다.

  • 에뮬레이터를 통해 이상없이 나오는지 확인한다.

2.4.1 우산대여함 위치에 마킹하기

  • initialCameraPosition 키를 통해 지도를 켰을 때 처음 위치를 설정한다.
  • 초기 위치는 우선 우산대여함 위치와 동일하게 설정한다.
  • markers 키를 통해 마커를 입력한다.
  • 마커 아이콘은 assets 디렉토리에 있는 우산이미지를 이용하였다.
  • 우산 아이콘을 클릭시 우산 대여 혹은 반납 페이지로 이동할 수 있는 네비게이션 창이 실행된다
Marker(
                            markerId: '1',
                            position: LatLng(standLatitude, standLongitude),
                            icon: snapshot.data,
                            width: 32,
                            height: 32,
                            onMarkerTab: (marker, iconSize) {
                              showDialog(
                                context: context,
                                builder: (context) {
                                  return AlertDialog(
                                    title: Text("우산 대여 화면으로\n이동하시겠습니까?"),
                                    content: Row(
                                      mainAxisAlignment:
                                          MainAxisAlignment.spaceEvenly,
                                      children: [
                                        ElevatedButton(
                                          onPressed: () {
                                            Navigator.of(context).push(
                                              MaterialPageRoute(
                                                builder: (context) =>
                                                    RentScreen(),
                                              ),
                                            );
                                          },
                                          child: Text("예"),
                                        ),
                                        ElevatedButton(
                                          onPressed: () {
                                            Navigator.of(context).pop();
                                          },
                                          child: Text("아니오"),
                                        ),
                                      ],
                                    ),
                                  );
                                },
                              );
                            },
                          ),

2.4.2 사용자 현재 위치 표시

  • geolocator 패키지를 사용
dependencies:
    geolocator: ^9.0.2
  • flutter pub get 명령어로 dependency를 적용
import 'package:geolocator/geolocator.dart';

Future<String> checkLocationPermission() async {
  final isLocationEnabled = await Geolocator.isLocationServiceEnabled();
  if (!isLocationEnabled) {
    return "위치 서비스를 활성화해주세요.";
  }
  LocationPermission checkedPermission = await Geolocator.checkPermission();
  if (checkedPermission == LocationPermission.denied) {
    checkedPermission = await Geolocator.requestPermission();
    if (checkedPermission == LocationPermission.denied) {
      return "위치 권한을 허가해주세요.";
    }
  }
  if (checkedPermission == LocationPermission.deniedForever) {
    return "앱의 위치 권한을 설정에서 허가해주세요.";
  }
  return "위치 권한이 허가 되었습니다.";
}
class BeforeRentScreen extends StatefulWidget {
  const BeforeRentScreen({Key? key}) : super(key: key);

  @override
  State<BeforeRentScreen> createState() => _BeforeRentScreenState();
}

class _BeforeRentScreenState extends State<BeforeRentScreen> {
  final standLatitude = 37.5706;
  final standLongitude = 126.9853;

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () {
        return Future(() => false);
      },
      child: Scaffold(
        appBar: AppBar(
          title: Text("Before Rent Screen"),
          leading: Container(),
          // 배포 시 홈버튼 삭제
          actions: [homeButton(context)],
        ),
        body: Stack(
          children: [
            FutureBuilder<String>(
              future: checkLocationPermission(),
              builder: (context, snapshot) {
                if (snapshot.hasData &&
                    snapshot.connectionState == ConnectionState.waiting) {
                  return Center(
                    child: CircularProgressIndicator(),
                  );
                }
                if (snapshot.data == '위치 권한이 허가 되었습니다.') {
                  return FutureBuilder(
                    future: OverlayImage.fromAssetImage(
                      assetName: 'assets/images/umb_icon.png',
                    ),
                    builder: (context, snapshot) {
                      return NaverMap( ...

2.5 앱 게시판 기능 구현

  • 게시글 작성 시 첨부파일 업로드 테스트
  • image_picker 패키지를 사용해 갤러리 혹은 카메라로 사진을 불러온다.
    소스코드 :
import 'dart:io';

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

void main() {
  runApp(
    MaterialApp(
      home: HomeScreen(),
    ),
  );
}

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

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  String? imagePath;
  XFile? tokenPthoto;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          ElevatedButton(
            onPressed: () async {
              final image =
              await ImagePicker().pickImage(source: ImageSource.gallery);
              print(image);
            },
            child: Text("Load Image"),
          ),
          ElevatedButton(
            onPressed: () async {
              final image = await ImagePicker().pickImage(
                source: ImageSource.camera,
                maxHeight: 100,
                maxWidth: 100,
                imageQuality: 30,
              );
              print("이미지 경로 : ${image!.path}");
              print("이미지 경로 타입 : ${image.path.runtimeType}");
              setState(() {
                imagePath = image.path;
                tokenPthoto = image;
              });
            },
            child: Text("Take a Photo"),
          ),
          previewPhoto(),
        ],
      ),
    );
  }

  Widget previewPhoto() {
    if (imagePath == null) {
      return Container(
        child: Text("이미지 경로 없음"),
      );
    } else {
      return Column(
        children: [
          Text("이미지 경로 : $imagePath"),
          Image.file(
            File(imagePath!),
            width: 200,
            height: 200,
          ),
        ],
      );
    }
  }
}
첫 화면이미지 선택 화면이미지 로드 완료
  • 촬영한 데이터 (혹은 불러온 데이터)를 서버에 전송한다.
    - Dio 패키지의 post 함수를 사용한다.

2.6 앱 챗봇 기능 구현

챗봇 응답 서버와 통신 가능 테스트

  • http 패키지의 post 메서드를 이용해 서버의 챗봇 인공지능 모델과 통신한다.
  • ListView builder를 이용해 사용자와 챗봇의 메세지를 화면에 나타낸다.
  • 소스코드 :
챗봇 응답 서버와 통신 가능 테스트

http 패키지의  post 메서드를 이용해 서버의 챗봇 인공지능 모델과 통신한다.

ListView builder를 이용해 사용자와 챗봇의 메세지를 화면에 나타낸다.

소스코드 : 
  • 에뮬레이터 화면

2.7 QR코드 인식

  • QR code scanner 패키지를 pub.dev 에서 가져온다
dependencies:
  qr_code_scanner: ^1.0.1
  • QR코드 인식에 성공하는 순간 우산 내부 사진을 캡쳐하기 위해 Screenshot 패키지를 pub.dev 에서 가져온다
dependencies:
    screenshot: ^1.3.0
  • flutter pub dev 명령어로 depndency를 적용
  • class의 속성으로 screenshot의 controller, qrview의 controller, qrcode, 캡쳐 시 QR 가이드라인을 제거하기 위해 boolean값을 생성
class RentScreen extends StatefulWidget {
  const RentScreen({Key? key}) : super(key: key);

  @override
  State<RentScreen> createState() => _RentScreenState();
}

class _RentScreenState extends State<RentScreen> {
  final screenshotController = ScreenshotController();
  String? qrCode;
  QRViewController? controller;
  final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
  bool removeQrScannerOverlayShape = false;

  @override
  Widget build(BuildContext context) {
    double deviceWidth = MediaQuery.of(context).size.width;

    return Scaffold( ... 생략 ...
  • 캡쳐하는 범위는 QRView 위젯 전체이므로 Screenshot 위젯으로 QRView 위젯을 감싼다. QRcode 스캔에 성공하면 카메라를 멈추고 Screenshot의 캡쳐 기능이 발동하도록 코딩한다. 캡쳐에 성공하면 API서버에 발송할지 여부를 묻는 다이얼로그가 뜨고 승인하면 서버에서 QR코드를 매치하고 커스텀한 YOLOv5를 이용해 우산의 고장 여부를 판별
... 생략 ...
return Scaffold(
      appBar: AppBar(
        title: Text("Rent Screen"),
        actions: [
          homeButton(context)
        ],
        leading: IconButton(
          icon: Icon(Icons.arrow_back),
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(
                builder: (context) => BeforeRentScreen(),
              ),
            );
          },
        ),
      ),
      body: Screenshot(
        controller: screenshotController,
        child: QRView(
          key: qrKey,
          overlay: renderQrScannerOverlayShape(deviceWidth),
          onQRViewCreated: (controller) {
            controller.scannedDataStream.listen(
              (scanData) {
                if (scanData.code == null) {
                  return;
                } else {
                  qrCode = scanData.code;
                  setState(
                    () {
                      removeQrScannerOverlayShape = true;
                    },
                  );
                  controller.pauseCamera().then(
                    (_) async {
                      final capturedImg = await screenshotController.capture();
                      showDialog(
                        context: context,
                        builder: (context) {
                          return AlertDialog(
                            title: Image.memory(
                              capturedImg!,
                              width: deviceWidth * 0.75,
                              height: deviceWidth * 0.75,
                            ),
                            actions: [
                              ElevatedButton(
                                onPressed: () {
                                  setState(
                                    () {
                                      removeQrScannerOverlayShape = false;
                                    },
                                  );
                                  Navigator.pop(context);
                                  controller.resumeCamera();
                                },
                                child: Text("다시 스캔"),
                              ),
                              FutureBuilder(
                                future:
                                    FlutterSecureStorage().read(key: "token"),
                                builder: (context, snapshot) {
                                  return ElevatedButton(
                                    child: Text("대여"),
                                    onPressed: () async {
                                      await Dio()
                                          .post(
                                        '$API_DOMAIN/flutter/rent',
                                        data: jsonEncode(
                                          {
                                            "token": snapshot.data,
                                            "qr_code": qrCode,
                                          },
                                        ),
                                      )
                                          .then(
                                        (value) {
                                          if (value.data['msg'] == null) {
                                            showDialog(
                                              context: context,
                                              builder: (context) {
                                                return AlertDialog(
                                                  title:
                                                      Text("서버와 통신에 실패하였습니다"),
                                                  actions: [
                                                    ElevatedButton(
                                                      child: Text("확인"),
                                                      onPressed: () {
                                                        Navigator.of(context)
                                                            .pop();
                                                      },
                                                    ),
                                                  ],
                                                );
                                              },
                                            );
                                          } else if (value.data['msg'] ==
                                              "대여 성공") {
                                            showDialog(
                                              context: context,
                                              builder: (context) {
                                                return AlertDialog(
                                                  title: Text("대여 성공했습니다"),
                                                  actions: [
                                                    ElevatedButton(
                                                      child: Text("확인"),
                                                      onPressed: () {
                                                        Navigator.of(context)
                                                            .push(
                                                          MaterialPageRoute(
                                                            builder: (context) =>
                                                                AfterRentScreen(),
                                                          ),
                                                        );
                                                      },
                                                    ),
                                                  ],
                                                );
                                              },
                                            );
                                          } else if (value.data['msg'] ==
                                              "대여 실패") {
                                            showDialog(
                                              context: context,
                                              builder: (context) {
                                                return AlertDialog(
                                                  title: Text("대여 실패했습니다"),
                                                  actions: <Widget>[
                                                    ElevatedButton(
                                                      child: Text("확인"),
                                                      onPressed: () {
                                                        Navigator.of(context)
                                                            .push(
                                                          MaterialPageRoute(
                                                            builder: (context) =>
                                                                RentScreen(),
                                                          ),
                                                        );
                                                      },
                                                    ),
                                                  ], // add this
                                                );
                                              },
                                            );
                                          }
                                        },
                                      );
                                    },
                                  );
                                },
                              ),
                            ],
                          );
                        },
                      );
                    },
                  );
                }
              },
            );
          },
        ),
      ),
    );
  • 캡쳐 시 QR코드 가이드라인이 보이지 않도록 하기 위해 renderQrScannerOverlayShape을 추가
... 생략 ...
QrScannerOverlayShape renderQrScannerOverlayShape(deviceWidth) {
    if (removeQrScannerOverlayShape == false) {
      return QrScannerOverlayShape(
        borderColor: Colors.red,
        borderRadius: 5,
        borderLength: 15,
        borderWidth: 5,
        cutOutSize: deviceWidth * 0.2,
      );
    } else {
      return QrScannerOverlayShape(
        overlayColor: Colors.black.withOpacity(0.0),
      );
    }
  }



2.8 학습한 모델 실행

소스코드

import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
import 'package:screenshot/screenshot.dart';
import '../const/constant.dart';

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

  @override
  State<ReturnScreen> createState() => _ReturnScreenState();
}

class _ReturnScreenState extends State<ReturnScreen> {
  final screenshotController = ScreenshotController();
  String? qrCode;
  QRViewController? controller;
  final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
  bool removeQrScannerOverlayShape = false;

  @override
  Widget build(BuildContext context) {
    double deviceWidth = MediaQuery.of(context).size.width;

    return Scaffold(
      appBar: AppBar(
        title: Text("Return Screen"),
      ),
      body: Screenshot(
        controller: screenshotController,
        child: QRView(
          key: qrKey,
          overlay: renderQrScannerOverlayShape(deviceWidth),
          onQRViewCreated: (controller) {
            controller.scannedDataStream.listen(
              (scanData) {
                if (scanData.code == null) {
                  return;
                } else {
                  qrCode = scanData.code;
                  setState(() {
                    removeQrScannerOverlayShape = true;
                  });
                  controller.pauseCamera().then(
                    (_) async {
                      final capturedImg = await screenshotController.capture(
                        pixelRatio: 0.50,
                      );
                      showDialog(
                        context: context,
                        builder: (context) {
                          return AlertDialog(
                            title: Column(
                              children: [
                                Image.memory(capturedImg!,
                                    width: deviceWidth * 0.75,
                                    height: deviceWidth * 0.75),
                                Text(qrCode!),
                                Row(
                                  children: [
                                    ElevatedButton(
                                      onPressed: () {
                                        setState(
                                          () {
                                            removeQrScannerOverlayShape = false;
                                          },
                                        );
                                        Navigator.pop(context);
                                        controller.resumeCamera();
                                      },
                                      child: Text("다시 스캔"),
                                    ),
                                    FutureBuilder(
                                      future: FlutterSecureStorage()
                                          .read(key: "token"),
                                      builder: (context, snapshot) {
                                        return ElevatedButton(
                                          onPressed: () async {
                                            var response = await Dio().post(
                                              '$API_DOMAIN/flutter/return',
                                              data: jsonEncode(
                                                {
                                                  "token": snapshot.data,
                                                  "qr_code": qrCode,
                                                  "image": capturedImg,
                                                },
                                              ),
                                            );
                                            print(
                                                "response : ${response.toString()}");
                                            Navigator.of(context).pop();
                                          },
                                          child: Text("반납"),
                                        );
                                      },
                                    ),
                                  ],
                                ),
                              ],
                            ),
                          );
                        },
                      );
                    },
                  );
                }
              },
            );
          },
        ),
      ),
    );
  }

  QrScannerOverlayShape renderQrScannerOverlayShape(deviceWidth) {
    if (removeQrScannerOverlayShape == false) {
      return QrScannerOverlayShape(
        borderColor: Colors.red,
        borderRadius: 5,
        borderLength: 15,
        borderWidth: 5,
        cutOutSize: deviceWidth * 0.2,
      );
    } else {
      return QrScannerOverlayShape(
        overlayColor: Colors.black.withOpacity(0.0),
      );
    }
  }
}

구현된 화면

결과

  • 로컬에서는 우산을 인식한 모델이 앱에서는 우산인식을 못하는 문제 발생

대안

  • 우산 내부 인식이 부족하다고 생각해서 파손 여부 판단에 필요한 우산 내부 이미지를 늘려서 재학습
  • 리턴된 값 확인

0개의 댓글