⭐️ 개발/플러터

[프로젝트] 미세먼지 앱

짱구러버 2022. 12. 29. 18:30
728x90

들어가기 앞서...

1. 주의 깊게 봐야하는 점...

  • CustomScrollView 위젯 실전 사용! (스크롤 열였을때, 안열었을떄 UI 다르게)
  • Drawer 위젯 사용 (지역선택시 열리고 선택시 닫히게)
  • Dio 로 HTTP 요청 (서버에서 외부데이터 가져오기)
  • Hive 로 NOSQL 데이터 관리 (drift 를 사용해서 SQLdatabase 를 사용했지만, Hive 로 NOSQL(캐싱, 데이터저장하는법))
  • 오프라인 지원 (인터넷 안되는 상황, 기존 데이터를 보여주는 방법 간단하게 접근!)
  • 정부 오픈 API 알아보기 

본문으로...

1.  정부 api 승인받기!

 

한국환경공단_에어코리아_대기오염통계 현황

대기오염 통계 정보를 조회하기 위한 서비스로 각 측정소별 농도 정보와 기간별 통계수치 정보를 조회할 수 있다. ※ 운영계정으로 사용하고자 할 경우 에어코리아 OpenAPI 사용자 관리시스템(http

www.data.go.kr

 

dependencies:
    hive: ^2.0.6
    hive_flutter: ^1.1.0
    dio: ^4.0.4
     
dev_dependencies:
    hive_generator: ^1.1.2
    build_runner: ^2.1.7

assets:
     - asset/img/

fonts:
     - family: sunflower
       fonts:
        - asset: asset/fonts/Sunflower-Bold.ttf
          weight: 700
        - asset: asset/fonts/Sunflower-Medium.ttf
          weight: 500
        - asset: asset/fonts/Sunflower-Light.ttf
          weight: 300

2. SliverAppBar 세팅!

CustomScrollView , appBar 만들어주자

import 'package:flutter/material.dart';

import '../constant/colors.dart';

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

  @override
  Widget build(BuildContext context) {
    final ts = TextStyle(
      color: Colors.white,
      fontSize: 30.0,
    );

    return SliverAppBar(
      expandedHeight: 500,
      backgroundColor: primaryColor,
      flexibleSpace: FlexibleSpaceBar(
        background: SafeArea(
          child: Column(
            children: [
              Text(
                '서울',
                style: ts.copyWith(
                  fontSize: 40.0,
                  fontWeight: FontWeight.w700,
                ),
              ),
              Text(
                DateTime.now().toString(),
                style: ts.copyWith(
                  fontSize: 20.0,
                ),
              ),
              SizedBox(height: 20.0),
              Image.asset(
                'asset/img/mediocre.png',
                width: MediaQuery.of(context).size.width / 2,
              ),
              SizedBox(height: 20.0),
              Text(
                '보통',
                style: ts.copyWith(
                  fontSize: 40.0,
                  fontWeight: FontWeight.w700,
                ),
              ),
              SizedBox(height: 8.0),
              Text(
                '나쁘지 않네요!',
                style: ts.copyWith(
                  fontSize: 20.0,
                  fontWeight: FontWeight.w700,
                ),
              )
            ],
          ),
        ),
      ),
    );
  }
}

3. Drawer 디자인!

Scaffold(
	drawer: Drawer(), // 기본적으로 나온다.
)

 

 

margin 을 사용해서 appBar 의 시스템적으로 높이 가져와서 피하자!

여기서 잠깐!
 SafeArea(child: Container( 여기에 추가! )) 에서 maring 과 padding 을 각 각 사용했다고 치자, 여기서 차이는?
 margin 은 SafeArea 와 Container 사이에 밖의 여백을 컨트롤 해주는 것이고,
 padding 은 SafeArea 와 상관없이! Container 내부에 여백을 컨트롤 해주는 것이다. 
더보기

margin

margin 은 외부의 영역에 여백을 추가하는 것이다.

Container 외부 여백을 추가 후, backgroundColor 를 적용하면, Container 는 backgroundColor 가 적용되지만,

margin 은 외부 영역이기 때문에 backgroundColor 가 적용안된것을 볼 수 있다.

