레이블이 코딩인 게시물을 표시합니다. 모든 게시물 표시
레이블이 코딩인 게시물을 표시합니다. 모든 게시물 표시

2024년 4월 26일 금요일

공간정보 GIS 기반 IoT 데이터 분석 스타일 데쉬보드 만들고 서비스해보기

이 글은 Django, Bootstrap을 사용해 GIS 기반 IoT 데이터 분석 스타일의 데쉬보드 개발방법을 간략히 정리하고, 개발 방법 후 서비스하는 방법을 보여준다. 이를 통해, 공간정보 기반 IoT 장비를 하나의 데쉬보드로 관리하고, 분석하는 것이 가능하다. 여기서 공간정보는 GIS, BIM, 3D Point cloud data 와 같이 공간상 좌표로 표현되는 모든 정보를 말한다. 상세한 구현 소스 코드는 본 글의 Github링크를 참고할 수 있다. 
IoT 데쉬보드 web app 서버 실행 (example)

이 글을 통해, 다음 내용을 이해할 수 있다. 
  • 부트스트랩 데쉬보드 UI 라이브러리 사용법
  • 장고의 데이터 모델과 웹 UI 간의 연계 방법
  • GIS 맵 가시화 및 이벤트 처리
  • 실시간 IoT 데이터에 대한 동적 UI 처리 방법
이 글의 독자는 장고, 부트스트랩, GIS, IoT 의 기본 개념은 알고 있다고 가정한다. 

요구사항 디자인
다음과 같은 목적의 웹앱 서비스를 가정한다. 
  • GIS 기반 센서 위치 관리
  • IoT 데이터셋 표현
  • IoT 장치 관리
  • IoT 장치 활성화 관리 KPI 표현
  • 계정 관리
  • 기타 메뉴 
이러한 정보를 보여줄 수 있는 데쉬보드 웹앱을 디자인한다. 이 예제는 데쉬보드 레이아웃을 가진 웹앱 프레임 개발 방법을 보여주는 것에 집중한다. 세부 비지니스 로직 및 데이터베이스 모델은 다른 페이지를 참고한다.
개발환경 준비
개발도구
개발에 필요한 도구는 다음으로 한다.
  • UI: bootstrap
  • 웹앱 프레임웍: DJango
  • GIS: leaflet, Cesium
  • 데이터소스: sqlite, spreadsheet, mongodb
구현된 상세 소스코드는 다음을 참고한다.
장고 기반 웹앱 프로젝트 생성
장고(Django)는 파이썬으로 작성된 고수준의 웹 프레임워크로, 웹 애플리케이션 개발을 빠르고 쉽게 할 수 있도록 도와준다. 장고는 "The web framework for perfectionists with deadlines"라는 슬로건을 가지고 있으며, 많은 기능을 내장하고 있어 개발자가 반복적인 작업을 줄이고 핵심 기능에 집중할 수 있도록 한다.

다음과 같이 장고 웹앱 프로젝트를 생성한다. 
python -m venv myenv
source myenv/bin/activate  
pip install django pandas
django-admin startproject iot_dashboard
cd iot_dashboard
python manage.py startapp dashboard

생성된 프로젝트 폴더 구조는 다음과 같다.

디자인 스타일 고려사항
부트스랩 레이아웃 표현
부트스트랩(Bootstrap)은 웹 개발에서 널리 사용되는 프론트엔드 프레임워크로 주로 HTML, CSS, JavaScript로 작성되어 있다. 트위터의 개발자들에 의해 처음 만들어졌으며, 웹 애플리케이션의 개발 속도를 높이고, 반응형 디자인을 쉽게 구현할 수 있도록 도와준다. 

부트스랩의 그리드 시스템은 12개 열로 디자인된다. 이는 유연성과 사용 편의성을 제공하기 위한 디자인 결정이다. 반응형 웹사이트를 구축하는 데 많이 사용된다. 

참고로, 12라는 숫자는 많은 약수(1, 2, 3, 4, 6, 12)를 갖고 있어 다양한 열의 조합으로 균등하게 나눌 수 있다. 이를 통해 분수나 번거로운 나머지 없이 다양한 레이아웃을 만들 수 있다.
  • 유연성: 12개의 열을 사용하면 다양한 화면 크기와 디바이스에 적합한 레이아웃을 쉽게 만들 수 있다. 각 요소가 차지하는 열의 수를 조정하여 대형 데스크톱 화면, 태블릿 및 스마트폰에서 잘 보이는 반응형 디자인을 만들 수 있다.
  • 이해하기 쉬움: 12개의 열을 기반으로 한 그리드 시스템은 디자이너와 개발자들에게 직관적이다. 그리드 내에서 요소들이 어떻게 동작할지 시각화하고 계산하기 쉽기 때문에 일관된 레이아웃을 생성하고 유지하기가 간단하다.
  • 디자인 관행: 12개의 열을 사용하는 그리드 시스템은 부트스트랩 이전부터 다양한 그래픽 디자인 및 레이아웃 소프트웨어에서 사용되어 왔다. 
데쉬보드 카드 스타일
데쉬보드에 컨텐츠를 담을 패널을 카드 스타일로 표현할 수 있다. 카드 내에 차트 뿐 아니라 지도 등 그래픽도 표시할 수 있다. 

보통, 일반적인 카드 스타일 구조는 다음과 같다. 
            <div class="row">
               <div class="col-lg-8">
                   <div class="card mb-3">
                       <div class="card-header">
                           <i class="fa fa-map"></i> Leaflet Map
                       </div>
                       <div class="card-body">
                           <div id="leafletMap" style="width:100%; height: 450px"></div>
                       </div>
                   </div>
               </div>
           </div>     

이 코드는 행 스타일 안에 가변 8개 컬럼을 차지(col-lg-8)하고, 중간 수준 마진(card mb-3)를 가지는 카드를 생성한다. 카드는 헤더(card_header)와 본체(card-body)를 가지며, 헤더는 아이콘(<i>) 스타일의 Font Awesome의 map 아이콘을 사용한다. body 내에는 디스플레이할 위젯을 표시할 <div>를 정의한다.

부트스랩은 이와 같은 style tag가 있어, 다양한 UI를 손쉽게 정의할 수 있다. 자세한 내용은 다음을 참고한다.
개발
주요 구현부분만 표현한다. 상세 구현 코드는 앞의 github 링크를 참고한다.

