728x90

윈도우즈 환경에서 잘 동작하던 코드가 리눅스(CentOS 7) 환경에서 테스트하니까 동작이 안된다.

아래와 같이 설정하면 제대로 동작되는 걸 확인할 수 있다.

 

1. Google Chrome 설치

yum -y install https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm

 

2. Chrome Driver 설치

먼저 google-chrome --version 을 실행하여 현재 버전을 확인한다.

 

wget https://storage.googleapis.com/chrome-for-testing-public/123.0.6312.58/linux64/chromedriver-linux64.zip
로 파일을 다운로드 한다.

 

압축을 풀고 chromedriver 파일을 아래와 같이 옮긴다. (이게 중요하더라)

mv chromedriver /usr/bin/

 

3. 이제 코드 상에서 동작되도록 구현된 코드를 살펴보자.

chromedriver 를 /usr/bin 으로 옮겨서

driver = webdriver.Chrome(options=options) 만으로 코드가 잘 동작된다.

구글링해보면 아래와 같은 설정으로 동작이 되는 것처럼 설명되어 있지만...

driver = webdriver.Chrome(
    executable_path='크롬드라이버 설치경로', options=options
)

로 테스트 한 것은 동작이 안되었다.

 

pip install selenium 을 하면 4.18.1 버전이 설치된다. (pip list 로 확인)

# pip install selenium  
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
 
import time
 
