[프로젝트] 미세먼지 앱
들어가기 앞서...

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 적용하기!
끝으로...
- 첫번째
- 두번째
- 세번째