데이터소스 모델 연결 및 차트 표시
본 예시는 데쉬보드 앱 디자인 및 개발 과정을 보여주는 것이므로, 간단한 iot sample dataset을 다음과 같이 models.py에 개발해 놓는다. 
def IoT_model_from_file():
    save_sample_iot_dataset()
    df = pd.read_csv('iot_dataset_sample.csv')
    json_dict = df.to_dict('records')
    return json_dict

def save_sample_iot_dataset():
    # Create a DataFrame
    df = pd.DataFrame({
        'time': [datetime.now() - timedelta(days=i) for i in range(10)],
        'open': [randint(100, 200) for _ in range(10)],
        'high': [randint(200, 300) for _ in range(10)],
        'low': [randint(50, 100) for _ in range(10)],
        'close': [randint(100, 200) for _ in range(10)],
    })

    # Convert the 'time' column to a string in the format 'YYYY-MM-DD'
    df['time'] = df['time'].dt.strftime('%Y-%m-%d')

    # Save the DataFrame to a CSV file
    df.to_csv('iot_dataset_sample.csv', index=False)

IoT 센서 실시간 데이터 표시
특정 카드 내 차트에 실시간으로 데이터를 표현하기 위해서, 장고에서는 html script > view > model 업데이트 과정을 거쳐야 한다. 이 경우는 3초마다 센서 데이터를 화면에 업데이트한다고 가정한다. 이를 위해, 데이터가 준비되면 렌더링될 수 있도록 비동기 처리 요구 방식을 사용한다.

index.html의 script부분에 아래 코드를 추가한다. 
         setInterval(fetchColumnData, 3000); // 3초마다 업데이트
         function fetchColumnData() {
            var xhr = new XMLHttpRequest();
            xhr.open('GET', '/charts?chartType=column', true);
            xhr.onreadystatechange = function () {
               if (xhr.readyState == 4 && xhr.status == 200) {  // 비동기. 데이터 준비 시 호출
                     var columnData = JSON.parse(xhr.responseText);
                     columnChart.options.data[0].dataPoints = columnData;
                     columnChart.render();  // 값을 차트에 업데이트

                     var columnChart_ready = document.getElementById('columnChart_ready');
                     var columnChart_operation = document.getElementById('columnChart_operation');
                     var columnChart_shutdown = document.getElementById('columnChart_shutdown');
                     columnChart_ready.innerHTML = columnData[0].y;
                     columnChart_operation.innerHTML = columnData[1].y;
                     columnChart_shutdown.innerHTML = columnData[2].y;
               }
            };
            xhr.send();
         }

참고로, 이러한 방식은 많은 CPU 부하를 차지한다. 다른 대안으로, 다음처럼 animation loop를 사용하는 방식이 있다. 

         let lastTime = 0;
         const interval = 3000;
         function animationLoop(timestamp) {
               if (timestamp - lastTime >= interval) {
                  fetchColumnData();
                  lastTime = timestamp;
               }
               requestAnimationFrame(animationLoop);
         }
         requestAnimationFrame(animationLoop);         

이 또한 성능 문제가 있다면, 실시간 업데이트 기능이 필요한 사용자만 사용할 수 있도록 페이지를 분리하거나, 별도 UI 앱을 개발하자.

이외, Leaflet(리플렛), Cesium(세슘) 라이브러리를 이용해 2차원, 3차원 화면을 표시한다. 세귬은 미리 API 사용 토큰을 발급받아야 제대로 동작된다. 
Cesium.Ion.defaultAccessToken = 'your_access_token';

실행 결과
앞의 디자인 목적을 고려한 데쉬보드 실행 결과는 다음과 같다.

번들(buddle)로 컴파일된 자바스크립트 라이브러리 중 하나인 ifc.js의 viewer를 card-body의 id와 연결하여, 다음과 같이 3D 모델을 표현할 수 있다. Cesium도 동일한 방식으로 렌더링될 수 있다.


웹 서비스 배포 및 호스팅
호스팅 방법은 다양하다. 여기에선 최근 인기가 높아지고 있는 cloudtype을 사용해 배포한다.
cloud server setting 모습

배포에 성공하면, 다음과 같이 외부에서도 웹 서비스에 접속 실행될 것이다. 
실행 화면

스마트폰에서 접속하면, bootstrap layout style에 따라 패널이 잘 정렬되어 보여지는 것을 확인할 수 있다.
 
스마트 폰 실행 모습

이외에 유용한 배포 호스팅 서버로 python anywhere, amazon free tier 등이 있다. 아래 링크를 참고한다.
마무리
요즘에는 좋은 개발 도구와 라이브러리가 워낙 많아, 프론트앤드, 백앤드, 미들웨어 스택을 만들때 조합하기가 오히려 혼란스러운 점이 있다. 장고, 부트스랩 등을 이용하면 기본적인 프레임웍에서 이들을 조합할 수 있어, 개발이 편해진다. 

GIS, BIM, 3D Point cloud data 관련 유명한 라이브러리는 CDN 스토리지를 지원하고 있어, import해 사용하기 편하다. 다만, 이런 공간정보 라이브러리는 많은 리소스를 차지하므로, 성능 최적화를 고려해야 사용할 필요가 있다. 

추신. 2005년에 개발된 장고는 저널리즘을 전공한 Adrian Holovaty의 주도로 Joscho Stephan과 함께 개발되었다. 그들은 올바른 저널리즘과 기타 연주를 사랑한다.  
Django 개발자 Joscho Stephan, Adrian Holovaty (patrus)

레퍼런스

2024년 3월 30일 토요일

몬테카를로 시뮬레이션 기반 주가 예측

이 글은 몬테카를로 시뮬레이션 기반 주가 예측하는 방법을 보여준다. 

주가를 다운로드하여, 평균, 편차를 계산한 후, 95% 수준에서 시뮬레이션을 수행해, 수익률을 예측해 본다. 다음은 이를 구현한 코드이다. 

import numpy as np
import matplotlib.pyplot as plt
from eodhd import APIClient
import config as cfg

api = APIClient(cfg.API_KEY)

def get_ohlc_data():
    df = api.get_historical_data("GSPC.INDX", "d", results=365)
    return df