def jusoGet(keyword):
    # 크롬 드라이버 생성
    options = Options()
    options.add_argument("headless"# 창 숨기는 옵션
    options.add_argument("--no-sandbox")
    driver = webdriver.Chrome(options=options)
    #driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=options)
 
    # 사이트 접속하기
    url = 'https://www.juso.go.kr/support/AddressMainSearch.do?searchKeyword='
    driver.get(url+keyword) # url 페이지로 이동
    time.sleep(2# 로딩 대기
 
    try:
        h = driver.find_element(By.XPATH, value='//*[@id="list1"]/div[2]/span[2]').text
        print(h)
    except:
        pass
 
 
if __name__ == "__main__":
    keyword = '서초구청'
    jusoGet(keyword)

 

 

Python3.9 버전이 설치된 환경 변수 설정 방법

1. Python 환경변수 3.9 설정 (alias)
vi /etc/profile
 
alias python3='/usr/local/bin/python3.9'
alias python='/usr/local/bin/python3.9'
alias pip='/usr/local/bin/pip3.9'
 
source /etc/profile
 
2. 설치 확인
python
python3 -V

 

 

Python 3.9 버전과 3.11 버전 모두 정상 동작함을 확인했다.

 

 

블로그 이미지

Link2Me

,
728x90

오랫만에 크롤링을 해보려고 하니까 selenium 드라이브 설치없이 auto 로 설정하는 옵션이 전혀 동작하지 않는다.

버전업이 중단되어서 동작이 안되는가 보다.

방식이 새롭게 변경되었다는 걸 검색하고 테스트 해본 결과 확인했다.

 

아래 코드를 CentOS 7 에서 실행해보니 안된다. Windows10 환경에서는 잘 된다.

CentOS 7 환경에서 성공한 사항은 다음 게시글에 기록해둔다.

# pip install -U selenium  # Selenium is upgraded to v4.0.0
# pip install webdriver-manager  # Webdriver Manager for Python is installed
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
 
import time
 
def jusoGet(keyword):
    # 크롬 드라이버 생성
    options = Options()
    #options.add_experimental_option("detach", False) # 브라우저 창 떳다기 사라지기(False), 계속 유지(True)
    options.add_argument("headless"# 창 숨기는 옵션
    driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=options)
    # driver.maximize_window() # 브라우저 창 최대로 하는 옵션인데 필요없을 거 같다.
 
    # 페이지 로딩이 완료될 때 까지 기다리는 코드
    driver.implicitly_wait(1)
 
    # 사이트 접속하기
    url = 'https://www.juso.go.kr/support/AddressMainSearch.do?searchKeyword='
    driver.get(url+keyword) # url 페이지로 이동
    time.sleep(2# 로딩 대기
 
    try:
        h = driver.find_element(By.XPATH, value='//*[@id="list1"]/div[2]/span[2]').text
        print(h)
    except:
        pass
 
 
if __name__ == "__main__":
    keyword = '서초구청'
    jusoGet(keyword)

 

 

 

크롬 브라저에 맞는 driver 설치방법 → 불필요

드라이버 설치하고 해보면 아래와 같은 경고 문구가 나온다.

DeprecationWarning: executable_path has been deprecated, please pass in a Service object

 

아래 내용은 불필요한 사항이지만 나중에 보면서 이런 적도 있었구나 하는 셈치고 적어둔다.

chrome://settings/help 를 크롬 브라우저에서 실행하여 현재 버전을 찾아야 한다.

 

 

https://chromedriver.chromium.org/downloads/version-selection

 

ChromeDriver - WebDriver for Chrome - Version Selection

Version selection is the process of matching a Chrome binary of a given version to a compatible ChromeDriver binary. For versions 115 and newer Starting with M115 the ChromeDriver release process is integrated with that of Chrome. The latest Chrome + Chrom

chromedriver.chromium.org

사이트에 접속하면 최신버전과 맞지 않는다.

 

더 최신버전을 위에서 찾아들어가야 한다.

https://googlechromelabs.github.io/chrome-for-testing/

 

Chrome for Testing availability

chrome-headless-shellmac-arm64https://storage.googleapis.com/chrome-for-testing-public/123.0.6312.58/mac-arm64/chrome-headless-shell-mac-arm64.zip200

googlechromelabs.github.io

 

 

블로그 이미지

Link2Me

,
728x90

주소(juso.go.kr) 사이트에서 파일을 다운로드 받아서 주소를 만들고 파싱처리를 하다보니 이런 데이타가 있더라.

 

대부분은 지번주소가 존재하는데 신규로 생성되는 도로명주소에 해당하는 지번주소가 없는 것이다.

이런 데이터는 Naver Map API 에 위치좌표를 조회하면 결과가 없는 경우가 있다.

그렇다고 구글 Geocording API 를 연동하여 처리를 하자니 건당 비용이 발생해서 못하겠다.

불편하지만 구글 스프레드시트를 통해서 자료를 업데이트하는 수 밖에...

방법을 좀 더 찾아봐야겠다.

 

 

 

블로그 이미지

Link2Me

,
728x90

Javascript 코드를 구현할 때 재귀호출을 하면서 Identifier 'marker' has already been declared 라는 메시지가 나온다.

뭐가 문제일까?

 

아래코드를 새로운 창에서 매번 실행한다면 문제될 것이 전혀 없다.

그리고 var 변수로 선언된 변수는 다시 선언해도 되므로 문제가 되지 않는다.

즉, 특정 div 영역에 재로딩을 해도 var 변수가 덮어쓰기 되므로 문제될 것이 없다.

하지만 변수를 let, const  로 변경하면 문제가 될 수 있다.

<body>
<div id="map" style="width:100%;height:350px;"></div>
 
<script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=발급받은 APP KEY를 사용하세요"></script>
<script>
var mapContainer = document.getElementById('map'), // 지도를 표시할 div 
    mapOption = { 
        center: new kakao.maps.LatLng(33.450701126.570667), // 지도의 중심좌표
        level: 3 // 지도의 확대 레벨
    };
 
var map = new kakao.maps.Map(mapContainer, mapOption); // 지도를 생성합니다
 
// 마커가 표시될 위치입니다 
var markerPosition  = new kakao.maps.LatLng(33.450701126.570667); 
 
// 마커를 생성합니다
var marker = new kakao.maps.Marker({
    position: markerPosition
});
 
// 마커가 지도 위에 표시되도록 설정합니다
marker.setMap(map);
 
// 아래 코드는 지도 위의 마커를 제거하는 코드입니다
// marker.setMap(null);    
</script>
</body>

 

 

아래와 같이 코드를 구현했을 경우에는 어떤 문제가 생기는지 살펴보자.

id가 panel_content 라는 DIV 영역에 필요한 파일을 계속 Load하여 화면을 새로 그리는 구조로 코드를 구현해서 사용하고 있다.

<main>
    <div class="container-fluid text-center">
        <div class="row">
            <div class="col-md-12">
                <div class="content" id="panel_content">
                </div>
                <div class="card">
                </div>
            </div>
        </div>
    </div>
</main>
 
<script>
function MListTable(where,keyword,curPage,uri,bidx,sort,cat1,cat2,idx) {
 
    if (keyword.length > 20) {
        calluri = uri + '?p='+curPage+'&idx='+idx+'&cat1='+cat1+'&cat2='+cat2+'&where='+where+'&keyword='+encodeURIComponent(keyword)+'&sortby='+sort;
        console.log('bCode : ' + calluri);
    }
    else
        calluri = uri + '?p='+curPage+'&idx='+idx+'&cat1='+cat1+'&cat2='+cat2+'&where='+where+'&keyword='+encodeURIComponent(keyword)+'&bidx='+bidx+'&sortby='+sort;
 
    $('#panel_content').load(calluri, function() {
        var curPage = $('#paging .act a').text();
 
 
        $('#MNListTable tbody tr').mouseover(function() {
            $(this).children().css({
                'backgroundColor' : '#DCDCDC''cursor' : 'pointer'
            });
        }).mouseout(function() {
            $(this).children().css({
                'backgroundColor' : '#FFFFFF''cursor' : 'default'
            });
        });
 
        $('#MNListTable tbody tr').click(function() {
            var idx = $(this).attr('id');
            MNView(idx,curPage,where,keyword,cat1,cat2,bidx,sort);
        });
        $('#MNHome').click(function(e) {
            e.preventDefault();
            var uri = $('#urlPath').attr('url-path');
           MListTable('','',1,uri,'','','','','');
        });
 
    });
}
 
</script>

 

 

테이블에서 특정 TR 를 누르면 해당 값을 읽어서 MNView 로 넘기는 형태이다.

<script>
function MNView(idx,curPage,where,keyword,cat1,cat2,bidx,sort){
    var uri = 'View.php';
   MListTable(where,keyword,curPage,uri,bidx,sort,cat1,cat2,idx);
}
</script>

 

 

id 가 panel_content 의 DIV 영역에 View.php?idx=1 파일의 내용을 로딩하게 된다.

아래 파일이 View.php?idx=3 으로 다시 로딩하게 되면 let marker 가 이미 선언되어 있기 때문에 중복 선언되는 문제가 발생하는 것이다.

<div class="card">
<div class="mt-1 mb-2 mx-2">
    <div id="map" style="width:100%;height:450px;"></div>
</div>
</div>
 
<script>
var latitude = '<?php echo $lat;?>';
var longitude = '<?php echo $lng;?>';
 
// 마커를 담을 배열입니다
let marker = [];
 
const mapContainer = document.getElementById('map'), // 지도를 표시할 div 
    mapOption = { 
        center: new kakao.maps.LatLng(latitude, longitude), // 지도의 중심좌표
        level: 6 // 지도의 확대 레벨
    };
 
const map = new kakao.maps.Map(mapContainer, mapOption); // 지도를 생성합니다
 
// 마커가 표시될 위치입니다 
const markerPosition  = new kakao.maps.LatLng(latitude, longitude); 
 
// 마커를 생성합니다
marker = new kakao.maps.Marker({
    position: markerPosition
});
 
// 마커가 지도 위에 표시되도록 설정합니다
marker.setMap(map);
 
// 아래 코드는 지도 위의 마커를 제거하는 코드입니다
// marker.setMap(null); 
</script>

 

 

이 문제를 방지하려면 함수를 만들어서 구현하면 해결된다.

index.php 파일 하단에 DrawMap 함수를 추가한다.

<script>
function DrawMap(latitude,longitude){
    // 마커를 담을 배열입니다
    let marker = [];
 
    const mapContainer = document.getElementById('map'), // 지도를 표시할 div 
        mapOption = { 
            center: new kakao.maps.LatLng(latitude, longitude), // 지도의 중심좌표
            level: 6 // 지도의 확대 레벨
        };
 
    const map = new kakao.maps.Map(mapContainer, mapOption); // 지도를 생성합니다
 
    // 마커가 표시될 위치입니다 
    const markerPosition  = new kakao.maps.LatLng(latitude, longitude); 
 
    // 마커를 생성합니다
    marker = new kakao.maps.Marker({
        position: markerPosition
    });
 
    // 마커가 지도 위에 표시되도록 설정합니다
    marker.setMap(map);
 
    // 아래 코드는 지도 위의 마커를 제거하는 코드입니다
    // marker.setMap(null); 
    
</script>

 

그리고 View.php 파일은 아래와 같이 수정한다.

그러면 let 과 const 로 선언한 변수가 재선언되지 않기 때문에 문제가 해결된다.

<div class="card">
<div class="mt-1 mb-2 mx-2">
    <div id="map" style="width:100%;height:450px;"></div>
</div>
</div>
 
<script>
var latitude = '<?php echo $lat;?>';
var longitude = '<?php echo $lng;?>';
 
DrawMap(latitude,longitude);
</script>

 

블로그 이미지

Link2Me

,
728x90

플러터에서 전화걸기를 하는 기능을 구현해보고 있다.

 

IconButton(
  iconSize: 30,
  icon: Icon(Icons.call),
  onPressed: (){
    launchUrl(Uri.parse('tel:${widget.mobileNO}'));
  },
),

 

url_launcher 패키지를 사용하는 것은 Direct 로 전화걸기가 되지 않고 2단계로 거치는 불편함이 있다.

 

그래서 https://pub.dev/packages/flutter_phone_direct_caller 패키지를 이용하여 구현해봤다.

 

화면 전환이 발생하면서 로그에 이런 메시지를 출력한다.

MSG_WINDOW_FOCUS_CHANGED 1 1

이러면서 앱이 초기상태로 변경되어 로그인 창이 나온다.

로그인 후 상태관리로 로그인 상태 표시를 확실하게 해줘야 하는가보다.

 

전화거는 코드는 아래와 같다.

IconButton(
  iconSize: 30,
  onPressed: () async {
    FlutterPhoneDirectCaller.callNumber(Utils.PhoneNO(mobileNO));
  },
  icon: Icon(Icons.call),
),

 

전화버튼은 IconButton 이 가장 무난한 거 같다. 크기도 적당히 조절할 수도 있어 편리한 거 같다.

DirectCall 을 하면 화면 갱신이 발생하는 현상을 방지하기 위한 방안을 찾아보고, 나중에 업데이트하고자 한다.

 

 

블로그 이미지

Link2Me

,
728x90

플러터에서 사진촬영 또는 사진 이미지에서 위치좌표를 추출하는 방법을 알아보자.

 

사진을 찍을 때 위치좌표값이 포함되어 저장되도록 하는 방법은 카메라에서 위치정보를 활성화시켜야 한다.

아이폰

설정 → 개인정보 보호 및 보안 → 위치서비스 → 카메라 → 앱을 사용하는 동안

안드로이드폰

카메라 앱 → 카메라 설정 → 위치태그 활성화

 

위와 같이 하면 기본적으로 사진을 찍으면 이미지에 위치정보가 포함된다.

하지만 구현하는 앱에서 카메라로 촬영해도 위치정보가 포함되지 않을 수도 있다.

이 경우에는 사진을 찍는 시점의 GPS 위치정보를 기준으로 위치정보를 수집해야 한다.

 

pubspec.yaml 파일

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  dio: ^5.4.0
  retrofit: ^4.0.3
  flutter_riverpod: ^2.4.9
  json_annotation: ^4.8.1
  freezed_annotation: ^2.4.1
  permission_handler: ^11.2.0
  flutter_secure_storage: ^9.0.0
  image_picker: ^1.0.7
  exif: ^3.3.0
  geolocator: ^10.1.0
 
 
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  build_runner: ^2.4.8
  json_serializable: ^6.7.1
  freezed: ^2.4.6
  retrofit_generator: ^8.0.6

 

 

앨범 및 사진촬영한 이미지를 앱 화면에 보여주는 예제 코드

import 'dart:io';
 
import 'package:exif/exif.dart';
import 'package:fileupload/presentation/view/file_upload_screen.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:image_picker/image_picker.dart';
 
class ImageViewScreen extends StatefulWidget {
  const ImageViewScreen({super.key});
 
  @override
  State<ImageViewScreen> createState() => _ImageViewScreenState();
}
 
class _ImageViewScreenState extends State<ImageViewScreen> {
  XFile? _image; //이미지 담을 변수 선언
  double latitude = 0;
  double longitude = 0;
  bool isLocation = false;
 
  @override
  void initState() {
    super.initState();
    getLocationPermission();
  }
 
  Future<void> getLocationPermission() async {
    LocationPermission permission = await Geolocator.checkPermission();
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
    }
  }
 
  Future<void> getImage(ImageSource imageSource) async {
    var picker = ImagePicker();
    var pickerImage = await picker.pickImage(source: imageSource);
 
    // 이미지를 읽을 때마다 위치정보 좌표값 초기화 처리
    latitude = 0;
    longitude = 0;
 
    if (pickerImage != null) {
      var metadata = await readExifFromBytes(File(pickerImage.path).readAsBytesSync());
      if (metadata.containsKey('GPS GPSLatitude')) {
        print("location enabled");
        isLocation = true;
        getCurrentLocationFromexif(metadata);
      } else {
        isLocation = false;
        print("location disabled");
        try {
          Position position = await Geolocator.getCurrentPosition(
              desiredAccuracy: LocationAccuracy.high);
          latitude = position.latitude;
          longitude = position.longitude;
        } catch (e) {
          print(e);
        }
      }
      // 이미지를 출력하기 위해 상태 변경
      setState(() {
        _image = XFile(pickerImage.path);
      });
    }
  }
 
  Future<void> getCurrentLocationFromexif(var data) async {
    if (data.containsKey('GPS GPSLongitude')) {
      final gpsLatitude = data['GPS GPSLatitude'];
      final latitudeSignal = data['GPS GPSLatitudeRef']!.printable;
      List latitudeRation = gpsLatitude!.values.toList();
      List latitudeValue = latitudeRation.map((item) {
        return (item.numerator.toDouble() / item.denominator.toDouble());
      }).toList();
      latitude = latitudeValue[0+ (latitudeValue[1/ 60+
          (latitudeValue[2/ 3600);
      if (latitudeSignal == 'S') latitude = -latitude;
      print('latitude ::: ${latitude}');
 
      final gpsLongitude = data['GPS GPSLongitude'];
      final longitudeSignal = data['GPS GPSLongitude']!.printable;
      List longitudeRation = gpsLongitude!.values.toList();
      List longitudeValue = longitudeRation.map((item) {
        return (item.numerator.toDouble() / item.denominator.toDouble());
      }).toList();
      longitude = longitudeValue[0+ (longitudeValue[1/ 60+
          (longitudeValue[2/ 3600);
      if (longitudeSignal == 'W') longitude = -longitude;
      print('longitude ::: ${longitude}');
    }
  }
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        crossAxisAlignment: CrossAxisAlignment.center,
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          _buildPhotoArea(),
          if(latitude > 0) _buildLocation(),
        ],
      ),
      floatingActionButton: Stack(
        children: [
          Align(
            alignment: Alignment(
                Alignment.bottomRight.x, Alignment.bottomRight.y - 0.4),
            child: FloatingActionButton(
              onPressed: () {
                Navigator.of(context).push(MaterialPageRoute(
                  builder: (context) => const FileUploadScreen(),
                ));
              },
              tooltip: 'back',
              heroTag: UniqueKey(),
              child: const Icon(Icons.arrow_back),
            ),
          ),
          Align(
            alignment: Alignment(
                Alignment.bottomRight.x, Alignment.bottomRight.y - 0.2),
            child: FloatingActionButton(
              onPressed: () async {
                getImage(ImageSource.camera);
              },
              tooltip: 'image',
              heroTag: UniqueKey(),
              child: const Icon(Icons.camera_alt),
            ),
          ),
          Align(
            alignment: Alignment.bottomRight,
            child: FloatingActionButton(
              onPressed: () async {
                getImage(ImageSource.gallery);
              },
              tooltip: 'image',
              heroTag: UniqueKey(),
              child: const Icon(Icons.image),
            ),
          ),
        ],
      ),
    );
  }
 
  Widget _buildPhotoArea() {
    return _image != null
        ? Container(
            width: 400,
            height: 400,
            child: Image.file(File(_image!.path)), //가져온 이미지 화면에 띄워주는 코드
          )
        : const Center(
            child: Text("불러온 이미지가 없습니다."),
          );
  }
 
  Widget _buildLocation() {
    return Column(
      children: [
        Container(
          child: Text('GPS Location ::: $isLocation'),
        ),
        Container(
          child: Text('위도: ${latitude} ,경도: ${longitude}'),
        ),
      ],
    );
  }
 
}

 

 

위 소스코드를 보다 정리한 모든 소스코드 파일은 GitHub 에 올려두었다.

https://github.com/jsk005/Flutter/tree/main/fileupload/2nd

 

블로그 이미지

Link2Me

,
728x90

플러터에서 앱의 사진 이미지나 직접 촬영한 이미지를 서버에 업로드하는 예제이다.

이미지를 업로드하는 코드는 Retrofit 라이브러리를 이용하는 방법과 Dio 라이브러리에서 직접 업로드하는 방법이 있다.

두가지 사례를 모두 하나의 파일에 구현했다.

 

pubspec.yaml 파일

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  dio: ^5.4.0
  retrofit: ^4.0.3
  flutter_riverpod: ^2.4.9
  json_annotation: ^4.8.1
  freezed_annotation: ^2.4.1
  flutter_naver_map: ^1.1.2
  permission_handler: ^11.2.0
  flutter_secure_storage: ^9.0.0
  image_picker: ^1.0.7
 
 
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  build_runner: ^2.4.8
  json_serializable: ^6.7.1
  freezed: ^2.4.6
  retrofit_generator: ^8.0.6

 

 

ios > Runner > Info.plist 파일에 아래 키들을 추가해 준다.

<key>NSPhotoLibraryUsageDescription</key>
<string>This app requires access to the photo library.</string>
<key>NSCameraUsageDescription</key>
<string>This app requires access to the camera.</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require access to the microphone.</string>

 

 

Android
기존에는 AndroidManifest.xml에 권한을 추가해주어야 했지만 지금은 따로 추가하지 않아도 된다.
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
는 추가하지 않아도 된다.
image_picker 버전에 맞게 app build.gradle 파일내에서 
compileSdkVersion 34
minSdkVersion 23
으로 변경 요청해서 변경해줬다.

 

주요 소스코드

서버에서 POST 변수로 받는 항목이 userID, code, file 이다.

로그인 후 userID 정보, code 파일이 필요하다는 가정하에 항목을 넣었다.

 

Android폰에서는 카메라 촬영 이미지, 갤러리 이미지 모두 잘 가져와서 서버로 업로드 되는 걸 확인했다.

하지만 아이폰 15 Pro 에서 갤러리 이미지를 가져오려고 하면 에러가 발생하는 현상을 겪었다.

image_picker 버전 문제인지 여부는 아직 모르겠다. 위치정보 가져오기를 포함해서 테스트해보니 정상 동작된다.

그래서 카메라 영상을 직접 촬영하여 이미지를 업로드하는 방법으로 아이폰에서는 테스트를 했다.

서버에 업로드하는 이미지를 줄이기 위한 옵션은 선택하면 된다.

 

Retofit 라이브러리는 서버로 업로드하는 변수와 서버에서 결과를 Return 하는 JSON 변수를 파싱처리할 수 있게 할 수 있으나 본 예제에서는 그것까지는 처리하지 않았다. 향후 Riverpod 상태관리 코드 기준으로 합쳐서 작성하기 위해서 관련 사항을 염두에 두고 소스 일부를 구현했다. 현재 코드는 View 와 ViewModel 이 분리되어 있지 않다.

 

class RetrofitURL {
  static const baseUrl = "https://www.abc.com";
  static const mLogin = "/androidSample/loginChk.php";
  static const contactData = "/androidSample/ContactList.php";
  static const imgUpload = "/androidSample/photo_upload.php";
}

 

import 'dart:io';
 
import 'package:dio/dio.dart' hide Headers;
import 'package:fileupload/core/network/retrofit_url.dart';
import 'package:retrofit/retrofit.dart';
 
part 'rest_client.g.dart';
 
@RestApi(baseUrl: RetrofitURL.baseUrl)
abstract class RestClient {
  factory RestClient(Dio dio, {String baseUrl}) = _RestClient;
 
  @POST(RetrofitURL.imgUpload)
  @MultiPart()
  Future<String> uploadFile(
      @Part() File file,
      @Part(name: "userID"String userID,
      @Part(name: "code"String code,
      {@SendProgress() ProgressCallback? onSendProgress});
 
}

코드를 수정하면 터미널 창에서 dart run build_runner build 를 실행하여 업데이트 해야 한다.

 

import 'dart:io';
 
import 'package:dio/dio.dart';
import 'package:fileupload/core/network/custlog_interceptor.dart';
import 'package:fileupload/core/network/retrofit_url.dart';
import 'package:fileupload/data/provider/base_provider.dart';
import 'package:fileupload/data/repository/rest_client.dart';
import 'package:fileupload/presentation/view/imag_view_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
 
class FileUploadScreen extends ConsumerStatefulWidget {
  const FileUploadScreen({Key? key}) : super(key: key);
 
  @override
  ConsumerState<FileUploadScreen> createState() => _FileUploadScreenState();
}
 
class _FileUploadScreenState extends ConsumerState<FileUploadScreen> {
  String? _uploadProgress;
  RestClient? _apiService;
 
  @override
  void initState() {
    super.initState();
    var dio = Dio();
    _apiService = RestClient(dio);
  }
 
  Future<void> _uploadFile() async {
    final userID = "jsk005";
    final code = "XFD0001";
 
    final ImagePicker picker = ImagePicker();
    final XFile? pickedImage = await picker.pickImage(
      //이미지를 선택
      source: ImageSource.camera, // 위치는 카메라
      maxHeight: 200,
      //maxWidth: 200,
      //imageQuality: 70, // 이미지 크기 압축을 위해 퀄리티를 70으로 낮춤.
    );
 
    if (pickedImage == null) {
      // No image selected
      return null;
    }
 
    try {
      // Get the path of the selected image
      String imagePath = pickedImage.path;
 
      // Create a File object from the image path
      File imageFile = File(imagePath);
 
      print(imageFile);
 
      final response = await ref.read(restClientProvider).uploadFile(
        imageFile,
        userID,
        code,
        onSendProgress: (sent, total) {
          setState(() {
            _uploadProgress = "${(sent / total) * 100}%";
          });
        },
      );
 
      print(response);
    } catch (error) {
      // Handle error
      print('Error converting image picker result to File: $error');
    }
  }
 
  Future<void> uploadFile() async {
    final userID = "jsk005";
    final code = "XFD0001";
 
    final ImagePicker _picker = ImagePicker();
 
    // Select image from gallery
    XFile? pickedImage = await _picker.pickImage(
      //이미지를 선택
      source: ImageSource.gallery, //위치는 갤러리
      maxHeight: 200,
      //maxWidth: 200,
      //imageQuality: 70, // 이미지 크기 압축을 위해 퀄리티를 70으로 낮춤.
    );
 
    if (pickedImage != null) {
      try {
        String fileName = pickedImage.path
            .split('/')
            .last; // Extract the file name from its path
 
        // Create FormData object with the image file
        FormData formData = FormData.fromMap({
          'file': await MultipartFile.fromFile(pickedImage.path,
              filename: fileName),
          "userID": userID,
          "code": code,
        });
 
        final storage = ref.read(secureStorageProvider);
 
        // Create Dio instance
        BaseOptions options = BaseOptions(
          baseUrl: RetrofitURL.baseUrl,
        );
        Dio dio = Dio(options);
        dio.interceptors.add(LogInterceptor());
        dio.interceptors.add(
          CustLogInterceptor(storage: storage,),
        );
 
        // Send POST request with FormData
        final response = await dio.post(
          '${RetrofitURL.baseUrl}${RetrofitURL.imgUpload}',
          data: formData,
          options: Options(
            headers: {
              'Content-Type''multipart/form-data',
              // Add any additional headers required by your server
            },
          ),
        );
 
        final Map<String, dynamic> body = response.data;
        print('${body}');
 
      } catch (error) {
        // Handle error
        print('Error uploading file: $error');
      }
    }
  }
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: _uploadFile,
              child: Text("Upload File"),
            ),
            if (_uploadProgress != null)
              Text("Upload Progress: $_uploadProgress"),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.of(context).push(MaterialPageRoute(
            builder: (context) => const ImageViewScreen(),
          ));
        },
        tooltip: 'scan',
        child: const Icon(Icons.camera_alt),
      ),
    );
  }
}

 

 

 

테스트에 사용한 전체 소스코드 및 서버 PHP 파일(DB테이블 포함) 자료는

https://github.com/jsk005/Flutter/tree/main/fileupload 에 올려두었다.

블로그 이미지

Link2Me

,
728x90

플러터 버튼을 2개 이상 표시하는 방법이다.

 

floatingActionButton: Stack(
  children: [
    Align(
      alignment: Alignment(
          Alignment.bottomRight.x, Alignment.bottomRight.y - 0.4),
      child: FloatingActionButton(
        onPressed: () {
          Navigator.of(context).push(MaterialPageRoute(
            builder: (context) => const FileUploadScreen(),
          ));
        },
        tooltip: 'back',
        heroTag: UniqueKey(),
        child: const Icon(Icons.arrow_back),
      ),
    ),
    Align(
      alignment: Alignment(
          Alignment.bottomRight.x, Alignment.bottomRight.y - 0.2),
      child: FloatingActionButton(
        onPressed: () async {
          getImage(ImageSource.camera);
        },
        tooltip: 'image',
        heroTag: UniqueKey(),
        child: const Icon(Icons.camera_alt),
      ),
    ),
    Align(
      alignment: Alignment.bottomRight,
      child: FloatingActionButton(
        onPressed: () async {
          getImage(ImageSource.gallery);
        },
        tooltip: 'image',
        heroTag: UniqueKey(),
        child: const Icon(Icons.image),
      ),
    ),
  ],
),

 

 

블로그 이미지

Link2Me

,
728x90

플러터에서 QR코드를 스캔하는 코드 예제이다.

맥북에서 IOS 와 Android 모두 정상 동작함을 확인하고 기록한다.

 

pubspec.yaml 에 추가한 dependencies

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  dio: ^5.4.0
  retrofit: ^4.0.3
  flutter_riverpod: ^2.4.9
  json_annotation: ^4.8.1
  freezed_annotation: ^2.4.1
  flutter_naver_map: ^1.1.2
  permission_handler: ^11.2.0
  geolocator: ^10.1.0
  flutter_secure_storage: ^9.0.0
  qr_code_scanner: ^1.0.1
 
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  build_runner: ^2.4.8
  json_serializable: ^6.7.1
  freezed: ^2.4.6
  retrofit_generator: ^8.0.6

 

 

안드로이드 Manifest.xml 파일

QR코드 스캔을 위해서는 CAMERA 기능을 ON할 수 있어야 한다.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
    <uses-feature
        android:name="android.hardware.camera"
        android:required="false" />
 
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
 
    <application
        android:label="nmap_test"
        android:name="${applicationName}"

 

iOS 파일

Info.plist

    <key>NSCameraUsageDescription</key>
    <string>This app needs camera access to scan QR codes</string>

 

Podfile

        ## dart: PermissionGroup.camera
        'PERMISSION_CAMERA=1',

 

보다 세부적인  사항은 https://link2me.tistory.com/2381 파일내의 ios 부분을 참조하면 된다.

 

 

네이버 MAP에서 QR코드 버튼을 누르면 QR코드 스캔이 활성화되고 QR코드를 찍으면 해당 정보를 읽어낸다.

  @override
  Widget build(BuildContext context) {
    return DefaultLayout(
      title: 'Naver Map',
      appbarBgColor: Palette.primary,
      child: NaverMap(
        options: const NaverMapViewOptions(locationButtonEnable: true),
        onMapReady: onMapReady,
        onMapTapped: onMapTapped,
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.of(context).push(MaterialPageRoute(
            builder: (context) => const QRScanView(),
          ));
        },
        tooltip: 'scan',
        child: const Icon(Icons.camera_alt),
      ),
    );
  }
 
  void onMapReady(NaverMapController controller) async {
    mapController = controller;
    print('네이버맵 로딩됨');
  }
 
  void onMapTapped(NPoint point, NLatLng latLng) {
    // 터치를 하면 해당 좌표를 반환한다.
    print("${latLng.latitude}、${latLng.longitude}");
  }
 

 

 

QRScanView 파일

import 'dart:io';
 
import 'package:flutter/material.dart';
import 'package:nmap_test/core/component/default_layout.dart';
import 'package:nmap_test/core/res/palette.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
 
class QRScanView extends StatefulWidget {
  const QRScanView({super.key});
 
  @override
  State<QRScanView> createState() => _QRScanViewState();
}
 
class _QRScanViewState extends State<QRScanView> {
  final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
  Barcode? barcode;
  late QRViewController controller;
 
  @override
  void reassemble() {
    super.reassemble();
    if (Platform.isAndroid) {
      controller.pauseCamera();
    } else if (Platform.isIOS) {
      controller.resumeCamera();
    }
  }
 
  @override
  void dispose() {
    controller?.dispose();
    super.dispose();
  }
 
  void _onQRViewCreated(QRViewController controller) {
    this.controller = controller;
    controller.scannedDataStream.listen((scanData) async {
      setState(() {
        barcode = scanData;
      });
    });
  }
 
  @override
  Widget build(BuildContext context) {
    return DefaultLayout(
      title: 'QR Code Scanner',
        child: Stack(
          alignment: Alignment.center,
          children: [
            _buildQRView(context),
            Positioned(bottom: 15, child: _buildResult()),
          ],
        ),
    );
  }
 
  Widget _buildQRView(BuildContext context) {
    return QRView(
      key: qrKey,
      onQRViewCreated: _onQRViewCreated,
    );
  }
 
  Widget _buildResult() {
    if (barcode != null) {
      controller?.pauseCamera();
      Future.microtask(() {
        // Navigator.push(
        //     context,
        //     MaterialPageRoute(builder: (context) => ),
        // );
        controller?.resumeCamera();
      });
 
      return Center(
        child: Text(
          'QR Code Result: ${barcode!.code}',
          style: TextStyle(fontSize: 20),
        ),
      );
    } else {
      return Container();
    }
  }
 
}
 

 

QR코드 스캔 결과가 나오면 해당 스캔 값을 가지고 서버에 전송하고 결과를 받아서 처리할 수도 있다.

그래서 _buildResult() 위젯 결과를 실행해보고 수정하여 사용하면 된다.

 

 

 

 

 

블로그 이미지

Link2Me

,
728x90

하나의 클래스를 다른 클래스에 위임하도록 선언하여 위임된 클래스가 가지는 인터페이스 메소드를 참조 없이 호출할 수 있도록 생성해주는 기능이다.

 

interface Fruit를 구현하고 있는 class Apple이 있다면, 
Fruit에서 정의하고 있는 Apple의 모든 메소드를 클래스 GreenApple로 위임할 수 있다. 
즉, GreenApple은 Apple이 가지는 모든 Fruit의 메소드를 가지며, 이를 클래스 위임(Class delegation)이라고 한다.

interface Fruit {
    val name: String
    val color: String
    fun bite()
}
 
class Apple: Fruit {
    override val name: String
        get() = "사과"
    override val color: String
        get() = "빨간색"
 
    override fun bite() {
        print("사과 아삭~ 아삭~")
    }
}
 
// 클래스 위임 : 사용할 기능은 그대로 사용하고 새로 정의할 것만 override로 재정의
class GreenApple(
    private val apple: Apple
) : Fruit by apple {
    override val color: String
        get() = "초록색"
}

 

fun main() {
    val greenApple = GreenApple(Apple())
    println(greenApple.color)
    println(greenApple.name)
    greenApple.bite()
}

 

 

'안드로이드 > Kotlin 문법' 카테고리의 다른 글

코틀린 제네릭 공변, 반공변  (0) 2024.02.04
[코틀린] ArrayList, mutableListOf  (0) 2021.06.14
[코틀린] Inner Class  (0) 2020.08.21
[코틀린] Nested Class (중첩 클래스)  (0) 2020.08.21
코틀린 클래스  (0) 2020.05.06
블로그 이미지

Link2Me

,
728x90

Kotlin에서 List, Map, Set같은 Collections 클래스를 사용할 때, List<String> 이런식으로 제네릭 타입을 지정할 수 있다.

제네릭을 사용하면 타입 안정성을 얻을 수 있다.
하지만 제네릭으로 지정한 타입 이외에도 좀 더 유연하게 사용하고 싶을 수 있다.
그래서 코틀린에서는 공변성과 반공변성을 허용한다.

 

package generic
 
abstract class Animal(val name: String)
 
abstract class Fish(name: String) : Animal(name)
 
// 금붕어
class GoldFish(name: String) : Fish(name)
 
// 잉어
class Carp(name: String) : Fish(name)

 

package generic
 
fun main(){
    val goldFishCage = Cage<GoldFish>()
    goldFishCage.put(GoldFish("금붕어")) // Type Casting없이 바로 금붕어를 가져올 수 있다.
 
    val cage = Cage<Fish>()
    cage.moveFrom(goldFishCage)
 
    val fish: Fish = cage.getFirst()
    // Cage<Fish>에서 데이터를 가져오면 GoldFish인지 Carp인지 모른다.
}
 
/***
 * 제네릭 클래스 : 타입 파라미터를 사용한 클래스
 * 코틀린에서는 Raw 타입 사용이 불가능하다.
 * Raw Type : 제네릭 클래스에서 타입 매개변수를 사용하지 않고 인스턴스화 하는 것
 * in-variant(무공변) : 타입 파라미터끼리는 상속관계이더라도, 제네릭 클래스 간에는 상속관계가 없다는 의미
 * co-variant(공변) : 타입 파라미터간의 상속관계가 제네릭 클래스에도 동일하게 유지된다는 의미
 * 코틀린에서는 타입 파리미터 앞에 out 변성 어노테이션을 사용한다.
 * contra_variant(반공변) : 타입 파라미터간의 상속관계가 제네릭 클래스에서는 반대로 유지된다는 의미
 * 코틀린에서는 타입 파리미터 앞에 in 변성 어노테이션을 사용한다.
 */
class Cage<T : Animal> {
    private val animals: MutableList<T> = mutableListOf()
 
    fun getFirst() : T {
        return animals.first();
    }
 
    fun getAll() : List<T> {
        return  this.animals
    }
 
    fun put(animal: T) {
        this.animals.add(animal)
    }
 
    fun moveFrom(otherCage: Cage<out T>) {
        // out을 붙이면 moveFrom 함수를 호출할 때 Cage는 공변하게 된다.
        // out을 붙이면, otherCage로부터 데이터를 꺼낼 수만 있다.
        this.animals.addAll(otherCage.animals)
    }
 
    fun moveTo(otherCage: Cage<in T>){
        // in을 붙인 otherCage는 데이터를 받을 수만 있다.
        otherCage.animals.addAll(this.animals)
    }
}

 

 

 

'안드로이드 > Kotlin 문법' 카테고리의 다른 글

코틀린 클래스 위임(Class Delegation)  (1) 2024.02.06
[코틀린] ArrayList, mutableListOf  (0) 2021.06.14
[코틀린] Inner Class  (0) 2020.08.21
[코틀린] Nested Class (중첩 클래스)  (0) 2020.08.21
코틀린 클래스  (0) 2020.05.06
블로그 이미지

Link2Me

,
728x90

Macbook에서 아이폰과 USB 케이블 연결하여 컴파일을 하면 앱 실행이 잘 되는데, 케이블을 제거하면 앱 실행이 안되는 현상을 겪었다.

아이폰과 안드로이드폰 2개 모두 USB 케이블로 연결하고 테스트했다.

 

해결방법 : flutter run --release

 

컴파일을 하다보니 에러가 발생하는 현상이 있어서 다른 것도 추가하여 기록한다.

블로그 이미지

Link2Me

,
728x90

예제를 찾아서 테스트를 하다보면 Android폰에서는 잘 동작되는데, 아이폰에서는 동작이 안되기도 한다.

그래서 양쪽에서 모두 동작하는 걸 찾아서 테스트하기가 쉽지 않다.

location 라이브러리를 추가해서 테스트해보니 Android폰에서는 잘 동작하는데 ios 에서는 설정을 잘못했는지 에러가 발생하여 포기했다. 그리고 geolocator 라이브러리를 활용하여 구현했다.

잘 동작되기는 하는데, onMapReady 보다 늦게 결과가 나와 화면에서 바로 현재 위치로 이동시키는 것은 아직 미해결 상태다.

 

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  cupertino_icons: ^1.0.2
  dio: ^5.4.0
  json_annotation: ^4.8.1
  freezed_annotation: ^2.4.1
  retrofit: ^4.0.3
  logger: ^2.0.2+1
  flutter_riverpod: ^2.4.9
  flutter_naver_map: ^1.1.2
  geolocator: ^10.1.0
  permission_handler: ^11.2.0
  flutter_secure_storage: ^4.2.1
 
 
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  json_serializable: ^6.7.1
  build_runner: ^2.4.8
  freezed: ^2.4.6
  retrofit_generator: ^8.0.6

 

AndroidManifest.xml 추가사항

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
 
    <application

 

ios 설정 추가사항은 https://link2me.tistory.com/2381 게시글의 ios를 참조하면 된다.

 

import 'package:geolocator/geolocator.dart';
 
class LocationService {
  double latitude = 0;
  double longitude = 0;
 
  Future<void> getCurrentLocation() async {
    LocationPermission permission = await Geolocator.checkPermission();
    // print(permission);
    if (permission == LocationPermission.denied) {
      permission = await Geolocator.requestPermission();
    }
    try {
      Position position = await Geolocator.getCurrentPosition(
          desiredAccuracy: LocationAccuracy.high);
      latitude = position.latitude;
      longitude = position.longitude;
    } catch (e) {
      print(e);
    }
  }
}

 

 

import 'package:flutter/material.dart';
import 'package:flutter_naver_map/flutter_naver_map.dart';
import 'package:geolocator/geolocator.dart';
import 'package:location/location.dart';
import 'package:nmap_test/core/component/default_layout.dart';
import 'package:nmap_test/core/res/palette.dart';
import 'package:nmap_test/presentation/view/location_service.dart';
import 'package:permission_handler/permission_handler.dart';
 
class NaverMapScreen extends StatefulWidget {
  const NaverMapScreen({Key? key}) : super(key: key);
 
  @override
  State<NaverMapScreen> createState() => _NaverMapScreenState();
}
 
class _NaverMapScreenState extends State<NaverMapScreen> {
  final location = LocationService();
 
  void _getLocation() async {
    await location.getCurrentLocation();
    double lat = location.latitude;
    double lng = location.longitude;
    print('lat:: $lat  lng::: $lng');
  }
 
  @override
  void initState() {
    super.initState();
    _permission();
    WidgetsBinding.instance.addPostFrameCallback((_) {
     _getLocation();
    });
  }
 
  void _permission() async {
    var requestStatus = await Permission.location.request();
    var status = await Permission.location.status;
    if (requestStatus.isPermanentlyDenied || status.isPermanentlyDenied) {
      openAppSettings();
    }
  }
 
  @override
  Widget build(BuildContext context) {
    return DefaultLayout(
      title: 'Naver Map',
      appbarBgColor: Palette.primary,
      child: NaverMap(
          options: const NaverMapViewOptions(locationButtonEnable: true),
          onMapReady: (controller) {
            print('네이버맵 로딩됨');
          },
        ),
      );
  }
}

 

 

 

Android 에서만 동작이 되는 걸 성공한 코드도 적어둔다.

location: ^5.0.3

class _NaverMapScreenState extends State<NaverMapScreen> {
  final Location location = Location();
  late double lat;
  late double lng;
  late LocationData _locationData;
  late bool _serviceEnabled;
 
  void _getCurrentLocation() async {
    _serviceEnabled = await location.serviceEnabled();
    if (!_serviceEnabled) {
      _serviceEnabled = await location.requestService();
      if (!_serviceEnabled) {
        return;
      }
    }
 
    _locationData = await location.getLocation();
    setState(() {
      lat = _locationData.latitude!;
      lng = _locationData.longitude!;
    });
    print('lat:: $lat  lng::: $lng');
  }
 
  @override
  void initState() {
    super.initState();
    _permission();
    _getCurrentLocation();
  }
 
  void _permission() async {
    var requestStatus = await Permission.location.request();
    var status = await Permission.location.status;
    if (requestStatus.isPermanentlyDenied || status.isPermanentlyDenied) {
      openAppSettings();
    }
  }
 

 

 

 

블로그 이미지

Link2Me

,
728x90

1. 네이버 지도 띄우기 위한 첫번째 단계

    flutter pub add flutter_naver_map permission_handler 를 터미널 창에서 실행하여 dependencies 에 추가한다.

 

2. 프로젝트명 생성

import 'package:flutter/material.dart';
import 'package:flutter_naver_map/flutter_naver_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:nmap_test/presentation/view/naver_map_view.dart';
 
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
 
  await NaverMapSdk.instance.initialize(
      clientId: 'z4t12347e4',
      onAuthFailed: (error) {
        print('Auth failed: $error'); // 인증 실패시 메시지 출력
      });
 
  runApp(
    ProviderScope(
      child: const MyApp(),
    ),
  );
}
 
class MyApp extends StatelessWidget {
  const MyApp({super.key});
 
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'NaverMap View',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: NaverMapScreen(),
    );
  }
}
 

 

clientId 는 임의로 변경했기 때문에 그대로 넣으면 동작되지 않는다.

각자 ncloud.com 에 등록해서 사용해야 동작된다.

 

import 'package:flutter/material.dart';
import 'package:flutter_naver_map/flutter_naver_map.dart';
import 'package:nmap_test/core/component/default_layout.dart';
import 'package:nmap_test/core/res/palette.dart';
import 'package:permission_handler/permission_handler.dart';
 
class NaverMapScreen extends StatefulWidget {
  const NaverMapScreen({Key? key}) : super(key: key);
 
  @override
  State<NaverMapScreen> createState() => _NaverMapScreenState();
}
 
class _NaverMapScreenState extends State<NaverMapScreen> {
 
  @override
  void initState() {
    super.initState();
    _permission();
  }
 
  void _permission() async {
    var requestStatus = await Permission.location.request();
    var status = await Permission.location.status;
    if (requestStatus.isPermanentlyDenied || status.isPermanentlyDenied) {
      openAppSettings();
    }
  }
 
  @override
  Widget build(BuildContext context) {
    return DefaultLayout(
      title: 'Naver Map',
      appbarBgColor: Palette.primary,
      child: NaverMap(
          options: const NaverMapViewOptions(locationButtonEnable: true),
          onMapReady: (controller) {
            print('네이버맵 로딩됨');
          },
        ),
      );
  }
}
 

locationButtonEnable: true 로 설정하여 지도 왼쪽 하단에 현재 위치로 이동하는 버튼이다.

위험권한을 추가하여 현재 위치 파악을 할 수 있다.

 

 

3. ncloud.com 에 앱 등록

   https://link2me.tistory.com/1832 를 참조하여 앱을 등록한다.

 

4. Android 설정

- build.gradle

  compileSdkVersion 34

  minSdkVersion 23 으로 변경

android {
    namespace "com.link2me.flutter.nmap_test"
    compileSdkVersion 34
    ndkVersion flutter.ndkVersion
 
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
 
    kotlinOptions {
        jvmTarget = '1.8'
    }
 
    sourceSets {
        main.java.srcDirs += 'src/main/kotlin'
    }
 
    defaultConfig {
        applicationId "com.link2me.flutter.nmap_test"
        minSdkVersion 23
        targetSdkVersion flutter.targetSdkVersion
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
    }
 
    buildTypes {
        release {
            signingConfig signingConfigs.debug
        }
    }
}

 

- AndroidManifest.xml 파일 추가 사항

  퍼미션 추가 및 meta-data 추가

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
 
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
 
    <application
        android:label="nmap_test"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
 
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
 
        <meta-data
            android:name="com.naver.maps.map.CLIENT_ID"
            android:value="z4t12347e4" />
    </application>
</manifest>

 

 

5. ios 설정

  기본 테스트는 Windows 환경에서 하고, 나중에 맥북에서 ios 테스트를 하는 순서로 진행 한다.

  아이폰에서 단순 지도 띄우는 것은 추가 사항 없어도 동작됨을 확인했다.

  현재 위치 정보를 가져오기 위해서는 권한 설정이 필요하다.

 

Info.plist 하단 dict안에 추가할 사항

1
2
3
4
5
6
7
8
9
10
    <!-- Permission options for the `location` group -->
    <key>NSLocationWhenInUseUsageDescription</key>
    <string>Need location when in use</string>
    <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
    <string>Always and when in use!</string>
    <key>NSLocationUsageDescription</key>
    <string>Older devices need location.</string>
    <key>NSLocationAlwaysUsageDescription</key>
    <string>Can I have location always?</string>

 

 

Podfile 추가사항

파일 하단에서 아래 부분에 코드를 추가한다.

권한설정은 Info.plist 와 Podfile 을 모두 설정해야 정상 동작되고, 앱에 권한 설정하는 기능이 추가된다.

post_install do |installer|
  installer.pods_project.targets.each do |target|
    flutter_additional_ios_build_settings(target)
 
    target.build_configurations.each do |config|
      # You can remove unused permissions here
      # for more information: https://github.com/BaseflowIT/flutter-permission-handler/blob/master/permission_handler/ios/Classes/PermissionHandlerEnums.h
      # e.g. when you don't need camera permission, just add 'PERMISSION_CAMERA=0'
      config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
        '$(inherited)',
 
        ## dart: PermissionGroup.calendar
        #'PERMISSION_EVENTS=1',
 
        ## dart: PermissionGroup.calendarFullAccess
        #'PERMISSION_EVENTS_FULL_ACCESS=1',
 
        ## dart: PermissionGroup.reminders
        #'PERMISSION_REMINDERS=1',
 
        ## dart: PermissionGroup.contacts
        #'PERMISSION_CONTACTS=1',
 
        ## dart: PermissionGroup.camera
        #'PERMISSION_CAMERA=1',
 
        ## dart: PermissionGroup.microphone
        #'PERMISSION_MICROPHONE=1',
 
        ## dart: PermissionGroup.speech
        #'PERMISSION_SPEECH_RECOGNIZER=1',
 
        ## dart: PermissionGroup.photos
        #'PERMISSION_PHOTOS=1',
 
        ## dart: [PermissionGroup.location, PermissionGroup.locationAlways, PermissionGroup.locationWhenInUse]
        'PERMISSION_LOCATION=1',
 
        ## dart: PermissionGroup.notification
        #'PERMISSION_NOTIFICATIONS=1',
 
        ## dart: PermissionGroup.mediaLibrary
        #'PERMISSION_MEDIA_LIBRARY=1',
 
        ## dart: PermissionGroup.sensors
        #'PERMISSION_SENSORS=1',
 
        ## dart: PermissionGroup.bluetooth
        #'PERMISSION_BLUETOOTH=1',
 
        ## dart: PermissionGroup.appTrackingTransparency
        #'PERMISSION_APP_TRACKING_TRANSPARENCY=1',
 
        ## dart: PermissionGroup.criticalAlerts
        #'PERMISSION_CRITICAL_ALERTS=1',
 
        ## dart: PermissionGroup.criticalAlerts
        #'PERMISSION_ASSISTANT=1',
      ]
 
    end
  end