flexibleSpace: FlexibleSpaceBar(
  background: SafeArea(
    child: Container(
      color: Colors.black,
      // kToolbarHeight: 시스템적으로 기본적인 정해진 AppBar 높이를 사용할 수 있다.
      margin: EdgeInsets.only(top: kToolbarHeight),
      child: Column(
      ...

padding

padding 은 Container 내부 쪽으로 여백을 추가하는 것이기에,Container 에 backgroundColor 를 적용하면,

padding 영역까지 전부 backgroundColor 가 적용된 것을 볼 수 있다.

flexibleSpace: FlexibleSpaceBar(
        background: SafeArea(
          child: Container(
            color: Colors.black,
            // kToolbarHeight: 시스템적으로 기본적인 정해진 AppBar 높이를 사용할 수 있다.
            padding: EdgeInsets.only(top: kToolbarHeight),
            child: Column(
            ...

 

return Drawer(
      backgroundColor: darkColor,
      child: ListView(
        children: [
          DrawerHeader(
              child: Text(
            '지역 선택',
            style: TextStyle(color: Colors.white, fontSize: 20.0),
          )),
          // 리스트가 아니고 위젯이 각각 들어가야한다. cascade operator
          ...regions
              .map((e) => ListTile(
                    // 제목 색상
                    tileColor: Colors.white,
                    // 선택된 타일 색상
                    selectedTileColor: lightColor,
                    // 선택된 글씨 색상
                    selectedColor: Colors.black,
                    // 선택된 스타일 여부
                    selected: false,
                    onTap: () {},
                    title: Text(e),
                  ))
              .toList()
        ],
      ),
    );

cascade operation  란?

... 

4.  CategoryCard 제작!

공통적으로 사용하려는 카드 컴포넌트를 만들겠다.

body: CustomScrollView(
    slivers:[
        // slivers 에는 무조건 Sliver~ 라고 시작하는 위젯들만 사용하라고 강제한다.
        // 하지만 무조건 못쓰게 하진 않는다.
        // 다른 위젯들도 같이 쓸 수있게끔! 도움을 주는 위젯이 있다.
        SliverToBoxAdapter(
            child:
        ),    
    ],
)

UI 제작!

SliverToBoxAdapter(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Card(
                  margin: EdgeInsets.symmetric(horizontal: 8.0),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.all(
                      Radius.circular(16.0),
                    ),
                  ),
                  color: lightColor,
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      Container(
                        decoration: BoxDecoration(
                          color: darkColor,
                          borderRadius: BorderRadius.only(
                            topLeft: Radius.circular(16.0),
                            topRight: Radius.circular(16.0),
                          ),
                        ),
                        child: Padding(
                          padding: const EdgeInsets.all(4.0),
                          child: Text(
                            '종류별 통계',
                            style: TextStyle(
                              color: Colors.white,
                              fontWeight: FontWeight.w700,
                            ),
                            textAlign: TextAlign.center,
                          ),
                        ),
                      ),
                      Row(
                        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                        children: [
                          MainStat(
                            category: '미세먼지',
                            imgPath: 'asset/img/best.png',
                            level: '최고',
                            stat: '0㎍/㎥',
                          ),
                          MainStat(
                            category: '미세먼지',
                            imgPath: 'asset/img/best.png',
                            level: '최고',
                            stat: '0㎍/㎥',
                          ),
                          MainStat(
                            category: '미세먼지',
                            imgPath: 'asset/img/best.png',
                            level: '최고',
                            stat: '0㎍/㎥',
                          )
                        ],
                      )
                    ],
                  ),
                )
              ],
            ),
          )

 

카드 디테일 정보들 컴포넌트!

 

class MainStat extends StatelessWidget {
  // 미세먼지 / 초미세먼지 등
  final String category;

  // 아이콘 경로
  final String imgPath;

  // 오염 정도
  final String level;

  // 오염 수치
  final String stat;

  const MainStat(
      {Key? key,
      required this.category,
      required this.imgPath,
      required this.level,
      required this.stat})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    final ts = TextStyle(
      color: Colors.black,
    );

    return Column(
      children: [
        Text(
          category,
          style: ts,
        ),
        const SizedBox(
          height: 8.0,
        ),
        Image.asset(
          imgPath,
          width: 50.0,
        ),
        const SizedBox(
          height: 8.0,
        ),
        Text(
          level,
          style: ts,
        ),
        Text(
          stat,
          style: ts,
        ),
      ],
    );
  }
}

결과)

코드 정리

 

5.  !

카테고리 카드내의 수평으로 슬라이드 형식을 넣고, ListView()

스크롤을 수평으로 정의 (default : vertical 이니깐!)

1)  SizedBox() 로 높이 제한 

2) 스크롤이 가능한 위젯안에 column()  이 있다면 Expanded() 를 꼭! 사용해줘야한다.

// 높이를 정해주지 않아서 에러가 나는 것
Horizontal viewport was given unbounded height.

스크린 사이즈에 맞게

 

전체 너비의 공간을 찾고 싶은 위젯 위 에다가 적어줘야한다.

3) LayoutBuilder() : 차지하고 있는 공간을 기준으로 최대 높이와 최대 길이를 제공해준다.

LayoutBuilder(
	builder: (context, constaint) {
        constaint.maxHeight(), 최대 높이 
        constaint.maxWidth() 최대 너비 

        return Column(...)
    }
)

 

 

 

빨간 부분의 최대 전체 넓이 를 구한다음 / 3 하면 MainStat 의 위젯의 넓이를 정해줄 수 있다,

Expanded(
                child: ListView(
                  scrollDirection: Axis.horizontal,
                  physics: PageScrollPhysics(),
                  children: List.generate(
                      20,
                      (index) => MainStat(
                            category: '미세먼지',
                            imgPath: 'asset/img/best.png',
                            level: '최고',
                            stat: '0㎍/㎥',
                            width: constraint.maxWidth / 3 ,
                          )),
                ),
              )

 

6. HourlyCard 작업 및 정리!

시간별 미세먼지 카드를 만들거다. 

종륲별 통계 카드와 UI 가 같으니 공통적인 부분을 분리시켜주자!

카드 내용 부분

class MainCard extends StatelessWidget {
  final Widget child;