if __name__ == "__main__":
    df = get_ohlc_data()

    # 날짜별 계산
    daily_returns = df["adjusted_close"].pct_change().dropna()

    # 시뮬레이션 파라메터
    initial_investment = 10000  # 초기 투자량
    num_simulations = 1000    # 시뮬레이션 횟수
    forecast_days = 365    # Investment horizon in days
    desired_return = 0.10  # Desired return (10%)

    # Calculate the average daily return
    average_daily_return = daily_returns.mean()

    # Calculate volatility as the standard deviation of daily returns
    volatility = daily_returns.std()

    print(f"Average Daily Return: {average_daily_return}")
    print(f"Volatility: {volatility}")

    # Simulating future returns
    simulated_end_returns = np.zeros(num_simulations)
    for i in range(num_simulations):
        random_returns = np.random.normal(
            average_daily_return, volatility, forecast_days
        )
        cumulative_return = np.prod(1 + random_returns)
        simulated_end_returns[i] = initial_investment * cumulative_return

    # Calculate the final investment values
    final_investment_values = simulated_end_returns

    # Calculate Value at Risk (VaR) and Expected Tail Loss (Conditional VaR)
    confidence_level = 0.95
    sorted_returns = np.sort(final_investment_values)
    index_at_var = int((1 - confidence_level) * num_simulations)
    var = initial_investment - sorted_returns[index_at_var]
    conditional_var = initial_investment - sorted_returns[:index_at_var].mean()

    print(f"Value at Risk (95% confidence): £{var:,.2f}")
    print(f"Expected Tail Loss (Conditional VaR): £{conditional_var:,.2f}")

    num_success = np.sum(
        final_investment_values >= initial_investment * (1 + desired_return)
    )
    probability_of_success = num_success / num_simulations

    print(
        f"Probability of achieving at least a {desired_return*100}% return: {probability_of_success*100:.2f}%"
    )

    plt.figure(figsize=(10, 6))
    plt.hist(final_investment_values, bins=50, alpha=0.75)
    plt.axvline(
        initial_investment * (1 + desired_return),
        color="r",
        linestyle="dashed",
        linewidth=2,
    )
    plt.axvline(initial_investment - var, color="g", linestyle="dashed", linewidth=2)
    plt.title("Distribution of Final Investment Values")
    plt.xlabel("Final Investment Value")
    plt.ylabel("Frequency")
    plt.show()


레퍼런스

2024년 3월 11일 월요일

유용한 Django GUI 차트 및 데쉬보드 라이브러리 소개

이 글은 유용한 Django GUI 라이브러리를 소개하는 글이다. 
장고는 파이썬 기반 웹 앱 개발 지원 프레임웍으로 매우 강력한 기능을 지원한다. 이 글은 장고 기반 웹 앱 개발 시 사용할 수 있는 유용한 GUI 라이브러리를 살펴본다. 

이 도구는 웹 기반 차트 등 생성을 제공한다. 다양한 데이터소스를 제공해, 이를 사전 정의된 테마, 다양한 차트를 통해 웹에 렌더링할 수 있다.

150개 이상의 반응형 그래프, 차트를 지원하는 이 도구는 대화형 방식의 GUI를 제공한다. MySQL 서버에서 가져온 데이터를 이용해 즉각적인 차트를 생성한다. 사용자 입력에 대한 이벤트를 지원하고, 실시간으로 차트를 추가할 수 있다. 
간트 차트 등 그래프 생성을 지원한다. 이 도구는 매우 다양한 차트를 제공하는 highcharts를 기반으로 개발되었다. 

SaaS지원을 위한 분석 도구, 데쉬보드 등 지원한다. 

Bootstrap 4 lite 기반 템플릿을 사용해, 높은 품질의 데쉬보드, 맵 패널 등을 제공한다. 

Bootstrap 5 기반으로 커스터마이징 가능한 데쉬보드를 제공한다. 

Material Dashboard
이 도구는 타임 시리즈 데이터를 데쉬보드로 가시화하는 데 유용하다. 부트스트랩 기반으로 개발되었으며, 다양한 테마를 제공한다. 

기타, 장고에는 DB 객체 맵핑 도구인 ORM, 다중 파일 업로드를 지원하는 ModelForm와 같은 강력한 기능을 기본으로 제공한다. 

레퍼런스

2024년 2월 17일 토요일

불필요한 폴더 파일 정리 후 컴퓨터 용량 확보하기

요즘 컴퓨터 용량이 점점 줄어들어, 대대적으로 디스크 정리를 하였다(딥러닝, 오픈소스 등 작업할 때 로컬에 데이터 다운로드가 필수라 용량 부족하면 작업이 안된다).

순서는 다음과 같다.

1. 탐색기에서 디스크 정리한다.

2. du 란 disk usage 용량 보여주는 프로그램을 다운로드해, 윈도우즈 폴더에 풀어준다.

3. c:\Users\[user name\AppData\LocalLow\Temp를 삭제한다. 

4. cmd를 실행해, C:\Users\MAC\AppData\Roaming 로 이동한다. 그리고 다음 명령을 입력한다. 그럼, 현재 하위 폴더의 용량을 요약해 보여준다.
du -l 1 . 

5. 각 폴더 중 불필요한 폴더들은 삭제한다. 예를 들어, Apple Computer\itunes 폴더는 백업이 있어, 과도한 용량을 차지하므로, 해당 백업을 삭제하여, 30GB를 확보하였다. Temp 폴더 등도 삭제한다.

이렇게 하여, 전체 사용 가능 용량을 2배 이상 더 확보할 수 있다.


2024년 2월 3일 토요일

Django 사용한 간단한 데쉬보드 웹 어플리케이션 개발하기

이 글은 Django 사용한 간단한 데쉬보드 웹 어플리케이션 개발 이야기를 나눔한다.

웹 어플리케이션 개발 방법은 다양하지만, 몇 년 사이에 유행하고 있는 트랜드는 마이크로 서비스 개발이 가능한 플라스크 같은 플랫폼을 사용하는 것이다. 여기서는 개발자가 애용하고 있는 파이썬 기반 Django(장고) 플랫폼을 사용해 웹 서비스 개발 방법을 알아본다.
장고를 이용하면, 파이썬의 다양한 라이브러리를 사용한 웹 앱을 손쉽게 개발할 수 있다. 장고보다 경량의 플랫폼인 플라스크 사용법은 다음 링크를 참고한다.
이 글은 다음 레퍼런스를 참고한다.
이 글의 소스코드는 다음 링크를 참고한다. 

개발 환경 준비
다음과 같이 개발 환경을 준비한다.
  1. 데이터베이스 설치: Django 설치시 sqlite db가 설치된다. 만약, 다른 DB를 사용하고 싶다면, MariaDB, MySQL, PostgreDB 등을 설치한다. 
  2. 데이터베이스 커넥터 설치: pip install mysqlclient 명령으로 커넥터 드라이버 설치한다.
  3. 장고 설치: pip install Django
제대로 설치되었다면, 다음 코드를 입력해 실행한다. 정상동작하면 성공한 것이다. 
import django
print(django.get_version())

데쉬보드 개발해보기
터미널에서 다음 명령을 실행해본다.
django-admin startproject monitoring
cd monitoring
python manage.py runserver

이제, The install worked successfully! Congratulations!를 클릭해 본다. 다음과 같이 웹 앱이 실행되면 성공한 것이다. 

터미널에 다음 같이 실행한다. 이 결과로 dashboard 앱 코드가 자동생성될 것이다. 
python manage.py startapp dashboard

자동생성된 앱 코드 소스 파일

각 파일 역할은 다음과 같다. 
  • __init__.py : 이 앱에 필요한 파이썬 패키지를 초기화하고, 임포트함
  • admin.py : 장고 관리자 페이지를 위한 설정
  • apps.py : app 설정 
  • models.py : 장고 ORM(Object Relationship Mapping)을 위한 클래스 정의
  • tests.py : 테스트 클래스 정의
  • views.py : 데이터가 웹 템플릿에 의해 보여지는 방식을 정의
생성된 웹 앱을 등록하기 위해, monitoring/settings.py을 다음과 같이 편집한다. 
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'dashboard'
]
편집 화면 일부