end
 
 

 

복사가 잘 안되면 https://github.com/Baseflow/flutter-permission-handler/blob/main/permission_handler_apple/example/ios/Podfile 파일을 참조하여 내용을 추가하고 필요없는 권한은 주석처리(#)한다.

 

블로그 이미지

Link2Me

,
728x90

네이버 지도를 아이폰에서 띄우는 걸 테스트 해보다가 완전 맨붕에 빠졌다.

Android 폰에서는 잘 동작하는데, iOS 환경에서 네이버 지도가 화면에 나오지 않고, 에러 메시지는 다음과 같이 나온다.

 

******** Authorize Error : 잘못된 클라이언트 ID를 지정. 콘솔에서 앱 Bundle Identifier를 잘못 등록함
[ERROR:flutter/shell/common/shell.cc(1015)] The 'flutter_naver_map_sdk' channel sent a message from native to Flutter on a non-platform thread. Platform channel messages must be sent on the platform thread. Failure to do so may result in data loss or crashes, and must be fixed in the plugin or application code creating that channel.
See https://docs.flutter.dev/platform-integration/platform-channels#channels-and-platform-threading for more information.
flutter: Auth failed: NAuthFailedException(code: 401, message: 잘못된 클라이언트 ID를 지정. 콘솔에서 앱 Bundle Identifier를 잘못 등록함)

 