  const MainCard({
    Key? key,
    required this.child,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: EdgeInsets.symmetric(horizontal: 8.0),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.all(
          Radius.circular(16.0),
        ),
      ),
      color: lightColor,
      child: child,
    );
  }
}

카드 제목 부분

class CardTitle extends StatelessWidget {
  final String title;

  const CardTitle({Key? key, required this.title}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: BoxDecoration(
        color: darkColor,
        borderRadius: BorderRadius.only(
          topLeft: Radius.circular(16.0),
          topRight: Radius.circular(16.0),
        ),
      ),
      child: Padding(
        padding: const EdgeInsets.all(4.0),
        child: Text(
          title,
          style: TextStyle(
            color: Colors.white,
            fontWeight: FontWeight.w700,
          ),
          textAlign: TextAlign.center,
        ),
      ),
    );
  }
}
MainCard(
                    child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    CardTitle(
                      title: '시간별 미세먼지',
                    ),
                    Column(
                      children: List.generate(24, (index) {
                        // 현재시간
                        final now = DateTime.now();
                        // 현재 시
                        final hour = now.hour;
                        int currentHour = hour - index;

                        // 현재 시 0 보다 작으면 하루를 더한다.
                        if (currentHour < 0) {
                          currentHour += 24;
                        }
                        return Padding(
                          padding: const EdgeInsets.symmetric(
                              horizontal: 8.0, vertical: 4.0),
                          child: Row(
                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
                            children: [
                              Expanded(
                                  child: Text(
                                '$currentHour시',
                              )),
                              Expanded(
                                child: Image.asset(
                                  'asset/img/good.png',
                                  height: 20.0,
                                ),
                              ),
                              Expanded(
                                  child: Text(
                                '좋음',
                                textAlign: TextAlign.right,
                              ))
                            ],
                          ),
                        );
                      }),
                    )
                  ],
                )),

결과) 

 

 

 

 

 

7. Dio 패키지로 api 통신 !

import 'package:dio/dio.dart';

// initState 를 하기 위한 stful 로 변경
class HomeScreen extneds StatfulWidget {

}

class _HomeScreenState extends State<HomeScreen> {
  @override
  void initState() {
    super.initState();

    fetchData();
  }