views.py에 데쉬보드가 보여질 방법을 코딩한다. 이 파일을 편집해 다음과 같이 코딩한다. 
from django.http import JsonResponse
from django.shortcuts import render
from dashboard.models import Order
from django.core import serializers

def dashboard(request):   // 이 함수는 사용자가 URL 입력 시 호출됨
    return render(request, 'dashboard.html', {})  // 이때 HTML 템플릿을 사용해 렌더링

def order_data(request):  // HTML 렌더링 시 전달될 DB의 데이터는 JSON 형태로 변환 리턴
    dataset = Order.objects.all()
    data = serializers.serialize('json', dataset)
    return JsonResponse(data, safe=False)

여기서, 데쉬보드에 렌더링될 템플릿 HTML파일을 리턴하는 dashboard..()함수, 보여질 데이터를 준비하는 pivot...() 함수를 준비해 놓았다. 참고로, 아직 구현되지 않은 HTML과 Order 모델은 이후 작업될 것이다. 

URL-함수 맵핑
앞서 정의된 함수를 사용자 URL 입력 시 실행되도록 맵핑해야 한다. 
이를 위해, monitoring/urls.py를 다음과 같이 편집한다. 
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('dashboard/', include('dashboard.urls'))
]

위 코드에서 include에 urls.py를 사용하므로, 이 파일을 코딩해야 한다. dashboard/urls.py 파일을 다음같이 편집한다. 앞서 정의된 함수와 URL이 연결된 것을 알 수 있다.
from django.urls import path
from . import views

urlpatterns = [
    path('', views.dashboard, name='dashboard'),
    path('data', views.order_data, name='order_data'),
]

모델 개발
이제 보여줄 데이터를 가지고 있는 모델을 코딩한다. 여기서는 가벼운 SQLite를 데이터베이스로 사용한다. 
from django.db import models

class Order(models.Model):
    product_category = models.CharField(max_length=20)
    payment_method = models.CharField(max_length=50)
    shipping_cost = models.CharField(max_length=50)
    unit_price = models.DecimalField(max_digits=5, decimal_places=2)

이제 정의된 모델 클래스를 데이터베이스 테이블로 생성해줘야 데이터를 저장할 수 있다. 이 처리를 위해, 다음 명령으로 마이그레이션을 한다.
python manage.py makemigrations dashboard

결과, 다음과 같이 자동으로 지정 폴더의 model을 읽어 ORM처리 하도록 설정된다. 

다음 명령으로 DB 테이블을 만든다.
python manage.py migrate dashboard
명령 실행 결과
테이블 생성 후 Sqlite DB 파일

DB 테이블이 생성되었으니, 예제로 사용할 데이터를 INSERT해보자. 다음 명령을 실행한다.
python manage.py shell

DB를 조작할 수 있는 터미널이 실행될 것이다. 여기서 파이썬 코드를 실행할 수 있다. 다음을 입력한다. 
from dashboard.models import Order

o1 = Order(
 product_category='Books',
 payment_method='Credit Card',
 shipping_cost=39,
 unit_price=59
)

o1.save()

이런 방식으로 원하는 만큼 데이터 레코드를 INSERT할 수 있다.
데이터 생성 모습

참고로, 다음 명령으로 생성된 데이터를 리스트할 수 있다. 셀의 상세 기능은 여기를 참고한다. 
for u in Order.objects.all():
    print(u)
    dic = u.__dict__
    for f, v in dic.items():
        print(f'{f}: {v}')
데이터 리스트 모습

데쉬보드 UI와 데이터 연결
이제 준비된 데이터를 데쉬보드 UI에 연결해야 한다. 

웹에서 UI 처리는 크게 '요청-응답' 방식, 비동기 요청 방식(AJAX)이 있다. 여기서는 AJAX 방식을 사용한다. 이를 위해 jQuery $.ajax 함수를 사용하였다. 관련된 상세 사용법은 아래 링크를 참고한다. 
templates/dashboard...html 파일을 다음과 같이 편집한다. 데쉬보드는 Flexmonster UI로 만든 테이블, 원 차트 두개로 구성된다. 
<script>
function processData(dataset) {
    var result = []
    dataset = JSON.parse(dataset);
    dataset.forEach(item => result.push(item.fields));
    return result;
}
$.ajax({   // 비동기 jQuery 호출. 데이터는 DB에 미리 준비됨. URL HTML 렌더링은 비동기. 
    url: $("#pivot-table-container").attr("data-url"),
    dataType: 'json',
    success: function(data) {
        new Flexmonster({
            container: "#pivot-table-container",
            componentFolder: "https://cdn.flexmonster.com/",
            width: "100%",
            height: 430,
            toolbar: true,
            report: {
                dataSource: {
                    type: "json",
                    data: processData(data)
                },
                slice: {}
            }
        });
        new Flexmonster({
            container: "#pivot-chart-container",
            componentFolder: "https://cdn.flexmonster.com/",
            width: "100%",
            height: 430,
            //toolbar: true,
            report: {
                dataSource: {
                    type: "json",
                    data: processData(data)
                },
                slice: {},
                "options": {
                    "viewType": "charts",
                    "chart": {
                        "type": "pie"
                    }
                }
            }
        });
    }
});
</script>