문제가 된 사항부터 파악하기 위해 순차적으로 적어보자.

 

https://console.ncloud.com/   에 등록할 때부터 주의를 해야 한다는 걸 확인했다.

안드로이드에서는 com.link2me.flutter.nmap_test 로 등록하면 정상적으로 네이버 지도가 폰에 출력된다.

하지만, 아이폰에서는 네이버 지도가 화면에 출력되지 않으면서, 위와 같은 에러메시지가 나온다.

아예 언더바를 사용하지 않고 테스트를 해봤더니 정상적으로 네이버지도가 출력되는 걸 확인했다.

그래서 다시 com.link2me.flutter.nampTest 로 변경하여 등록했더니 네이버지도가 출력된다.

이것 때문에 몇시간을 삽질했는지 모르겠다. ㅠㅠㅠ

 

콘솔창에서 open ios/Runner.xcworkspace 를 하면 Xcode 가 실행된다.

여기서 보면 Bundle Identifier 가 자동으로 변경해준 것을 확인할 수 있다.

이 identifier 를 ncloud.com 에 등록해야 한다.

 

위 이미지에 보이는 Bundle Identifier 를 앱에서 찾는 방법

ios/Runner.xcodeproj/project.pbxproj 파일을 열어서 PRODUCT_BUNDLE_IDENTIFIER 를 검색한다.