  fetchData() async {
    final res = await Dio().get(
        'http://apis.data.go.kr/B552584/ArpltnStatsSvc/getCtprvnMesureLIst',
        queryParameters: {
          'serviceKey': serviceKey,
          'returnType': 'json',
          'numOfRows': 30,
          'pageNo': 1,
          'itemCode': 'PM10',
          'dataGubun': 'HOUR',
          'searchCondition': 'WEEK',
        });

    print(res.data);

api 통신 결과

{response: {body: {totalCount: 25, items: [{daegu: 46, chungnam: 43, incheon: 48, daejeon: 34, gyeongbuk: 37, sejong: 49, gwangju: 39, jeonbuk: 40, gangwon: 31, ulsan: 33, jeonnam: 34, seoul: 44, busan: 32, jeju: 25, chungbuk: 44, gyeongnam: 33, dataTime: 2022-12-30 08:00, dataGubun: 1, gyeonggi: 47, itemCode: PM10}, {daegu: 42, chungnam: 42, incheon: 48, daejeon: 32, gyeongbuk: 36, sejong: 47, gwangju: 36, jeonbuk: 40, gangwon: 31, ulsan: 30, jeonnam: 31, seoul: 43, busan: 29, jeju: 26, chungbuk: 44, gyeongnam: 29, dataTime: 2022-12-30 07:00, dataGubun: 1, gyeonggi: 45, itemCode: PM10}, {daegu: 39, chungnam: 41, incheon: 45, daejeon: 31, gyeongbuk: 36, sejong: 47, gwangju: 36, jeonbuk: 39, gangwon: 30, ulsan: 29, jeonnam: 30, seoul: 42, busan: 27, jeju: 29, chungbuk: 41, gyeongnam: 27, dataTime: 2022-12-30 06:00, dataGubun: 1, gyeonggi: 44, itemCode: PM10}, {daegu: 37, chungnam: 41, incheon: 44, daejeon: 29, gyeongbuk: 35, sejong: 47, gwangju: 36, jeonbuk: 40, gangwon: 30, ulsan: 29, <…>

 

8. 데이터 모델링!

데이터 모델링 선언!

api response 로 오는 데이터들을 플러터에서 사용하기 쉽도록 변환하는 작업이다.

// stat_mode.dart
import 'dart:convert';

// enum 선언
enum ItemCode {
  // 미세먼지
  PM10,
  // 초미세먼지
  PM25,
  // 이산화질소
  NO2,
  // 오존
  O3,
  // 일산화탄소
  CO,
  // 이황산가스
  SO2,
}

class StatModel {
  final double daegu;
  final double chungnam;
  final double incheon;
  final double daejeon;
  final double gyeongbuk;
  final double sejong;
  final double gwangju;
  final double jeonbuk;
  final double gangwon;
  final double ulsan;
  final double jeonnam;
  final double seoul;
  final double busan;
  final double jeju;
  final double chungbuk;
  final double gyeongnam;
  final DateTime dataTime;
  final ItemCode itemCode;
  final double gyeonggi;

  // fromJson() : json 형태로 데이터를 받아온다. 컨벤션이다.
  StatModel.fromJson({required Map<String, dynamic> json})
      : daegu = double.parse(json['daegu']),
        chungnam = double.parse(json['chungnam']),
        incheon = double.parse(json['incheon']),
        daejeon = double.parse(json['daejeon']),
        gyeongbuk = double.parse(json['gyeongbuk']),
        sejong = double.parse(json['sejong']),
        gwangju = double.parse(json['gwangju']),
        jeonbuk = double.parse(json['jeonbuk']),
        gangwon = double.parse(json['gangwon']),
        ulsan = double.parse(json['ulsan']),
        jeonnam = double.parse(json['jeonnam']),
        seoul = double.parse(json['seoul']),
        busan = double.parse(json['busan']),
        jeju = double.parse(json['jeju']),
        chungbuk = double.parse(json['chungbuk']),
        gyeongnam = double.parse(json['gyeongnam']),
        dataTime = DateTime.parse(json['dataTime']),
        itemCode = parseItemCode(json['itemCode']),
        gyeonggi = double.parse(json['gyeonggi']);

  static ItemCode parseItemCode(String raw) {
    // PM25 만 PM2.5 로 온다.
    if (raw == 'PM2.5') return ItemCode.PM25;

    // 원하는 방식 : ItemCode.CO.name -> 'CO'
    // 원하지 않는 방식 : ItemCode.CO.toString() -> 'ItemCode.CO'

    return ItemCode.values.firstWhere((element) => element.name == raw);
  }
}

itemCode 만  조금 수정을 해주었는데, 그 이유는 PM25 만 PM2.5 로 데이터가 오기때문이다.

데이터 에서 받아온 값 을 raw 파라미터로 받아서 ItemCode enum 으로 변환 해주는 로직 작성해준것이다.

 

api 통신 response.body.items 의 값을 만든 데이터 모델링으로 제대로 변환 을 시킨 모습이다.

print(res.data['response']['body']['items']
        .map((item) => StatModel.fromJson(json: item)));
(Instance of 'StatModel', Instance of 'StatModel', Instance of 'StatModel', ..., Instance of 'StatModel', Instance of 'StatModel')

추가로 , 데이터 모델링 null 처리 를 해줘야한다.

기획 적으로 정해져있지 않으니, null 로 오면 ?? '0' 으로 나오게 해주자

 

9.  StatusModel 제작!

어디에서는 데이터를 가져와야할때, StatRepository 에서 fetchData 함수 실행하면은 <List<StatModel>> 형식의 값을 가져올 수 있다.

1) 모델링 데이터 정리

import 'package:dio/dio.dart';
import 'package:dusty_study/constant/data.dart';
import 'package:dusty_study/model/stat_model.dart';

class StatRepository {
  static Future<List<StatModel>> fetchData() async {
    final response = await Dio().get(
        'http://apis.data.go.kr/B552584/ArpltnStatsSvc/getCtprvnMesureLIst',
        queryParameters: {
          'serviceKey': serviceKey,
          'returnType': 'json',
          'numOfRows': 30,
          'pageNo': 1,
          'itemCode': 'PM10',
          'dataGubun': 'HOUR',
          'searchCondition': 'WEEK',
        },);
    return response.data['response']['body']['items'].map<StatModel>(
      (item) => StatModel.fromJson(json: item),
    ).toList();
  }
}
void initState() {
    super.initState();

    fetchData();
  }

  fetchData() async {
    final statModels = await StatRepository.fetchData();

    print(statModels);
  }

2) statusModel 정리

status_level.dart 의 정보들을 저장하기 위해 StatusModel 을 만든것이다.

import 'package:flutter/material.dart';

class StatusModel {
  // 단계
  final int level;

  // 단계 이름
  final String label;

  // 주색상
  final Color primaryColor;

  // 어두운 색상
  final Color darkColor;

  // 밝은 색상
  final Color lightColor;

  // 폰트 색상
  final Color detailFontColor;

  // 이모티콘 이미지
  final String imagePath;

  // 코멘트
  final String comment;

  //미세먼지 최소치 (좋고 나쁨의 기준)
  final double minFineDust;

  // 초 미세먼지 최소치
  final double minUltraFineDust;

  // 오존 최소치
  final double minO3;

  // 이산화질소 최소치
  final double minNO2;

  // 일산화탄소 최소치
  final double minCO;

  // 이황산가스 최소치
  final double minSO2;

  StatusModel({
    required this.level,
    required this.label,
    required this.primaryColor,
    required this.darkColor,
    required this.lightColor,
    required this.detailFontColor,
    required this.imagePath,
    required this.comment,
    required this.minFineDust,
    required this.minUltraFineDust,
    required this.minO3,
    required this.minNO2,
    required this.minCO,
    required this.minSO2,
  });
}

 

10. StatusLevel 상수 작업하기!

/*
  * 1) 최고
  *
  * 미세먼지 : 0-15
  * 초미세먼지 : 0-8
  * 오존(O3) : 0-0.02
  * 이산화질소(NO2) : 0-0.02
  * 일산화탄소(CO) : 0-0.02
  * 아황산가스(SO2) : 0-0.01
  *
  * 2) 좋음
  *
  * 미세먼지 : 16-30
  * 초미세먼지 : 9-15
  * 오존 : 0.02 - 0.03
  * 이산화질소 : 0.02-0.03
  * 일산화탄소 : 1-2
  * 아황산가스 : 0.01-0.02
  *
  * 3) 양호
  *
  * 미세먼지 : 31-40
  * 초미세먼지 : 16-20
  * 오존 : 0.03-0.06
  * 이산화질소 : 0.03-0.05
  * 일산화탄소 : 2-5.5
  * 아황산가스 : 0.02-0.04
  *
  * 4) 보통
  *
  * 미세먼지 : 41-50
  * 초미세먼지 : 21-25
  * 오존 : 0.06-0.09
  * 이산화질소 : 0.05-0.06
  * 일산화탄소 : 5.5-9
  * 아황산가스 : 0.04-0.05
  *
  * 5) 나쁨
  *
  * 미세먼지 : 51-75
  * 초미세먼지 : 26-37
  * 오존 : 0.09-0.12
  * 이산화질소 : 0.06-0.13
  * 일산화탄소 : 9-12
  * 아황산가스 : 0.05-0.1
  *
  * 6) 상당히나쁨
  *
  * 미세먼지 : 76-100
  * 초미세먼지 : 38-50
  * 오존 : 0.12-0.15
  * 이산화질소 : 0.13-0.2
  * 일산화탄소 : 12-15
  * 아황산가스 : 0.1-0.15
  *
  * 7) 매우 나쁨
  *
  * 미세먼지 : 101-150
  * 초미세먼지 : 51-75
  * 오존 : 0.15-0.38
  * 이산화질소 : 0.2-1.1
  * 일산화탄소 : 15-32
  * 아황산가스 : 0.15-0.16
  *
  * 8) 최악
  *
  * 미세먼지 : 151~
  * 초미세먼지 : 76~
  * 오존 : 0.38~
  * 이산화질소 : 1.1~
  * 일산화탄소 : 32~
  * 아황산가스 : 0.16~
  * */
import 'package:dusty_study/model/status_model.dart';
import 'package:flutter/material.dart';

final statusLevel = [
  // 최고
  StatusModel(
    level: 0,
    label: '최고',
    primaryColor: Color(0xFF2196F3),
    darkColor: Color(0xFF0069C0),
    lightColor: Color(0xFF6EC6FF),
    detailFontColor: Colors.black,
    imagePath: 'asset/img/best.png',
    comment: '우와! 100년에 한번 오는날!',
    minFineDust: 0,
    minUltraFineDust: 0,
    minO3: 0,
    minNO2: 0,
    minCO: 0,
    minSO2: 0,
  ),
  StatusModel(
    level: 1,
    label: '좋음',
    primaryColor: Color(0xFF03a9f4),
    darkColor: Color(0xFF007ac1),
    lightColor: Color(0xFF67daff),
    detailFontColor: Colors.black,
    imagePath: 'asset/img/good.png',
    comment: '공기가 좋아요! 외부활동 해도 좋아요!',
    minFineDust: 16,
    minUltraFineDust: 9,
    minO3: 0.02,
    minNO2: 0.02,
    minCO: 1,
    minSO2: 0.01,
  ),
  StatusModel(
    level: 2,
    label: '양호',
    primaryColor: Color(0xFF00bcd4),
    darkColor: Color(0xFF008ba3),
    lightColor: Color(0xFF62efff),
    detailFontColor: Colors.black,
    imagePath: 'asset/img/ok.png',
    comment: '공기가 좋은 날이예요!',
    minFineDust: 31,
    minUltraFineDust: 16,
    minO3: 0.03,
    minNO2: 0.03,
    minCO: 2,
    minSO2: 0.02,
  ),
  StatusModel(
    level: 3,
    label: '보통',
    primaryColor: Color(0xFF009688),
    darkColor: Color(0xFF00675b),
    lightColor: Color(0xFF52c7b8),
    detailFontColor: Colors.black,
    imagePath: 'asset/img/mediocre.png',
    comment: '나쁘지 않네요!',
    minFineDust: 41,
    minUltraFineDust: 21,
    minO3: 0.06,
    minNO2: 0.05,
    minCO: 5.5,
    minSO2: 0.04,
  ),
  StatusModel(
    level: 4,
    label: '나쁨',
    primaryColor: Color(0xFFffc107),
    darkColor: Color(0xFFc79100),
    lightColor: Color(0xFFfff350),
    detailFontColor: Colors.black,
    imagePath: 'asset/img/bad.png',
    comment: '공기가 안좋아요...',
    minFineDust: 51,
    minUltraFineDust: 26,
    minO3: 0.09,
    minNO2: 0.06,
    minCO: 9,
    minSO2: 0.05,
  ),
  StatusModel(
    level: 5,
    label: '상당히 나쁨',
    primaryColor: Color(0xFFff9800),
    darkColor: Color(0xFFc66900),
    lightColor: Color(0xFFffc947),
    detailFontColor: Colors.black,
    imagePath: 'asset/img/very_bad.png',
    comment: '공기가 나빠요! 외부활동을 자제해주세요!',
    minFineDust: 76,
    minUltraFineDust: 38,
    minO3: 0.12,
    minNO2: 0.13,
    minCO: 12,
    minSO2: 0.1,
  ),
  StatusModel(
    level: 6,
    label: '매우 나쁨',
    primaryColor: Color(0xFFf44336),
    darkColor: Color(0xFFba000d),
    lightColor: Color(0xFFff7961),
    detailFontColor: Colors.black,
    imagePath: 'asset/img/worse.png',
    comment: '매우 안좋아요! 나가지 마세요!',
    minFineDust: 101,
    minUltraFineDust: 51,
    minO3: 0.15,
    minNO2: 0.2,
    minCO: 15,
    minSO2: 0.15,
  ),
  StatusModel(
    level: 7,
    label: '최악',
    primaryColor: Color(0xFF212121),
    darkColor: Color(0xFF000000),
    lightColor: Color(0xFF484848),
    detailFontColor: Colors.white,
    imagePath: 'asset/img/worst.png',
    comment: '역대급 최악의날! 집에만 계세요!',
    minFineDust: 151,
    minUltraFineDust: 76,
    minO3: 0.38,
    minNO2: 1.1,
    minCO: 32,
    minSO2: 0.16,
  ),
];

 

11. SliverAppBar 데이터 적용!

api 통신후 response 타입에 맞게 model 작성완료했고, 그 클래스 기반으로 내가 원하는 단계로 나눌 모델도 정의를 했으니, 그것에 맞게 데이터를 출력만 해주면 되는것이다.

class MainAppBar extends StatelessWidget {
  // 데이터 모델링 클래스를 기반으로 단계를 나누는 값 정의한 모델
  final StatusModel status;

  // 실제 api 통신하고 플러터로 사용하기 쉽게 만든 데이터 모델링 클래스
  final StatModel stat;

  const MainAppBar({
    Key? key,
    required this.status,
    required this.stat,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final ts = TextStyle(
      color: Colors.white,
      fontSize: 30.0,
    );

    return SliverAppBar(
      expandedHeight: 500,
      backgroundColor: status.primaryColor,
      flexibleSpace: FlexibleSpaceBar(
        background: SafeArea(
          child: Container(
            // kToolbarHeight: 시스템적으로 기본적인 정해진 AppBar 높이를 사용할 수 있다.
            margin: EdgeInsets.only(top: kToolbarHeight),
            child: Column(
              children: [
                Text(
                  '서울',
                  style: ts.copyWith(
                    fontSize: 40.0,
                    fontWeight: FontWeight.w700,
                  ),
                ),
                Text(
                // 데이터에서 받은 시간 출력 (데이터 가공)
                  getTimeFromDateTime(dateTime: stat.dataTime),
                  style: ts.copyWith(
                    fontSize: 20.0,
                  ),
                ),
                SizedBox(height: 20.0),
                Image.asset(
                // 데이터 모델에 맞는 이미지 출력
                  status.imagePath,
                  width: MediaQuery.of(context).size.width / 2,
                ),
                SizedBox(height: 20.0),
                Text(
                // 데이터 모델에 맞는 글 출력
                  status.label,
                  style: ts.copyWith(
                    fontSize: 40.0,
                    fontWeight: FontWeight.w700,
                  ),
                ),
                SizedBox(height: 8.0),
                Text(
                // 데이터 모델에 맞는 내용 출력
                  status.comment,
                  style: ts.copyWith(
                    fontSize: 20.0,
                    fontWeight: FontWeight.w700,
                  ),
                )
              ],
            ),
          ),
        ),
      ),
    );
  }

  // 시간 출력 하는 form 만듬
  String getTimeFromDateTime({required DateTime dateTime}) {
    return '${dateTime.year}-${dateTime.month}-${dateTime.day}-${getTimeFormat(dateTime.hour)}-${getTimeFormat(dateTime.minute)}';
  }
  
  // 00시 00분 으로 나오게 출력 
  String getTimeFormat(int number) {
    return number.toString().padLeft(2, '0');
  }
}
class HomeScreen extends StatefulWidget {
  const HomeScreen({Key? key}) : super(key: key);

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

class _HomeScreenState extends State<HomeScreen> {
  @override
  Future<List<StatModel>> fetchData() async {
    final statModels = await StatRepository.fetchData();

    return statModels;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: primaryColor,
      drawer: MainDrawer(),
      body: FutureBuilder<List<StatModel>>(
          future: fetchData(),
          builder: (context, snapshot) {
            if (snapshot.hasError) {
              // 에러일때!
              return Center(child: Text('에러가 있습니다.'));
            }

            if (!snapshot.hasData) {
              // 데이터가 없을때!
              return Center(
                child: CircularProgressIndicator(),
              );
            }

            List<StatModel> stats = snapshot.data!;
            StatModel recentStat = stats[0];

            // 현재 상태
            final status = statusLevel
                .where((element) => element.minFineDust < recentStat.seoul)
                .last;

            print(recentStat.seoul);

            return CustomScrollView(
              slivers: [
                MainAppBar(
                  stat: recentStat,
                  status: status,
                ),
                SliverToBoxAdapter(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      CategoryCard(),
                      const SizedBox(height: 16.0),
                      HourlyCard(),
                    ],
                  ),
                )
              ],
            );
          }),
    );
  }
}

 

12. DataUtils 추가!

api 통신 데이터 를 가공하는 하는 함수들을 한번에 모아놓는 파일을 만들거다.

import 'package:dusty_study/constant/status_level.dart';
import 'package:dusty_study/model/stat_model.dart';
import 'package:dusty_study/model/status_model.dart';

class DataUtils {
  // 현재 시간 가져오는 함수
  static String getTimeFromDateTime({required DateTime dateTime}) {
    return '${dateTime.year}-${dateTime.month}-${dateTime.day}-${getTimeFormat(
        dateTime.hour)}-${getTimeFormat(dateTime.minute)}';
  }

  // 시간 포맷(00시 00분)
  static String getTimeFormat(int number) {
    return number.toString().padLeft(2, '0');
  }

  // 단위를 받는 함수
  static String getUnitFromItemType({
    required ItemCode itemCode,
  }) {
    switch (itemCode) {
      case ItemCode.PM10:
        return '㎍/㎥';
      case ItemCode.PM25:
        return '㎍/㎥';

      default:
        return 'ppm';
    }
  }

  // item 코드를 한국어로 변경하는 함수
  static String itemCodeKrString({required ItemCode itemCode}) {
    switch (itemCode) {
      case ItemCode.PM10:
        return '미세먼지';
      case ItemCode.PM25:
        return '초미세먼지';
      case ItemCode.CO:
        return '일산화탄소';
      case ItemCode.NO2:
        return '이산화질소';
      case ItemCode.O3:
        return '오존';
      case ItemCode.SO2:
        return '아황산가스';
    }
  }

  // 현재 상태 돌려주는 함수
  static StatusModel getStatusFromItemCodeAndValue({
    required double value,
    required ItemCode itemCode,
  }) {
    return statusLevel.where((status) {
      if (itemCode == ItemCode.PM10) {
        return status.minFineDust < value;
      }
      else if (itemCode == ItemCode.PM25) {
        return status.minUltraFineDust < value;
      }
      else if (itemCode == ItemCode.CO) {
        return status.minCO < value;
      }
      else if (itemCode == ItemCode.O3) {
        return status.minO3 < value;
      }
      else if (itemCode == ItemCode.NO2) {
        return status.minNO2 < value;
      }
      else if (itemCode == ItemCode.SO2) {
        return status.minSO2 < value;
      } else {
        throw Exception('알수 없는 ItemCode 입니다.');
      }
    }).last;
  }

}

 

 

13. 지역 상태관리!

상태관리 하는 부분을 따로 관리 해주겠다.

// home_screen.dart
class _HomeScreenState extends State<HomeScreen> {
  // 지역
  String region = regions[0];
  
  ...
  drawer: MainDrawer(
  // typedef 로 선언한 함수 
  	onReionTap: (String region) {
    	setState(() {
        	this.region = region;
        })
        Navigator.of(context).pop();
    },
    // 선택한 지역
    selectedWidget:region,
  )
...
} 


// main_drawer.dart
typedef OnRegionTap = void Function(String region);

class MainDrawer extends StatelessWidget {
	// drawer 클릭 함수
	final OnRegionTap onRegiontap;
    // 선택된 지역
    final String seletedWidget;

	...
    seleted: e == selectedWidget,
    onTap: () {
    	onRegionTap(e);
    }


}

 

 

14. 모든 ItemCode 에 대한 요청 처리!

지금 까지, api 통신을 할때 itemCode 를 한가지로 하드코딩해서 한가지의 결과값만 가져왔는데, 모든 itemCode 에 대한 결과값을 가져오는 것으로 변경하려면, 외부에서 파라미터를 넘겨주면된다.

 

기존의 api 통신 itemCode 부분을 하드코딩으로 했는데, 이부분을 외부에서 받은 파라미터로 변경을 해주자

class StatRepository {
  static Future<List<StatModel>> fetchData({
  // 추가
  required ItemCode itemCode,
}) async {
    final response = await Dio().get(
        'api 주소',
        queryParameters: {
        // 외부 파라미터 변경 
          'itemCode': itemCode.name,
        },);
    return response.data['response']['body']['items'].map<StatModel>(
      (item) => StatModel.fromJson(json: item),
    ).toList();
  }
}

이제 HomeScreen 에 적어놓은 api 호출 부분에 한가지 List 형태로 받는 것이 아닌 여러가지 형태인 Map<key, values> 변경을 해줘야한다.

class _HomeScreenState extends State<HomeScreen> {
  // 지역
  String region = regions[0];

  @override
  // List<StatModel> --> <Map<ItemCode, List<StatModel>>> 로 변경 
  Future<Map<ItemCode, List<StatModel>>> fetchData() async {
  // Map<키, 벨류 값들을 List 로 받아온 그대로 넣어주면> 하나의 Map 형태가 된다고 보면 된다.
    Map<ItemCode, List<StatModel>> stats = {};
	
    // 반복문을 돌린다. 하나의 값이 아닌 map 형태로 여러개의 값이 오니깐!
    for (ItemCode itemCode in ItemCode.values) {
    // api 통신이 파라미터값 넘겨주고,
      final statModels = await StatRepository.fetchData(
        itemCode: itemCode,
      );
      // map 형태인 stats 에다가 모두 값을 넣어준다. addAll()
      stats.addAll({
        itemCode: statModels,
      });
    }

    return stats;
  }

그리고 이형태가 바뀐것에 따라서 밑에 FutureBuilder 의 제너릭도 바꿔줘야한다.

 

15.  다수의 비동기 요청 병렬로 처리하기!

이전과는 다르게 이제는 다수의 api 를 반복문을 돌려서 진행을 하다보니, api 를 총 6번 돌리고,  그 결과를 기다리고 다 받은 후에 앱이 그려진다.

이렇게 진행되면 유저가 보기에 너무 로딩시간이 길어지기 때문에, api 통신을 비동기 로 변경을 해주도록 하겠다. 

 

요청을 한번에 모아서 진행을 하겠다.

반복문안에 await 를 걸어놓으면, 값이 돌아오는것을 기다리고 그다음에 다음 반복문이 돈다.


class _HomeScreenState extends State<HomeScreen> {
  // 지역
  String region = regions[0];

  @override
  Future<Map<ItemCode, List<StatModel>>> fetchData() async {
    Map<ItemCode, List<StatModel>> stats = {};

    for (ItemCode itemCode in ItemCode.values) {
      final statModels = await StatRepository.fetchData(
        itemCode: itemCode,
      );

      stats.addAll({
        itemCode: statModels,
      });
    }

    return stats;
  }
  
  ⬇️⬇️⬇️  ⬇️⬇️⬇️  ⬇️⬇️⬇️  ⬇️⬇️⬇️  ⬇️⬇️⬇️  ⬇️⬇️⬇️  ⬇️⬇️⬇️  ⬇️⬇️⬇️  ⬇️⬇️⬇️  ⬇️⬇️⬇️
  
 
	List<Future> futures = [];
    
    for(ItemCode itemCode in ItemCode.values) {
    	futures.add(
        	StatRepositiory.fetchData(
            	itemCode: itemCode,
            ),
        );
    }
    
    final results = await Future.wait(futures);
    
    for(int i = 0; i < results.length; i ++) {
		final key = ItemCode.values[i];
        final value  results[i];
        
        stats.addAll({
        	key: value,
        });
	}
    
    return stats;

그러면 그전 보다 데이터 패칭이 빨라진것을 볼 수 있다.

 

 

16. CategoryCard !

각각 itemcode 별로 stat , status 가 필요한데 한번에 모아서 전달할수 있게 모델 하나 만들것이다.

import 'package:dusty_study/model/stat_model.dart';
import 'package:dusty_study/model/status_model.dart';

class StatAndStatusModel {
  // 미세먼지 / 초미세먼지 등 이름
  final ItemCode itemCode;
  final StatusModel status;
  final StatModel stat;

  StatAndStatusModel({
    required this.itemCode,
    required this.status,
    required this.stat,
  });
}

??? 잘 모르겠엉

 

17. !

18. HIVE 적용 !

 

//되도록이면 hive_flutter 에서 불러와라 아니면 사용못하는 기능들이 있다.
import 'package:hive_flutter/hive_flutter.dart';

// main.dart
// 박스에 이름을 지어준다.
const testBox = 'text';

// async 를 붙여준다.
void main() async {
	플러터 초기화 시켜준다.
	await Hive.initFlutter();
    // 박스를 열여준다. 
    await Hive.openBox(testBox);
    
}

 

import 'package:dusty_study/main.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';

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

  @override
  State<TestScreen> createState() => _TestScreenState();
}

class _TestScreenState extends State<TestScreen> {
  final box = Hive.box(testBox);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('TestScreen'),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Text(
            'TestScreen',
            textAlign: TextAlign.center,
          ),
          ValueListenableBuilder<Box>(
            // streamBuilder 와 같다.
              // valueListenable: Hive.box(읽고싶은 박스).listenable()
              valueListenable: Hive.box(testBox).listenable(),
              builder: (context, box, widget) {
                // box 의 상태값이 변할 때마다 builder() 가 다시 호출한다.
                print(box.values.toList());

                return Column(
                  children: box.values.map((e) => Text(e.toString())).toList(),
                );
              }),
          ElevatedButton(
            onPressed: () {
              print('key: ${box.keys.toList()}');
              print('value: ${box.values.toList()}');
            },
            child: Text('박스 프린트하기'),
          ),
          ElevatedButton(
            onPressed: () {
              // add : box 값에 순서대로 넣는다.
              // box.add(value);
              // put : 데이터 생성하거나, 업데이트 할 때! put(key, value)
              // delete(key) : 해당 key 삭제
              // deleteAt(인덱스): 해당 index 삭제
              box.put(10, 2);
            },
            child: Text('데이터 넣기'),
          ),
          ElevatedButton(
            onPressed: () {
              // key 2 를 찾는다.
              print(box.get(2));
              // 3 번째 인덱스를 찾는다.
              print(box.getAt(3));

            },
            child: Text('데이터 값 가져오기!'),
          ),
        ],
      ),
    );
  }
}

19. 어댑터 생성!

중간 역할을 하는 클래스를 만들기 위해서

StatModel 과 ItemCode 를 Hive 로 선언만해주면  자동으로 생성해준다.

flutter pub run build_runner build

모델 만들어서 등록까지 해봤다.

20. 프로젝트에 Hive 적용하기!

 

 


끝으로...

  1. 첫번째
  2. 두번째
  3. 세번째

 

728x90
댓글수0