여기서 사용된 데쉬보드 UI인 Flexmonster는 ajax모드에서 pivot-table-container ID를 가진 div에 임베딩되도록 설정되어 있다.  

여기서, processData는 URL질의 결과 리턴되는 JSON 텍스트를 파싱해, result 리스트에 각 아이템을 추가하는 역할을 한다. 이 함수는 Flexmonster란 데쉬보드 UI의 report의 datasource에 지정되어, UI와 데이터를 연결하는 역할을 한다. 

Flexmonster 데쉬보드는 데이터소스 속성이 보여지는 방식을 설정할 수 있다. 다음과 같이 편집한다. 
dataSource: {
type: "json",
data: processData(data),
mapping: {
"product_category": {
"caption": "Product Category",
"type": "string"
},
"payment_method": {
"caption": "Payment Method",
"type": "string"
},
"shipping_cost": {
"caption": "Shipping Cost",
"type": "number"
},
"unit_price": {
"caption": "Unit Price",
"type": "number"
}
}
},

이제 다음 명령으로 개발한 웹 앱을 실행해 본다. 
python manage.py runserver

URL은 Dashboard를 클릭한다. 다음과 같이 보여지면 성공한 것이다. 
장고로 개발된 웹 앱 실행 결과

맵 연동 
이제 맵과 연동해 본다. 다음은 리플릿과 Plotly을 이용한 간단한 장고 웹 앱 실행 결과이다.



코드 디버깅
장고로 개발한 코드를 디버깅할 수 있으면, 실행 상태에서 변수, 콜 스택 등을 쉽게 확인할 수 있다. 우선, 디버깅 버튼의 launch.json 생성 버튼을 클릭해, .vscode 폴더에 launch.json을 생성한다.
Debug의 create a launch.json 버튼

다음과 같이 launch.json를 수정한다. 보다시피, 디버깅할 programd의 args를 설정해 두었다. 본 실습에는 chrome대신 edge를 사용하였기에, 두번째 설정은 msedge로 타입을 지정하였다.
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Django",
            "type": "debugpy",
            "request": "launch",
            "program": "${workspaceFolder}\\manage.py",
            "args": [
                "runserver",
                "--noreload"
            ], 
            "django": true,
            "justMyCode": true
        }, 
        {
            "name": "Edge",
            "type": "msedge",
            "request": "launch",
            "url": "http://localhost:8000",
            "webRoot": "${workspaceFolder}"        
        }            
    ]
}

생성된 launch.json 파일

이제 manager.py를 선택하고 디버깅 버튼을 클릭하면 다음과 같이, 코드를 디버그할 수 있다.
장고 웹 앱 디버깅 모습

로그는 settings.py에 다음 LOGGING 사전을 추가하고, 원하는 코드에 사용하면 된다. 
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "console": {"class": "logging.StreamHandler"},
    },
    "loggers": {
        "django": {
            "handlers": ["console"],
            "level": "INFO",
        },
    }
}

코드는 다음과 같다. 
import logging
import logging.config
logger = logging.getLogger('django')

logger.info('here goes your message')

def your_func():
    logging.config.fileConfig('logging.conf')
    logger = logging.getLogger('applog')

    logger.debug('debug message')
    logger.info('info message')
    logger.warn('warn message')
    logger.error('error message')
    logger.critical('critical message')

좀 더 상세한 디버깅 방법은 다음을 참고한다.

마무리
이 글을 통해, 간단히 장고로 만든 앱 앱을 코딩하고 실행해 보았다. 이와 같은 방식으로 다양한 웹 앱을 개발할 수 있을 것이다. 

레퍼런스
부록: Mongo 데이터베이스 연결
일반적으로, Django가 지원하는 database는 sqlite, mysql, postgresql 이다. 그러므로, mongo database를 settings.py에서 설정한 후 실행해보면, 다음과 같이 에러가 발생할 수 있다.

django.core.exceptions.ImproperlyConfigured: 'django_mongodb_engine' isn't an available database backend.
Try using 'django.db.backends.XXX', where XXX is one of:
u'base', u'mysql', u'oracle', u'postgresql_psycopg2', u'sqlite3'
Error was: cannot import name BaseDatabaseFeatures

이 경우, 다음과 같이 패키지를 설치한다. djongo는 mongodb와 django를 연결해 준다. 
pip install djongo
pip install pytz


2023년 8월 16일 수요일

하나의 코드로 다양한 OS 크로스플랫폼 지원 Flutter 기반 AI 채팅앱 개발하기

이 글은 하나의 코드로 iOS, 안드로이드, 웹, 윈도우, 리눅스 크로스플랫폼(cross platform) OS에서 실행될 수 있는 멀티플랫폼 어플리케이션 개발 오픈소스 도구인 Flutter 사용법을 이야기한다. 이런 도구가 활성화되기 전에는 각 운영체제 마다 별도로 동작되는 프로그램을 개발해야 했었다. Flutter를 이용하면, 이런 고생을 할 필요가 있다. 이 글은 Flutter의 개념과 사용 방법을 간략히 설명하고, 채팅 앱을 개발해 본다. 또한, 채팅 앱과 OpenAI ChatGPT와 같은 AI 서비스를 상호연동하는 방법도 간략히 나눔한다.

참고로, 우리가 개발할 앱은 다음과 같은 것이다. 그림과 같이, 스마트폰 앱, 윈도우, 크롬 등에서 같은 개발 코드 만으로 동일하게 동작된다. 

개발할 AI 에이전트 채팅 앱 (좌로 부터 스마트폰, 윈도우, 인터넷 앱)

머리말
플로터는 구글에서 개발한 멀티플랫폼 지원 GUI 앱 개발 도구이다. 구글에서 개발한 DART언어를 사용해 개발한다. 이 언어는 C++과 유사한 문법을 가지나, 웹 개발에 적합하도록 비동기 데이터 처리, 스트림 처리, 플로터 라이브러리와 연계한 강력한 위젯 디자인 문법을 포함하고 있다. 

DART이용한 웹 앱 디자인 예

Flutter로 개발 가능한 앱 들

다트의 좀 더 상세한 문법은 다음 링크를 참고한다.
개발환경 준비
먼저 다음과 같이 vscode, flutter 도구 등을 설치한다.
설치가 모두 끝났다면, vscode에서 shift+ctrl+P를 누른후, flutter project를 선택한다.
혹시, SDK 설치가 안되었다면, SDK 설치 프롬프트가 나타난다. 앞서 SDK를 설치했다면, 설치된 폴더 위치를 지정해 준다.
이제, 터미널이나 powershell을 띄워 다음 명령을 입력한다.
git config --global --add safe.directory '*'
flutter doctor