그러면 PRODUCT_BUNDLE_IDENTIFIER = com.link2me.flutter.nmapTest; 와 같이 검색된 것을 확인할 수 있다.

 

 

 

아이폰 설정 사항

설정 앱 -> 개발자 -> 신뢰하는 컴퓨터 지우기

이 컴퓨터를 신뢰하겠습니까? 신뢰 선택 -> 설정한 폰 비밀번호 입력

 

관련이 있을지 없을지 여부는 모르겠는데 나중에 찾는데 도움될 거 같아서 적어둔다.

https://hsdev.tistory.com/entry/iOS-%EC%95%B1-%EB%B0%B0%ED%8F%AC-2-Identifiers-%EC%8B%9D%EB%B3%84%EC%9E%90-App-ID-%EB%93%B1%EB%A1%9D%ED%95%98%EA%B8%B0

 

[iOS 앱 배포] 2. Identifiers (식별자) App ID 등록하기

애플 개발자 계정에서 App ID 를 등록하는 과정입니다. 1. https://developer.apple.com/ 에 들어간다. (Account > Certificates, IDs & Profiles > Identifiers) 클릭한다. Apple Developer Submit your apps today. Build your apps using Xcod

hsdev.tistory.com

 

 

블로그 이미지

Link2Me

,
728x90