다음과 같이 실행되면 성공한 것이다.

기본 예제 프로젝트 생성
vscode를 실행하고, shift+ctrl+P를 누른 후, flutter project를 선택하고, 적당한 프로젝트 폴더(c:\projects)를 선택한다. 그럼, 다음과 같이 기본 application 프로젝트가 생성된다. 

실행하면, 다음과 같이 앱이 빌드되어 실행된다. 

터미널에서는 다음과 같이 실행한다.
flutter pub upgrade
flutter clean
flutter pub get
flutter run

참고로, sdk version 에러가 발생하여, 실행이 안된다면, 다음과 같이 해당 버전을 설명에 맞게 수정한다.
name: my_app
description: A new Flutter application.

version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

AI 채팅 앱 개발
새 프로젝트 생성
이제 AI 채팅 앱을 개발한다. 우선 앞서 같은 방식으로 vscode에서 chat_app 어플리케이션을 새로 생성한다. 

UI 개발을 위해서, chatting page, detail chatting page dart 파일을 추가해야 하고, 각 page의 구성 위젯을 추가한 후, 사용자 이벤트에 따라 적절한 채팅 동작을 코딩해야 한다. 

다음은 프로젝트 개발 끝났을 때 전체 폴더 구조를 보여준다. 소스 모듈 개발은 화면 담당, 위젯 담당, 데이터를 관리하는 모델 담당으로 폴더를 구분해 놓겠다.

pubspec.yaml 패키지 및 리소스 설정
다음과 같이 사용 패키지와 리소스를 설정한다. 
name: chat_app
description: "ChatLLM"
publish_to: 'none'
version: 1.0.0+1
environment:
  sdk: '>=3.3.1 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  chat_gpt_sdk: ^3.0.4
  cupertino_icons: ^1.0.6

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^3.0.0

flutter:
  uses-material-design: true

  assets:
    - images/userImage1.jpg
    - images/userImage2.jpg
    - images/userImage3.jpg
    - images/userImage4.jpg
    - images/userImage5.jpg
    - images/userImage6.jpg
    - images/userImage7.jpg
    - images/userImage8.jpg

설정 파일의 assets부분 images는 채팅 사용자 아이콘으로 사용될 것이다. 244x244 픽셀 크기 적당한 이미지를 다운로드하고, project folder의 images 폴더에 다음과 같이 저장한다. 

다음 명령을 입력해, 에러가 없다면 성공한 것이다.
flutter pub upgrade
flutter clean
flutter pub get

main.dart 개발
개발할 homePage를 임포트하고, 위젯 빌드하는 build()에 기본 설정 테마와 home 파라메터에 HomePage() 를 생성해 설정한다. 
다음과 같이 코딩한다. 

import 'package:chat_app/screens/homePage.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      debugShowCheckedModeBanner: false,
      home: const HomePage(),
    );
  }
}

homePage.dart 페이지 개발
homePage는 채팅 사용자 리스트와 하단에 네비게이션 버튼이 3개 있다. 이 위젯을 추가하기 위해, Scaffold 위젯을 사용한다.  
다음과 같이 homePage를 코딩한다. 
import 'package:chat_app/screens/chatPage.dart';
import 'package:flutter/material.dart';

class HomePage extends StatelessWidget{
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: const ChatPage(),
      bottomNavigationBar: BottomNavigationBar(
        selectedItemColor: Colors.red,
        unselectedItemColor: Colors.grey.shade600,
        selectedLabelStyle: const TextStyle(fontWeight: FontWeight.w600),
        unselectedLabelStyle: const TextStyle(fontWeight: FontWeight.w600),
        type: BottomNavigationBarType.fixed,
        items: const [
          BottomNavigationBarItem(
            icon: Icon(Icons.message),
            label: "Chats",
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.group_work),
            label: "Channels",
          ),
          BottomNavigationBarItem(
            icon: Icon(Icons.account_box),
            label: "Profile",
          ),
        ],
      ),
    );
  }
}

chatPage.dart 페이지 개발
채팅 유저 리스트를 관리하는 chatPage를 개발한다. 지금은 실제 사용자 DB등을 사용하는 것이 아닌, 플루터를 이용한 반응형 UI를 개발하는 방법에 집중하므로, 임시로 사용자 객체를 8명 리스트로 추가하고, 이를 표시한다(적색부). 사용자 객체인 ChatUsers는 이름, 메시지, 대표 이미지를 가진다. 
화면 렌더링을 위한 호출 순서는 다음과 같다.
   ChatPage > createState() > _ChatPageState > build()

다음과 같이 코딩한다.
import 'package:flutter/material.dart';
import 'package:chat_app/models/chatUsersModel.dart';
import 'package:chat_app/widgets/conversationList.dart';

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

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

class _ChatPageState extends State<ChatPage> {
  List<ChatUsers> chatUsers = [
    ChatUsers(name: "Alice Johnson", messageText: "Just finished the project report!", imageURL: "images/userImage1.jpg", time: "Now"),
    ChatUsers(name: "Bob Smith", messageText: "Let's meet for the team lunch tomorrow.", imageURL: "images/userImage2.jpg", time: "Yesterday"),
    ChatUsers(name: "Charlie Brown", messageText: "Can you review my code?", imageURL: "images/userImage3.jpg", time: "31 Mar"),
    ChatUsers(name: "David Williams", messageText: "The client meeting is rescheduled to 2 PM.", imageURL: "images/userImage4.jpg", time: "28 Mar"),
    ChatUsers(name: "Eva Davis", messageText: "The design assets are updated in the drive.", imageURL: "images/userImage5.jpg", time: "23 Mar"),
    ChatUsers(name: "Frank Miller", messageText: "I'll be working from home tomorrow.", imageURL: "images/userImage6.jpg", time: "17 Mar"),
    ChatUsers(name: "Grace Lee", messageText: "Can we discuss the new feature implementation?", imageURL: "images/userImage7.jpg", time: "24 Feb"),
    ChatUsers(name: "Harry Wilson", messageText: "How about a brainstorming session for the new UI?", imageURL: "images/userImage8.jpg", time: "18 Feb"),
  ]; 

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SingleChildScrollView(
        physics: const BouncingScrollPhysics(),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            SafeArea(
              child: Padding(
                padding: const EdgeInsets.only(left: 16,right: 16,top: 10),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.spaceBetween,
                  children: <Widget>[
                    const Text("Dialog",style: TextStyle(fontSize: 32,fontWeight: FontWeight.bold),),
                    Container(
                      padding: const EdgeInsets.only(left: 8,right: 8,top: 2,bottom: 2),
                      height: 30,
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(30),
                        color: const Color.fromARGB(255, 205, 221, 252),
                      ),
                      child: const Row(
                        children: <Widget>[
                          Icon(Icons.add,color: Color.fromARGB(255, 81, 81, 81),size: 20,),
                          SizedBox(width: 2,),
                          Text("Add new member",style: TextStyle(fontSize: 14,fontWeight: FontWeight.bold),),
                        ],
                      ),
                    ),
                  ],                  
                ),
              ),
            ),
            Padding(
              padding: const EdgeInsets.only(top: 16,left: 16,right: 16),
              child: TextField(
                decoration: InputDecoration(
                  hintText: "Search...",
                  hintStyle: TextStyle(color: Colors.grey.shade600),
                  prefixIcon: Icon(Icons.search,color: Colors.grey.shade600, size: 20,),
                  filled: true,
                  fillColor: Colors.grey.shade100,
                  contentPadding: const EdgeInsets.all(8),
                  enabledBorder: OutlineInputBorder(
                      borderRadius: BorderRadius.circular(20),
                      borderSide: BorderSide(
                          color: Colors.grey.shade100
                      )
                  ),
                ),
              ),
            ),                    

            ListView.builder(
              itemCount: chatUsers.length,
              shrinkWrap: true,
              padding: const EdgeInsets.only(top: 16),
              physics: const NeverScrollableScrollPhysics(),
              itemBuilder: (context, index){
                return ConversationList(
                  name: chatUsers[index].name,
                  messageText: chatUsers[index].messageText,
                  imageUrl: chatUsers[index].imageURL,
                  time: chatUsers[index].time,
                  isMessageRead: (index == 0 || index == 3)?true:false,
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

여기서, 사용자가 선택이 가능하도록, itemBuilder: (context, index) 이벤트 발생 시 리턴되는 ConversationList 위젯을 리턴하는 것을 알 수 있다(청색부분).

ConversationList.dart 위젯 개발
이 위젯은 앞서 설명한 바와 같이, 사용자 각자 이름, 아이콘을 가진 정보를 렌더링하는 역할을 한다. 이 사용자 정보는 모델 폴더의 chatUserModel.dart 에서 가져온다. 아울러, 선택 시 그 사용자와 채팅하는 페이지로 전환하는 함수를 정의한다(적색).

다음과 같이 코딩한다. 
import 'package:flutter/material.dart';
import 'package:chat_app/models/chatUsersModel.dart';
import 'package:chat_app/screens/chatDetailPage.dart';

class ConversationList extends StatefulWidget{
  String name;
  String messageText;
  String imageUrl;
  String time;
  bool isMessageRead;
  ConversationList({super.key, required this.name, required this.messageText, required this.imageUrl, required this.time, required this.isMessageRead});
  @override
  _ConversationListState createState() => _ConversationListState();
}

class _ConversationListState extends State<ConversationList> {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: (){
        Navigator.push(context, MaterialPageRoute(builder: (context){
          return const ChatDetailPage();
        }));        
      },
      child: Container(
        padding: const EdgeInsets.only(left: 16,right: 16,top: 10,bottom: 10), 
        child: Row(
          children: <Widget>[
            Expanded(
              child: Row(
                children: <Widget>[
                  CircleAvatar(
                    backgroundImage: AssetImage(widget.imageUrl),
                    maxRadius: 30,
                  ),
                  const SizedBox(width: 16,),
                  Expanded(
                    child: Container(
                      color: Colors.transparent,
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: <Widget>[
                          Text(widget.name, style: const TextStyle(fontSize: 16),),
                          const SizedBox(height: 6,),
                          Text(widget.messageText,style: TextStyle(fontSize: 13,color: Colors.grey.shade600, fontWeight: widget.isMessageRead?FontWeight.bold:FontWeight.normal),),
                        ],
                      ),
                    ),
                  ),
                ],
              ),
            ),
            Text(widget.time,style: TextStyle(fontSize: 12,fontWeight: widget.isMessageRead?FontWeight.bold:FontWeight.normal),),
          ],
        ),
      ),
    );
  }
}

chatUsersModel.dart 모델 파일 생성
채팅 사용자 정보를 관리하는 모델 파일을 다음과 같이 코딩한다.
import 'package:flutter/cupertino.dart';

class ChatUsers{
  String name;
  String messageText;
  String imageURL;
  String time;
  ChatUsers({required this.name, required this.messageText, required this.imageURL, required this.time});
}

chatMessageModel.dart 모델 파일 생성
채팅 메시지 구조를 정의하는 모델파일을 다음과 같이 코딩한다.
import 'package:flutter/cupertino.dart';

class ChatMessage{
  String messageContent;
  String messageType;
  ChatMessage({required this.messageContent, required this.messageType});
}

chatDetailPage.dart 페이지 파일 생성
선택된 사용자와 대화 하는 UI를 정의하기 위해, 미리 약간의 ChatMessage를 리스트로 생성해 놓는다(녹색부). 

이 페이지는 첫 행에 사용자 정보, 다음 행에 채팅 리스트, 마지막 행에 텍스트 입력 위젯과 버튼이 추가된다. 텍스트 입력 시 이벤트 처리, 채팅 리스트 자동 스크롤 처리 등을 위해 위젯 컨트롤러(controller)가 사용된다(녹색부). 

아울러, 입력 텍스트의 포커스를 계속 갖기 위한 FocusNode를 사용한다. 

OpenAI의 API KEY를 사용할 수 있다면, token의 '' 문자열에 사용할 KEY 문자열(청색 표시. 현재 51개 글자)을 입력한다. 그럼, 입력 텍스트가 프롬프트로 전달된 후, ChatGPT 서버로 부터 응답을 받을 수 있다. 이와 같이 인터넷 상에서 요청-응답은 비동기적으로 처리되어야 한다. 그러므로, 다트 언어의 Future, async, await 문법을 사용한다(적색부). 참고로, OpenAI API 사용요금을 지급하지 않았다면, 이 부분은 제대로 실행되지 않을 것이다. 

나머지는 위젯 정의이다. 

다음과 같이 코딩한다. 
import 'package:flutter/material.dart';
import 'package:chat_app/models/chatMessageModel.dart';
import 'package:chat_gpt_sdk/chat_gpt_sdk.dart';

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

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