News App 은 android 로 구현 테스트 해본 적이 있다.

riverpod 기능을 좀 더 익히기 위해서 검색해보니 https://www.youtube.com/watch?v=HgvKWMrbBe4 에 자료가 있더라.

원 코드 그대로 구현한 것은 아니며, Retrofit 라이브러리 사용, 디렉토리 구조는 Clean Architecture 로의 전환을 위한 걸 고려중이고, WebView 도 추가하여 실제 게시글로 이동하여 볼 수 있도록 했다.

 

NewsDataResult 클래스로 정의한 아래 코드가 동영상에서는 NewsModel 클래스로 정의되어 있다.

NewsDataResultBase 클래스는 테스트해보니 굳이 정의하지 않아도 되는 거 같다.

NewsDataResult.fromJson 부분이 구현의 핵심이다.

import 'package:news_app_riverpod/domain/model/news_data.dart';
 
sealed class NewsDataResultBase {}
 
class NewsDataResultLoading extends NewsDataResultBase {}
 
class NewsDataResultError extends NewsDataResultBase {
  final String errMsg;
 
  NewsDataResultError({
    required this.errMsg,
  });
}
 
class NewsDataResult extends NewsDataResultBase {
  String? status;
  int? totalResults;
  List<NewsData>? articles;
 
  NewsDataResult({
    this.status,
    this.totalResults,
    this.articles,
  });
 
  NewsDataResult.fromJson(Map < String, dynamic > json) {
    status = json['status'];
    totalResults = json['totalResults'];
    if (json['articles'!= null) {
      articles = <NewsData> [];
      json['articles'].forEach((v) {
        articles!.add(NewsData.fromJson(v));
      });
    }
  }
}

 

factory 생성자로 구현시 오류 발생 여부를 좀 더 테스트 해본 결과 정상동작됨을 확인했다.

Class 명을 NewsModel 로 변경해서 테스트를 했다. 이 사항도 GitHub 에 3rd버전으로 반영했다.

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:news_app_riverpod/domain/models/news_data.dart';
 
part 'news_model.g.dart';
 
sealed class NewsModelBase {}
 
class NewsDataResultLoading extends NewsModelBase {}
 
class NewsModelError extends NewsModelBase {
  final String errMsg;
 
  NewsModelError({
    required this.errMsg,
  });
}
 
@JsonSerializable()
class NewsModel extends NewsModelBase {
  String? status;
  int? totalResults;
  List<NewsData>? articles;
 
  NewsModel({
    this.status,
    this.totalResults,
    this.articles,
  });
 
  factory NewsModel.fromJson(Map<String, dynamic> json) => _$NewsModelFromJson(json);
 
  Map<String, dynamic> toJson() => _$NewsModelToJson(this);
 
  /*
  NewsModel.fromJson(Map < String, dynamic > json) {
    status = json['status'];
    totalResults = json['totalResults'];
    if (json['articles'] != null) {
      articles = <NewsData> [];
      json['articles'].forEach((v) {
        articles!.add(NewsData.fromJson(v));
      });
    }
  }
  */
}

 

 

 

Retrofit 라이브러리를 활용하면 코드를 더 심플하게 줄일 수 있다(?).

import 'package:dio/dio.dart' hide Headers;
import 'package:retrofit/retrofit.dart';
import 'package:news_app_riverpod/domain/model/news_data_result.dart';
import 'package:news_app_riverpod/data/repository/retrofit_url.dart';
 
part 'rest_client.g.dart';
 
@RestApi(baseUrl: RetrofitURL.baseUrl)
abstract class RestClient {
  factory RestClient(Dio dio, {String baseUrl}) = _RestClient;
 
  @GET(RetrofitURL.fetchNews)
  Future<NewsDataResult> fetchNews();
 
  @GET(RetrofitURL.fetchNewsBySearching)
  Future<NewsDataResult> fetchNewsBySearching(@Path() String title);
}

 

 

repository 정의 및 repositoryImpl 구현  => GitHub 마지막 버전에서는 삭제 처리

import 'package:news_app_riverpod/domain/model/news_data_result.dart';
 
abstract class NewsRepository {
  Future<NewsDataResult> fetchNews();
  Future<NewsDataResult> fetchNewsBySearching(String title);
}
 
/***
* Retrofit 라이브러리를 이용하여 구현하면 별도의 함수 정의는 하지 않아야 된다.
 */
 

 

 
import 'package:dio/dio.dart';
import 'package:news_app_riverpod/core/utils/app_utils.dart';
import 'package:news_app_riverpod/data/repository/retrofit_url.dart';
import 'package:news_app_riverpod/domain/model/news_data_result.dart';
import 'package:news_app_riverpod/domain/repository/news_repository.dart';
 
class NewsRepositoryImpl extends NewsRepository {
  final Dio _dio = Dio(
      BaseOptions(
          baseUrl: RetrofitURL.baseUrl,
          responseType: ResponseType.json
      )
  );
 
  @override
  Future<NewsDataResult> fetchNewsBySearching(String title) async {
    final response = await _dio.get('/v2/everything?q='+title+'&apiKey=${AppUtils.newsKey}');
    //print(response.data);
    final newsDataResult = NewsDataResult.fromJson(response.data);
    return newsDataResult;
  }
 
  @override
  Future<NewsDataResult> fetchNews() async {
    final response = await _dio.get('/v2/top-headlines?country=kr&category=science&apiKey=${AppUtils.newsKey}');
    final newsDataResult = NewsDataResult.fromJson(response.data);
    return newsDataResult;
  }
}

 

 

newsProvider 코드

 
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:news_app_riverpod/data/datasource/news_repositoryimpl.dart';
import 'package:news_app_riverpod/data/provider/base_provider.dart';
import 'package:news_app_riverpod/domain/model/news_data_result.dart';
 
part 'news_provider.freezed.dart';
 
@freezed
class NewsState with _$NewsState {
  factory NewsState({
    @Default(true) bool isLoading,
    required NewsDataResult newsDataResult,
  }) = _NewsState;
 
  const NewsState._();
}
 
final newsProvider = NotifierProvider<NewsNotifier, NewsState>(NewsNotifier.new);
 
class NewsNotifier extends Notifier<NewsState> {
  @override
  NewsState build() {
    return NewsState(newsDataResult: NewsDataResult(articles: []));
  }
 
  void loadNews() async {
    state = state.copyWith(isLoading: true);
    NewsDataResult resp = await ref.read(restClientProvider).fetchNews();
    state = state.copyWith(newsDataResult: resp, isLoading: false);
  }
 
  void loadSearchedNews(String title) async {
    state = state.copyWith(isLoading: true);
    //final newsResponse = await NewsRepositoryImpl().fetchNewsBySearching(title);
    NewsDataResult newsResponse = await ref.read(restClientProvider).fetchNewsBySearching(title);
    state = state.copyWith(newsDataResult: newsResponse, isLoading: false);
  }
 
}

 

 

위 전체코드는 GitHub 에 올려두었다.

https://github.com/jsk005/Flutter/tree/main/news_app_riverpod

 

 

 

블로그 이미지

Link2Me

,
728x90

인터넷(Network)을 통해서 전송된 JSON 포맷 문자열을 Custom Class 로 역직렬화(Deserialization)해서 사용자 클래스로 변환하는 과정을 거쳐야 한다.
사용자 클래스를 네트웍을 통해 전송하기 위해서는 문자열로 변환을 해야 한다.
과거에는 XML 이라는 포멧으로 전송 했으나, 현재는 JSON 포멧 문자열로 전송한다.

Dart 언어에서 역직렬화(Deserialization)하는 과정은 
ㅇ JSON 포맷 String → Map<String, dynamic>
ㅇ Map<String, dynamic> → Custom Class
2단계 과정을 거친다.

Custom Class를 JSON 포멧 문자열로 직렬화(Serialization)하는 과정
ㅇ Custom Class → Map<String, dynamic>
ㅇ Map<String, dynamic> → JSON 포맷 String
2단계 과정을 거친다.

먼저 서버에 있는 JSON 포멧 데이터를 역직렬화하기 위한 Model Class 를 구현해야 한다.

구현 방법은 Model 클래스를 직접 구현하는 방법과 Code generation 라이브러리를 이용하는 방법이 있다.

1. 직접 구현 방법

- 모델 클래스 내부에 fromJson과 toJson을 정의한다.

class LoginResponse {
  final String status;
  final String message;
  final UserInfo? userinfo;
 
  const LoginResponse({
    required this.status,
    required this.message,
    this.userinfo,
  });
 
  factory LoginResponse.fromJson(Map<String, dynamic> json) {
    return LoginResponse(
      status: json['status'] as String,
      message: json['message'] as String,
      userinfo: json['userinfo'== null
          ? null
          : UserInfo.fromJson(json['userinfo'] as Map<String, dynamic>),
    );
  }
 
  Map<String, dynamic> toJson() {
    return {
      "status"this.status,
      "message"this.message,
      "userinfo"this.userinfo,
    };
  }
}
 
class UserInfo {
  final String userNM;
  final String mobileNO;
  final int profileImg;
 
  const UserInfo({
    required this.userNM,
    required this.mobileNO,
    required this.profileImg,
  });
 
  factory UserInfo.fromJson(Map<String, dynamic> json) {
    return UserInfo(
      userNM: json['userNM'] as String,
      mobileNO: json['mobileNO'] as String,
      profileImg: json['profileImg'] as int,
    );
  }
 
  Map<String, dynamic> toMap() {
    return {
      'userNM'this.userNM,
      'mobileNO'this.mobileNO,
      'profileImg'this.profileImg,
    };
  }
 
}

 

2. Code generation 라이브러리 이용방법

- 라이브러리 설치

flutter pub add json_annotation
flutter pub add -d build_runner json_serializable

 

- 모델 클래스 내부에 fromJson과 toJson을 아래와 같은 규칙으로 정의한다.

import 'package:json_annotation/json_annotation.dart';
 
part 'userinfo.g.dart';
 
@JsonSerializable()
class UserInfo {
  final String userNM;
  final String mobileNO;
  final int profileImg;
 
  const UserInfo({
    required this.userNM,
    required this.mobileNO,
    required this.profileImg,
  });
 
  factory UserInfo.fromJson(Map<String, dynamic> json) => _$UserInfoFromJson(json);
 
  Map<String, dynamic> toJson() => _$UserInfoToJson(this);
}
 

 

import 'package:json_annotation/json_annotation.dart';
import 'package:login_ex/user/model/userinfo.dart';
 
part 'login_response.g.dart';
 
@JsonSerializable()
class LoginResponse {
  final String status;
  final String message;
  final UserInfo? userinfo;
 
  const LoginResponse({
    required this.status,
    required this.message,
    this.userinfo,
  });
 
  factory LoginResponse.fromJson(Map<String, dynamic> json) => _$LoginResponseFromJson(json);
 
  Map<String, dynamic> toJson() => _$LoginResponseToJson(this);
}
 

 

와 같이 한 다음에 터미널 창에서 dart run build_runner build 를 하면 자동으로 추가된다.

 

 

  /// 네트워크 응답 문자열
  String jsonString = '{"name": "길동", "age": 33}';
 
  /// JSON 포맷 String -> Map<String, dynamic>
  Map<String, dynamic> jsonMap = jsonDecode(jsonString);
  print(jsonMap);
 
  /// Map<String, dynamic> -> Person
  Person person = Person.fromJson(jsonMap);
  print(person);

 

일반적인 JSON decoding에는 dart:convert의 jsonDecode() 메서드를 사용한다. 

이 방법은 간단하고 빠르게 decoding할 수 있다는 장점이 있지만 decoding된 Map<String, dynamic> 타입을 다시 적절한 Data Type으로 변환하는 부분을 수작업으로 진행해야 한다.

 

하지만 위와 같은 과정을 처리하지 않고,

서버로 데이터를 보내고 결과를 응답받는 과정을 손쉽게 처리하는 방법은 Retrofit 라이브러리를 이용하면 된다.

import 'package:dio/dio.dart' hide Headers;
import 'package:retrofit/retrofit.dart';
import 'package:login_ex/common/repository/retrofit_url.dart';
import 'package:login_ex/contact/model/contact_result.dart';
import 'package:login_ex/user/model/login_response.dart';
 
part 'rest_client.g.dart';
 
@RestApi(baseUrl: RetrofitURL.baseUrl)
abstract class RestClient {
  factory RestClient(Dio dio, {String baseUrl}) = _RestClient;
 