class _ChatDetailPageState extends State<ChatDetailPage> {
  List<ChatMessage> messages = [
    // Use the ChatMessage class
    ChatMessage(messageContent: "Hi.", messageType: "receiver"),
    ChatMessage(messageContent: "How have you been?", messageType: "receiver"),
    ChatMessage(messageContent: "I am doing fine. au?", messageType: "sender"),
    ChatMessage(messageContent: "it's OK.", messageType: "receiver"),
    ChatMessage(messageContent: "What's your business", messageType: "sender"),
  ];

  final TextEditingController _textController = TextEditingController();
  final ScrollController _scrollController = ScrollController();
  final FocusNode _textFocus = FocusNode();
  final _openAI = OpenAI.instance.build(
    token: '',
    baseOption: HttpSetup(
      receiveTimeout: const Duration(seconds: 15),
    ),
    enableLog: true,
  );

  Future<void> getChatResponse(ChatMessage m) async {
    final request = ChatCompleteText(
      model: GptTurbo0301ChatModel(),
      messages: [
        {'messageContent': m.messageContent}
      ],
      maxToken: 200,
    );

    try {
      final response = await _openAI.onChatCompletion(request: request);
      for (var element in response!.choices) {
        if (element.message != null) {
          setState(() {
            messages.insert(
              0,
              ChatMessage(
                messageContent: element.message!.content,
                messageType: 'receiver',
              ),
            );
          });
        }
      }

      _scrollController.animateTo(
        _scrollController.position.extentBefore + _scrollController.position.viewportDimension - 60,
        duration: const Duration(milliseconds: 500),
        curve: Curves.easeOut,
      );
    } catch (e) {
      print('Exception type: ${e.runtimeType.toString()}');
      print('Exception details:\n ${e.toString()}');
    }
  }

  void _handleSubmitted(String text) {
    _textController.clear();

    ChatMessage message = ChatMessage(
      messageContent: text,
      messageType: 'sender', // replace with the actual sender's name
    );
    setState(() {
      messages.add(message);
    });

    _scrollController.animateTo(
      _scrollController.position.extentBefore + _scrollController.position.viewportDimension - 60,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeOut,
    );

    getChatResponse(message);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        elevation: 0,
        automaticallyImplyLeading: false,
        backgroundColor: Colors.white,
        flexibleSpace: SafeArea(
          child: Container(
            padding: const EdgeInsets.only(right: 16),
            child: Row(
              children: <Widget>[
                IconButton(
                  onPressed: (){
                    Navigator.pop(context);
                  },
                  icon: const Icon(Icons.arrow_back,color: Colors.black,),
                ),
                const SizedBox(width: 2,),
                const CircleAvatar(
                  backgroundImage: NetworkImage("https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg3c35gVgzgMrzxptGpIRobw9NCrFAYv9vGtjZ4boBkinbYOtTIvdEVl-eqstSBul1MPx8N65Orf6jePjuV0w24bbvlqyV7K4xqXE2CyXQwRbh6TDU9G4qDXvDuaDeAR9k2f969UymHAM_04o49N_Rl25Qc5kVkvQk_ohS_8tQTNyP0/s220/profile.jpg"),
                  maxRadius: 20,
                ),
                const SizedBox(width: 12,),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: <Widget>[
                      const Text("mac",style: TextStyle( fontSize: 16 ,fontWeight: FontWeight.w600),),
                      const SizedBox(height: 6,),
                      Text("Online",style: TextStyle(color: Colors.grey.shade600, fontSize: 13),),
                    ],
                  ),
                ),
                const Icon(Icons.settings,color: Colors.black54,),
              ],             
            ),
          ),
        ),
      ),
      body: Stack(
        children: <Widget>[
          Scrollbar(
            controller: _scrollController,
            thumbVisibility: true,
            child: ListView.builder(
              controller: _scrollController,
              itemCount: messages.length,
              shrinkWrap: false,
              padding: const EdgeInsets.only(top: 10,bottom: 60),
              physics: const AlwaysScrollableScrollPhysics(),
              itemBuilder: (context, index){
                return Container(
                  padding: const EdgeInsets.only(left: 14,right: 14,top: 10,bottom: 10),
                  child: Align(
                    alignment: (messages[index].messageType == "receiver"?Alignment.topLeft:Alignment.topRight),
                    child: Container(
                      decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(20),
                        color: (messages[index].messageType  == "receiver"?Colors.grey.shade200:Colors.blue[200]),
                      ),
                      padding: const EdgeInsets.all(16),
                      child: Text(messages[index].messageContent, style: const TextStyle(fontSize: 15),),
                    ),
                  ),
                );
              },
            ),        
          ),
          Align(
            alignment: Alignment.bottomLeft,
            child: Container(
              padding: const EdgeInsets.only(left: 10,bottom: 10,top: 10),
              height: 60,
              width: double.infinity,
              color: Colors.white,
              child: Row(
                children: <Widget>[
                  GestureDetector(
                    onTap: (){
                    },
                    child: Container(
                      height: 30,
                      width: 30,
                      decoration: BoxDecoration(
                        color: Colors.lightBlue,
                        borderRadius: BorderRadius.circular(30),
                      ),
                      child: const Icon(Icons.add, color: Colors.white, size: 20, ),
                    ),
                  ),
                  const SizedBox(width: 15,),
                  Expanded(
                    child: TextField(
                      focusNode: _textFocus,
                      controller: _textController,
                      decoration: const InputDecoration(
                        hintText: "Write message...",
                        hintStyle: TextStyle(color: Colors.black54),
                        border: InputBorder.none
                      ),
                      onSubmitted: (text) {
                        _handleSubmitted(text);                      
                        _textFocus.requestFocus();
                      },
                    ),
                  ),
                  const SizedBox(width: 15,),
                  FloatingActionButton(
                    onPressed: (){
                      _handleSubmitted(_textController.text);
                    },
                    backgroundColor: Colors.blue,
                    elevation: 0,
                    child: const Icon(Icons.send,color: Colors.white,size: 18,),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

참고로, vscode 디버깅 시 widget inspector를 사용하면, 위젯 레이아웃 구조를 잘 살펴볼 수 있다.

실행
다 완성하였다면, 다음과 같이 vscode에서 스마프폰 애뮬레이터를 띄운다. 

F5를 눌러 실행한다. 다음과 같이 앱이 실행된다면 성공한 것이다.

마무리
이와 같이 Flutter를 사용하면, iOS, web, windows 등 멀티 플랫폼에서 실행되는 다음과 같은 UI를 손쉽게 개발할 수 있다. 
레퍼런스