  @POST(RetrofitURL.mLogin)
  @FormUrlEncoded()
  Future<LoginResponse> userLogin(
      @Field() String keyword,
      @Field() String userID,
      @Field() String password,
      @Field() String uID,
      @Field() String mfoneNO,
      );
}

 

Retrofit 라이브러리 동작과정에 대한 설명은 Android 에서 설명한 게시글이 있으니 참고하면 도움된다.

로그인에 필요한 데이터를 POST 방식으로 전송하고, 결과를 LoginResponse 로 받는다.

 

riverpod 상태관리 패키지 활용 예제

- Dio 라이브러리와 Retrofit 을 처리하는 RestClient 를 보고 활용하면 된다.

- CustLogInterceptor는 필요하면 추가하지만 필요없는 경우에는 추가할 필요가 없다.

- 서버에서 로그인 정보가 일치하면 Session 정보를 생성하고 생성된 Session 정보를 쿠키로 저장하고 다른 URL 전송시 Cookie 정보를 실어서 보내도록 처리하는 로직을 CustLogInterceptor에 구현했다.

import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:login_ex/common/repository/custlog_interceptor.dart';
import 'package:login_ex/common/repository/rest_client.dart';
 
final secureStorageProvider =
    Provider<FlutterSecureStorage>((ref) => const FlutterSecureStorage());
 
final dioProvider = Provider<Dio>((ref) {
    final dio = Dio();
 
    final storage = ref.read(secureStorageProvider);
 
    dio.interceptors.add(LogInterceptor());
    dio.interceptors.add(
        CustLogInterceptor(storage: storage,),
    );
    return dio;
});
 
final restClientProvider = Provider<RestClient>((ref) {
    final dio = ref.read(dioProvider);
    final repository = RestClient(dio);
    return repository;
});

 

 

 

LoginView 처리는 View 와 ViewModel를 분리하여 구현한다.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:login_ex/common/view/base_view.dart';
import 'package:login_ex/user/view/login_view_model.dart';
 
class LoginView extends ConsumerWidget {
  String userid = '';
  String password = '';
 
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return BaseView(
      viewModelProvider: loginViewModelProvider,
      builder: (ref, viewModel, state) => DefaultLayout(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 16.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              const SizedBox(height: 50.0),
              Image.asset(
                'assets/img/logo/logo.png',
                width: MediaQuery.of(context).size.width,
              ),
              const SizedBox(height: 16.0),
              CustomTextFormField(
                hintText: '아이디를 입력하세요',
                obscureText: false,
                onChanged: (String value) {
                  userid = value.trim();
                },
              ),
              const SizedBox(height: 16.0),
              CustomTextFormField(
                hintText: '비밀번호를 입력하세요',
                obscureText: true,
                onChanged: (String value) {
                  password = value.trim();
                },
              ),
              const SizedBox(height: 16.0),
              ElevatedButton(
                onPressed: () => viewModel.login(context, userid, password),
                style: ElevatedButton.styleFrom(
                  backgroundColor: PRIMARY_COLOR,
                ),
                child: const Text(
                  '로그인',
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
 

 

final loginViewModelProvider =
    NotifierProvider.autoDispose<LoginViewModel, LoginViewState>(
        LoginViewModel.new);
 
class LoginViewModel extends BaseViewModel<LoginViewState> {
  @override
  LoginViewState build() {
    return LoginViewState(isBusy: false);
  }
 
  Future<void> login(
      BuildContext context, String userid, String password) async {
    if (userid.isNotEmpty && password.isNotEmpty) {
      state = state.copyWith(isBusy: true);
 
      final storage = ref.read(secureStorageProvider);
      final mobileNO = await storage.read(key: MNumber) ?? '';
      final deviceId = await storage.read(key: DeviceID) ?? '';
 
      LoginResponse result = await ref.read(restClientProvider).userLogin(
          Crypto.AES_encrypt(Crypto.URLkey()),
          Crypto.AES_encrypt(userid),
          Crypto.RSA_encrypt(password),
          deviceId,
          mobileNO);
 
      if (result.status.contains('success')) {
        Utils.showSnackBar(context, '로그인 성공');
 
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (_) => const ContactResultProviderPage(),
          ),
        );
        state = state.copyWith(isBusy: false);
      } else {
        Utils.showAlert(context, result.status, result.message);
        state = state.copyWith(isBusy: false);
      }
    } else {
      if (userid.isEmpty) {
        Utils.showSnackBar(context, '아이디를 입력하세요');
        state = state.copyWith(isBusy: false);
        return;
      }
      if (password.isEmpty) {
        Utils.showSnackBar(context, '비밀번호를 입력하세요');
        state = state.copyWith(isBusy: false);
        return;
      }
    }
 
  }
}
 

 

상속받은 BaseView 에 대한 코드는 제가 직접 구현한 것이 아니기 때문에 오픈할 수 없다.

riverpod 기반 BaseView 구현 코드를 얻고 싶은 분은 인프런 사이트 강좌 DevStory님의 "Flutter 앱 개발 실전" 강좌 수강을 추천한다. Flutter 에 대한 기본 개념을 확실하게 잡을 수 있고, 이를 바탕으로 응용하는데 도움이 될 것이다.

블로그 이미지

Link2Me

,
728x90

위젯 트리가 빌드된 이후에 실행되는 콜백 메서드이다.

함수를 바로 실행하지 않고, 아래와 같이 WidgetsBinding.instance.addPostFrameCallback 함수 이용한다는 걸 알아두자.

WidgetsBinding.instance.addPostFrameCallback((_) {
  // 실행할 작업
});

 

class HomePage extends ConsumerStatefulWidget {
  const HomePage({
    Key? key,
  }) : super(key: key);
 
  @override
  ConsumerState createState() => _HomePageState();
}
 
class _HomePageState extends ConsumerState<HomePage> {
  @override
  void initState() {
    // initiate viewModel
    WidgetsBinding.instance.addPostFrameCallback((_) {
      // call view model fetch data
      ref.read(homeViewModelProvider.notifier).fetchData();
    });
    super.initState();
  }
 
  @override
  Widget build(BuildContext context) {
    return Scaffold(

 

블로그 이미지

Link2Me

,
728x90

Class Fruits의 인스턴스를 생성하고 a.list 의 값을 확인해보자.

참조변수 b 에 a.list 를 할당하는 것은 shallow copy(얕은 복사)로 같은 메모리 주소를 바라보고 있어 동일한 값을 가진다.

참조변수 b에 새로운 값을 할당하고 나서 a.list 를 출력해보면 새로 추가한 수박이 반영되어 있는 걸 확인할 수 있다.

 

깊은 복사 방법은

- toList()를 추가하여 새로운 배열을 만드는 방법,

- 전개 연산자(...)를 사용하는 방법,

- map()함수를 활용하는 방법

이 있다.

class Fruits {
  List<String> _list = ['사과','복숭아','배','감','호두','자두'];
  List<String> get list => _list;
}
 
void main(){
  final a = Fruits();
  print(a.list); // [사과, 복숭아, 배, 감, 호두, 자두]
  List<String> b = a.list;
  print(b);      // [사과, 복숭아, 배, 감, 호두, 자두]
  print(a.list == b); // true 얕은 복사(동일한 메모리 주소를 가짐)
 
  b.add('수박');
  print(a.list); // [사과, 복숭아, 배, 감, 호두, 자두, 수박]
 
  List<String> c = a.list.toList(); // 새로운 배열
  print(c == a.list); // fasle 깊은 복사(deep copy)
  print(c); // [사과, 복숭아, 배, 감, 호두, 자두, 수박]
  /***
   * 깊은 복사란, 값이 동일한 객체를 새롭게 생성하는 것을 의미한다.
   */
 
  List<String> d = [...a.list]; // 전개 연산자를 활용하여 새로운 배열
  print(d == a.list); // false 깊은 복사
  print(d); // [사과, 복숭아, 배, 감, 호두, 자두, 수박]
 
  List<String> e = a.list.map((e) => e).toList();
  // map은 배열을 순환하며 값을 변경할 수 있는 함수이다.
  // map()은 Iterable을 반환한다.
  print('-------------------------------');
  print(e); // [사과, 복숭아, 배, 감, 호두, 자두, 수박]
  print(e == a.list); // false. 깊은 복사
 
  c.add('살구');
  print(c);      // [사과, 복숭아, 배, 감, 호두, 자두, 수박, 살구]
  print(a.list); // [사과, 복숭아, 배, 감, 호두, 자두, 수박]
 
  d.add('포도');
  a.list.remove('복숭아');
  print(d);      // [사과, 복숭아, 배, 감, 호두, 자두, 수박, 포도]
  print(a.list); // [사과, 배, 감, 호두, 자두, 수박]
 
}

 

 

그리고 copyWith를 활용하는 깊은 복사 방법을 알아보자.

class Person {
  final String name;
  final int age;
 
  Person({
    required this.name,
    required this.age,
  });
 
  Person copyWith({
    String? name,
    int? age,
  }) {
    return Person(
      name: name ?? this.name,
      age: age ?? this.age,
    );
  }
 
  @override
  String toString() {
    return 'Person{name: $name, age: $age}';
  }
}
 
void main(){
  final a = Person(name: '홍길동', age: 33);
  final b = Person(name: '홍길동', age: 33);
  print(a == b); // fasle. 객체 생성할 때마다 메모리 주소가 새로 할당된다.
 
  final d = a;
  print(d == a); // true 얕은 복사(동일한 메모리 주소를 가짐)
 
  final e = a.copyWith(); // 깊은 복사
  print(a.toString()); // Person{name: 홍길동, age: 33}
  print(e.toString()); // Person{name: 홍길동, age: 33}
  print(e == a); // false. 서로 다른 메모리 주소
 
}
 

 

 

 

 

'Flutter 앱 > Dart 언어' 카테고리의 다른 글

Dart 값 비교 - Equatable 패키지  (0) 2024.01.17
Dart cascade notation  (0) 2024.01.16
Dart List.where  (0) 2024.01.16
[Dart 고급] Dart asMap, entries  (0) 2024.01.16
Dart fold 함수  (2) 2023.12.15
블로그 이미지

Link2Me

,
728x90

Operator ==

oop라는 명칭이 생겨난 이유는 모든 클래스가 기본적으로 object를 extend 하기 때문이다.
Object라는 최상위 클래스가 제공해주는 모든 파라미터들을 모든 클래스들이 사용할 수 있다는 의미다.
대부분의 언어에서는 이 Object라는 클래스에 A 인스턴스와 B 인스턴스를 비교하는 알고리즘이 정의되어 있다.
Dart 에서는 operator라는 함수에 정의 되어있고, 이를 override 함으로써 값 비교 알고리즘을 자유롭게 변경할 수 있다.


Hash Code

hashcode 함수는 Map 또는 Set에서 키의 역할을 하게 된다.
Map이나 Set은 키가 중복으로 저장될 수 없기 떄문에 Set, Map의 키로 Object가 저장되었을 때 어떻게 키값을 정의할지가 중요하다.

프로퍼티(변수)가 늘어나면 늘어날수록 operator와 hashcode를 작성하는것이 귀찮아진다.
dart에서는 equatable클래스를 상속받아 이 문제를 해결하고 있다.

Equatable 클래스를 상속받고 props 라는 메소드를 override 해주면 된다.

 

import 'package:equatable/equatable.dart';
 
class A extends Equatable {
  // Equatable 패키지는 불변 객체를 전제로 설계되었다.
  final int a;
  final int b;
 
  const A({
    required this.a,
    required this.b,
  });
 
  @override
  List<Object?> get props => [this.a, this.b];
  // Equatable 클래스를 상속을 받고
  // props 라는 메소드를 override 해주면 된다.
  // props는 비교시 사용하고 싶은 속성을 배열로 반환해주면 된다.
}
 
void main() {
  final a1 = A(a:88, b:53);
  final a2 = A(a:88, b:53);
 
  print(a1 == a2); // true
  print(a1); // A(88,53)
}
 
/***
 * Dart 기본적으로 참조 비교(Reference Equality)를 사용하기 때문에,
 * 가변 객체인 A 인스턴스는 생성할 때 마다 다른 메모리에 할당되어 false를 반환
 * Equatable 패키지를 이용하면 보다 손쉽게 값 비교를 구현할 수 있다.
 */

 

equatable 패키지 설치는 https://pub.dev/packages/equatable 를 참조하면 된다.

'Flutter 앱 > Dart 언어' 카테고리의 다른 글

Dart 얕은 복사(shallow copy), 깊은 복사(deep copy)  (0) 2024.01.17
Dart cascade notation  (0) 2024.01.16
Dart List.where  (0) 2024.01.16
[Dart 고급] Dart asMap, entries  (0) 2024.01.16
Dart fold 함수  (2) 2023.12.15
블로그 이미지

Link2Me

,