4주차 - API 사용법 익히기 (왓챠피디아)
PDF 파일
단축키 모음
- 새로고침
F5 - 저장
- Windows:
Ctrl+S - macOS:
command+S
- Windows:
- 전체선택
- Windows:
Ctrl+A - macOS:
command+A
- Windows:
- 잘라내기
- Windows:
Ctrl+X - macOS:
command+X
- Windows:
- 콘솔창 줄바꿈
shift+enter
- 코드정렬
- Windows:
Ctrl+Alt+L - macOS:
option+command+L
- Windows:
- 들여쓰기
Tab- 들여쓰기 취소 :
Shift+Tab
- 주석
- Windows:
Ctrl+/ - macOS:
command+/
- Windows:
- 새로고침
[수업 목표]
- API 이해하기
- 비동기 이해하기
- API를 이용한 앱 만들기
- Provider 를 이용한 상태관리 익숙해지기
- WebView 사용해보기
[목차]
01. API 이해하기
1) API는 무엇인가요?

코드스니펫을 복사해 새 탭에서 열어주세요.
-
https://www.data.go.kr/data/15084084/openapi.do
-
<aside>
💡 아래 이미지와 같이 사용 설명서를 작성해 두었는데 이를 **API 문서**라고 부릅니다. API를 이해하는데 필요한 배경 지식을 배워보도록 하겠습니다.
</aside>

<aside>
💡 그 외에도 `인스타그램 API`, `Youtube API`, `Kakao API`와 같이 검색해보시면 많은 서비스들이 API를 제공하고 있는 것을 보실 수 있습니다. (API는 유료도 있고, 무료도 있습니다)
- **[[코드스니펫] Public APIs](https://github.com/public-apis/public-apis)**
```dart
https://github.com/public-apis/public-apis
```
</aside>2) API를 이해하기 위한 배경 지식
클라이언트와 서버

프로토콜(Protocol)

HTTP
요청(Request)
URL : 목적지
](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/252f1704-ea5d-4b7f-9c88-005e92ec95e5/Untitled.png)
메소드(method) : 원하는 액션
GET : 조회
POST : 생성 / 수정 / 삭제
-
https://developer.mozilla.org/ko/docs/Web/HTTP/Methods
파라미터(Parameter)

실제 예제를 보도록 하겠습니다. 코드스니펫을 복사해서 새 탭에서 열어주세요.
-
https://en.dict.naver.com/#/search?query=hello&range=all
-
<aside>
💡 아래와 같이 `hello`가 검색된 영어 사전 페이지가 뜹니다.
주소창을 자세히 보면 `query=hello`라고 검색어가 동일하게 있는 것을 확인하실 수 있습니다.
</aside>

<aside>
💡 주소창에 `hello`를 `good`으로 변경한 뒤 엔터를 눌러보면 검색창에 `good`이 작성되어 있는 것을 보실 수 있습니다.
이와 같이 네이버 서버에 내가 알고 싶은 단어를 `query`라는 키에 값으로 전달하여 결과를 받아올 수 있습니다.
</aside>

- 응답(Response)
- 웹페이지
<aside>
💡 웹 브라우저 주소창에 `naver.com`이라고 입력하면 네이버 웹페이지를 응답해줍니다.
</aside>

- 데이터
<aside>
💡 개발자가 아닌 이상 평소에 볼 일이 없지만 웹페이지가 아닌 데이터만 응답해주기도 합니다.
</aside>
코드스니펫을 복사해 새 탭에서 접속해주세요.
- [**[코드스니펫] json 데이터 응답 URL**](https://jsonplaceholder.typicode.com/posts)
```dart
https://jsonplaceholder.typicode.com/posts
```
<aside>
💡 데이터를 보내줄 때에도 정해진 형식에 따라 주는데, 보통 `JSON`이라는 형식을 많이 사용합니다.
</aside>

- 크롬 JSON Viewer 확장 프로그램 설치
<aside>
💡 JSON 구조를 조금 더 쉽게 파악할 수 있도록 도와주는 크롬 확장 프로그램을 설치해 보도록 하겠습니다.
</aside>
코드스니펫을 복사해서 새 탭에서 열어주세요.
- **[[코드스니펫] JSON Viewer 확장 프로그램 URL](https://chrome.google.com/webstore/detail/jsonview/gmegofmjomhknnokphhckolhcffdaihd?hl=ko)**
```dart
https://chrome.google.com/webstore/detail/jsonview/gmegofmjomhknnokphhckolhcffdaihd?hl=ko
```
1. `Chrome에 추가` 버튼을 눌러주세요.

2. `확장 프로그램 추가` 버튼을 눌러서 설치를 진행해주세요.

3. JSON 샘플 웹페이지를 새로고침 해주시면 아래와 같이 좀 더 가독성 있게 JSON 구조를 볼 수 있습니다.

<aside>
💡 JSON은 Dart 문법 시간에 배운 **문자열, 숫자, 배열, 맵(딕셔너리)** 형태 구조로 이루어져 있어 쉽게 익힐 수 있습니다.
자세한 사용 방법은, 실습하면서 배워보도록 하겠습니다.
</aside>
- 상태 코드
<aside>
💡 상태 코드란 HTTP 응답시 요청이 성공했는지 실패했는지 한 번에 알 수 있는 약속된 숫자입니다.
</aside>
<aside>
💡 자주 사용하는 상태코드
200 : 성공
4xx : 잘못된 요청
5xx : 서버 문제로 실패
좀 더 자세한 상태코드 종류는 아래 링크를 참고해 주세요.
- **[[코드스니펫] HTTP 상태코드](https://developer.mozilla.org/ko/docs/Web/HTTP/Status)**
```dart
https://developer.mozilla.org/ko/docs/Web/HTTP/Status
```
</aside>
.png)3) Google Book API 문서 읽어보기
그럼 직접 API 문서를 읽어볼까요? 아래 코드스니펫을 복사해서 새 탭에서 열면 구글 책 검색 API 문서가 열립니다.
-
https://developers.google.com/books/docs/v1/reference/volumes/list
-
<aside>
💡 **요청(Request)**
- URL : `https://www.googleapis.com/books/v1/volumes`
- method : GET
- Parameter : `q=검색어`
.png)
</aside>
<aside>
💡 **응답(Response)**
- JSON

</aside>4) Google Book API 사용해보기
q라는 파라미터에고양이라는 단어를 검색하여 책 데이터를 가져왔습니다.
[코드스니펫] DartPad google 책 검색 API
https://dartpad.dev/?id=1519f6b1a7ac26cf42e4e302750650e0Run버튼을 눌러 보면 Console에 JSON 응답이 출력되는 것을 볼 수 있습니다.
02. 비동기 이해하기
1) 동기 & 비동기

비동기 방식이 소요시간은 더 짧습니다.
.png)
2) async & await
코드스니펫을 복사해 새 탭에서 붙여넣어 DartPad를 열어주세요.
-
https://dartpad.dev/?id=1984d6cae83118a401f59f8f0034c8e4
-
<aside>
💡 console을 통해 실행 순서를 보면, `print("2");`의 경우 1초 뒤 실행되기 때문에 **비동기** 방식으로 실행되어 1 → 3 → 2 순서대로 실행된 것을 볼 수 있습니다.
</aside>

<aside>
💡 위 코드를 **동기** 방식으로 실행해 1 → 2 → 3 순서대로 출력되도록 만들어 보겠습니다. 코드스니펫을 복사해서 새 탭에 붙여넣어 DartPad를 열어주세요.
- **[[코드스니펫] DartPad async & await](https://dartpad.dev/?id=83d6c5a955cc9230d3f44ea5106236a7)**
```dart
https://dartpad.dev/?id=83d6c5a955cc9230d3f44ea5106236a7
```

</aside>
<aside>
💡 **비동기** 코드인 7번째 줄 앞에 `await`을 붙이고, 해당 코드가 속해있는 `main`함수의 소괄호와 중괄호 사이에 `async`라고 적어주면 **비동기** 방식으로 실행되는 코드를 **동기** 방식으로 실행할 수 있습니다.

</aside>3) Future
코드스니펫을 복사해 새 탭에서 붙여넣어 DartPad를 열어주세요.
-
https://dartpad.dev/?id=a6734196a437a06985388c183dc78c44
-

<aside>
💡 `async`가 붙은 함수는 내부에 코드 실행이 완료되기를 기다리는 `await` 코드가 있을 수 있기 때문에, 미래에 언젠간 값을 반환한다는 의미로 반환 값의 타입을 `Future<반환타입>` 형태로 작성해 줍니다.
.png)
실행 순서를 보면 9번째 줄에 `await`이 되어있으므로, 해당 코드가 끝날 때까지 기다립니다. 따라서 13번째 반환 값은 9번 째 줄이 끝난 미래에 반환이 되므로 8번째 줄에 반환 타입을 `Future<String>` 이라고 표시한다고 이해하시면 됩니다.
</aside>
<aside>
💡 해당 함수를 호출하는 쪽에서도 `await`을 함수 앞에 붙여주면 **동기** 방식으로 결과가 응답될 때까지 기다리기 때문에 `Future`를 벗겨낸 타입으로 반환 값을 받을 수 있습니다.
.png)
</aside>4) 요약
배운 이론들을 활용해 프로젝트에서 API를 사용해 보도록 하겠습니다.
03. 프로젝트 준비 (왓챠피디아)
1) Flutter 프로젝트 생성
VSCode를 실행해주세요.
View→Command Palette를 선택해주세요.
명령어를 검색하는 팝업창이 뜨면,
flutter라고 입력한 뒤Flutter: New Project를 선택해주세요.
Application을 선택해주세요.
프로젝트를 시작할 폴더를 선택하는 화면이 나오면
flutter폴더를 선택한 뒤Select a folder to create the project in버튼을 눌러 주세요.프로젝트 이름을
watcha_pedia으로 입력해주세요.
만약 중간에 아래와 같은 팝업이 뜬다면, 체크박스를 선택한 뒤 파란 버튼을 클릭해주세요. (팝업이 안보이시면 넘어가주세요!)

다음과 같이 프로젝트가 생성되고, 다음으로 불필요한 힌트를 숨기도록 하겠습니다.
코드스니펫을 복사해서
analysis_options.yaml파일에 24번째 라인 뒤에 붙여 넣어주세요.[코드스니펫] analysis_options.yaml
prefer_const_constructors: false prefer_const_literals_to_create_immutables: false

- 어떤 의미인지 궁금하신 분들을 위해
- `main.dart` 파일을 열어보시면 파란 실선이 있습니다.

- 파란 줄은, 개선할 여지가 있는 부분을 VSCode가 알려주는 표시입니다.
12번째 라인에 마우스를 올리면 아래와 같이 설명이 뜹니다.

위젯이 변경될 일이 없기 때문에 `const`라는 키워드를 앞에 붙여 상수로 선언하라는 힌트입니다.
<aside>
💡 상수로 만들면 어떤 이점이 있나요?
상수로 선언된 위젯들은 화면을 새로 고침 할 때 해당 위젯들은 변경을 하지 않기 때문에 스킵하여 성능상 이점이 있습니다.
</aside>
아래와 같이 `Icon`앞에 `const` 키워드를 붙여주시면 됩니다.

- 지금은 학습 단계이니 눈에 띄지 않도록 해주도록 하겠습니다.
8. Provider 패키지를 시작하는 코드에서 사용하고 있으므로 패키지 설치부터 진행하도록 하겠습니다. `View` → `Terminal`을 선택해주세요.

아래 코드 스니펫을 복사해서 터미널에 붙여넣고 실행해 주세요.
- **[코드스니펫] provider 패키지 설치**
```dart
flutter pub add provider
```

`pubspec.yaml`을 열어서 39번째 라인에 `provider`가 있으면 설치가 잘 되신 겁니다.

9. 아래 `lib/main.dart` 코드스니펫을 복사해서 기존 내용을 모두 지우고, `main.dart` 파일에 붙여 넣고 저장해 주세요.
- **[코드스니펫] main.dart**
```dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'book_service.dart';
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => BookService()),
],
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: HomePage(),
);
}
}
class HomePage extends StatefulWidget {
HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
var bottomNavIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: [
SearchPage(),
LikedBookPage(),
].elementAt(bottomNavIndex),
bottomNavigationBar: BottomNavigationBar(
selectedItemColor: Colors.black,
unselectedItemColor: Colors.grey,
showUnselectedLabels: true,
selectedFontSize: 12,
unselectedFontSize: 12,
iconSize: 28,
type: BottomNavigationBarType.fixed,
onTap: (value) {
setState(() {
bottomNavIndex = value;
});
},
items: [
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: '검색',
),
BottomNavigationBarItem(
icon: Icon(Icons.star),
label: '좋아요',
),
],
currentIndex: bottomNavIndex,
),
);
}
}
class SearchPage extends StatelessWidget {
SearchPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
toolbarHeight: 80,
title: TextField(
onSubmitted: (value) {},
cursorColor: Colors.grey,
decoration: InputDecoration(
prefixIcon: Icon(Icons.search, color: Colors.grey),
hintText: "작품, 감독, 배우, 컬렉션, 유저 등",
border: OutlineInputBorder(
borderSide: BorderSide(color: Colors.white),
borderRadius: BorderRadius.all(Radius.circular(10)),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey),
borderRadius: BorderRadius.all(Radius.circular(10)),
),
),
),
),
body: Center(
child: Text("검색"),
),
);
}
}
class LikedBookPage extends StatelessWidget {
const LikedBookPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text("좋아요"),
),
);
}
}
```
10. 그리고 `lib` 폴더 밑에 `book.dart`와 `book_service.dart` 파일도 만들어주신 뒤 아래 코드스니펫을 붙여 넣어주세요.
<aside>
💡 `Book` 클래스는 책에 대한 정보를 담을 클래스 입니다.
`BookService`는 `Book`에 대한 CRUD를 담당하는 클래스 입니다.
</aside>
- **[코드스니펫] book.dart**
```dart
class Book {}
```
- **[코드스니펫] book_service.dart**
```dart
import 'package:flutter/material.dart';
import 'book.dart';
class BookService extends ChangeNotifier {
List<Book> bookList = []; // 책 목록
}
```
최종적으로 `lib` 폴더 밑에 아래와 같이 파일들이 있으면 됩니다.
2) 에뮬레이터 실행하기
VSCode 우측 하단에
Chrome (web-javascript)를 클릭해주세요.
(에뮬레이터가 이미 실행중이라면 3번으로 이동해 주세요.)
Start Pixel 2 API 29 mobile emulator를 선택해주세요.
잠시 기다리면 Android 에뮬레이터가 실행됩니다.

VSCode 우측 상단에 아래 화살표를 눌러
Run Without Debugging을 눌러주세요.
에뮬레이터에 아래와 같이 탭으로 전환되는 화면이 나오면 준비 완료!

04. API 연결하기
구현 목표

1) 통신 패키지 dio 설치하기
코드스니펫을 복사해 새 탭에서 열어주세요.
[코드스니펫] Pub.dev / dio / Installing
https://pub.dev/packages/dio/install
dio 패키지의 Installing 페이지가 열리면
flutter pub add dio우측 아이콘을 눌러서 복사해주세요.
VSCode
View→Terminal을 선택해주세요.
Terminal창에 복사한flutter pub add dio를 붙여넣고 엔터를 눌러 실행해 주세요.
아래와 같이 나오면 정상적으로 작동한 것입니다.
pubspec.yaml파일을 열어서 아래와 같이 40번째 라인에dio가 있으면 설치가 완료된 것입니다.
2) dio 사용법
아래 코드는
GET메소드로URL로 요청을 보내는 코드입니다. dio 패키지는 매우 직관적이고 쉽게 사용할 수 있습니다.main() { Dio().get("URL"); }HTTP 요청은 응답까지 시간이 걸리기 때문에 비동기 코드입니다. 따라서 동기 방식으로 작동하게 하려면 아래와 같이
async&await을 추가해야 합니다.main() async { Response result = await Dio().get("URL"); print(result.data); // data 안에 응답 내용이 들어 있습니다. }
3) Google Book API 응답값 자세히 보기
- 일단 API를 호출해 보도록 하겠습니다. 코드스니펫을 복사해 새 탭에서 열어주세요.
-
https://www.googleapis.com/books/v1/volumes?q=%EA%B3%A0%EC%96%91%EC%9D%B4

<aside>
💡 위와 같이 GET방식으로 해당 URL로 요청시 JSON 응답이 옵니다.
`thumbnail` 이라고 적힌 부분에 파란 링크를 클릭해보시면 책 표지 사진이 나옵니다.

</aside>
<aside>
💡 Google Book API에 대한 자세한 사항은 아래 코드스니펫 링크를 참고해주세요.
- **[[코드스니펫] Google Book API 문서](https://developers.google.com/books/docs/v1/reference/volumes/list)**
```dart
https://developers.google.com/books/docs/v1/reference/volumes/list
```
</aside>
1. 주소창에 `q=고양이` 라고 되어있는 부분을 `q=dog`로 변경해 보면 아래와 같은 결과가 나옵니다.

`totalItems` 라는 요소 안에 조회된 책의 전채 개수가 나오는 것을 확인할 수 있습니다.
`items` 안에 있는 토글 버튼(화살표 표시) 를 눌러 요소를 두개 접어봅시다. `items` 안에 각각의 책에 대한 정보가 마치 `Map` 과 같은 형태로 담겨 있습니다.

<aside>
💡 위 JSON 데이터의 구조는 Dart 문법에서 나오는 `List`와 `Map`의 조합과 같다고 보셔도 좋습니다.
| 자료형 | 설명 | 예시 |
| --- | --- | --- |
| List | 배열 | [1, 2, 3] |
| Map | {key : value} 형태의 자료형 | {
'name': '철수',
'age': 20
} |
</aside>
<aside>
💡 예를 들어 3번째 책의 `title` 을 가져오고 싶다면 아래와 같은 문법으로 데이터에 접근하면 됩니다.
```dart
// data 에 위 json 데이터가 담겨온다고 가정
String title = data['items'][2]['volumeInfo']['title']
print(title) // "Everyday Dog"
```
</aside>4) Book 클래스 수정하기
json 데이터의 값들을 활용해
Book객체를 만들고 이를 화면에 띄워주는 과정이 필요하겠죠.먼저 Book 클래스가
id,title,subtitle,thumbnail,previewLink요소를 가질 수 있게끔book.dart파일을 수정해줍니다. 코드스니펫을 복사해book.dart파일 내의 내용을 모두 지우고 붙여넣어주세요.[코드스니펫] book.dart
class Book { String id; String title; String subtitle; String thumbnail; // 썸네일 이미지 링크 String previewLink; // ListTile 을 눌렀을 때 이동하는 링크 Book({ required this.id, required this.title, required this.subtitle, required this.thumbnail, required this.previewLink, }); }
5) Google Book API 호출하기
키보드에서 완료 버튼을 누르면,
TextField에 입력된 문자열을 인자로 받는onSubmitted함수가 실행됩니다.
BookService내에 검색 로직을 추가한 뒤, 이를onSubmitted에서 호출하도록 하겠습니다.아래 코드스니펫을 복사해
book_service.dart6번째 줄 맨 뒤에 붙여넣어주세요.[코드스니펫] 책 목록 search 함수
void search(String q) async { if (q.isNotEmpty) { Response res = await Dio().get( "https://www.googleapis.com/books/v1/volumes?q=$q&startIndex=0&maxResults=40", ); List items = res.data["items"]; print(items); } }

3. 에러를 해결하겠습니다. 10번째 줄의 `Dio()` 을 클릭한 뒤 Quick Fix(`Ctrl/Cmd + .`)를 눌러 `Import library ‘package:dio/dio.dart’` 를 선택해주세요.

4. 첫번째 줄에 Import 구문이 추가되며 에러가 해결되었습니다. 아까 설치해준 `Dio` 패키지를 이제 사용할 수 있습니다.

5. `SearchPage` 로 돌아와 `BookService` 의 `search` 함수를 사용할 수 있도록 해봅시다.
저번 시간에 배운 `Provider` 내용을 복기해봅시다. Service 내의 변수나 함수에 접근하기 위해서는 아래 두가지 방법을 사용합니다.
<aside>
💡 Provider 에서 Service 를 사용하는 방법
1. `Consumer<클래스명>` : 클래스 정보 갱신시 화면을 새로고침 해야 할 때 사용
2. `context.read<클래스명>` : 1회성으로 클래스 접근할 때 사용 (화면 새로고침이 필요 없을 때)
</aside>
우리는 검색이 일어날 때마다 화면에 책 목록을 다시 그려줄 것이므로, `Consumer` 를 사용해야 합니다.
`main.dart` 의 79번째 줄의 `Scaffold` 를 누르고, 왼쪽의 전구를 클릭해 `Wrap with Builder` 를 선택합니다.

아래와 같이 `Scaffold` 위젯을 `Builder` 위젯이 감쌉니다. `Consumer` 위젯과 형식이 비슷한 `Builder` 를 사용해 모양을 잡아줬습니다.

6. 아래 이미지와 같이 79번째 라인의 `Builder`를 `Consumer<BookService>`로 변경해주세요.
`context` 뒤에 `bookService` 와 `child` 도 추가해줍니다.

그리고 줄 정렬을 예쁘게 해주기 위해 105번째 라인의 중괄호와 소괄호 사이에 콤마(`,`)를 찍은 뒤 저장해주세요.

저장하면 코드가 정렬됩니다.
<aside>
💡 **복습:** `Consumer<BookService>`는 위젯 트리를 타고 올라가 **Provider**로 등록된 **BookService**를 찾습니다. 찾은 **BookService**를 80번째 라인의 두 번째 파라미터인 `bookService` 에 담아줍니다.
우리는 `bookService` 변수를 통해 **BookService** 안에 있는 변수에 접근해 수정하고, 화면을 새로고침할 수 있습니다.
</aside>
<aside>
💡 `BookService`에서 값을 변경하고 `notifyListeners();`를 호출하면, 해당 서비스를 `Consumer`로 등록한 모든 위젯의 `builder` 함수가 재호출 되면서 화면이 갱신 됩니다.
</aside>
7. 86번째 줄 `onSubmitted` 의 중괄호 사이에 아래 코드스니펫을 붙여넣습니다.
- **[코드스니펫] TextField 의 onSubmitted**
```dart
bookService.search(value);
```
저장하면 아래 이미지와 같이 코드가 정렬됩니다.

8. 이제 키보드에 값을 입력하고 Debug Console 을 확인해볼까요? VS Code 에서 `View → Debug Console` 를 눌러 콘솔을 켜줍니다.

아래와 같이 dog 라는 키워드를 입력하고 검색을 해보면 콘솔에 리스트가 출력되는 것을 확인할 수 있습니다.

이 리스트의 정체는 무엇일까요? 아까 **BookService** 에 만들어둔 `search` 함수를 찬찬히 뜯어봅시다.

응답으로 온 데이터 (`res.data`) 에 `items` 라는 Key 로 Value 를 가져오는 코드네요!
`res.data` 에 담겨오는 데이터는 바로…

아까 보았던 **json 데이터**입니다. `totalItems` 아래에 `items` 에 있는 데이터가 우리가 만들어둔 `items` (`book_service.dart` 14번째 줄) 변수에 List 형식으로 담긴 것입니다.6) JSON 파싱(parsing)
콘솔에서는 JSON 구조가 잘 안보이니, 샘플을 보면서 원하는 데이터만 뽑아보도록 하겠습니다. 코드스니펫을 복사해 새 탭에서 열어주세요.
-
https://www.googleapis.com/books/v1/volumes?q=dog&startIndex=0&maxResults=40
-

items 에서 맨 위 두개의 요소는 토글 버튼을 눌러 접어뒀습니다.
보시는 바와 같이 큰 `Map(맵)` 내에 items 라는 `key` 의 `value` 로 `List(배열)` 가 있고, 이 안에 다시 `Map(맵)`이 들어 있는 형태입니다.
<aside>
💡 List 안에 여러개의 `Map`이 들어있습니다. 따라서 **반복문**을 통해 해당 `List`에 있는 모든 `Map`을 각각 `Book` 클래스의 객체로 만들어 `bookList` 에 추가해주면 되겠군요!
</aside>

2. 데이터를 잘 가져오는 데 성공했으니, 이제는 이를 사용하기 편하게 `Book` 클래스의 객체로 만들겠습니다. 위에서 `book.dart` 에 아래와 같이 `Book` 클래스를 만들었습니다. 코드를 다시 볼까요?

Book 클래스의 생성자(8번째 줄)를 보면 어떤 값들을 넣어줘야 하는지 알 수 있습니다. `id`, `title`, `subtitle`, `thumbnail`, `previewLink` 에 값을 넣어 Book 클래스의 객체를 만들어줍시다.
아래 코드스니펫을 복사해 `book_service.dart` 의 15번째 줄을 지우고 붙여넣어주세요.
- **[코드스니펫] for 문 / Book 객체를 만들고 bookList 에 추가하는 함수**
```dart
for (Map<String, dynamic> item in items) {
Book book = Book(
id: item['id'],
title: item['volumeInfo']['title'] ?? "",
subtitle: item['volumeInfo']['subtitle'] ?? "",
thumbnail: item['volumeInfo']['imageLinks']?['thumbnail'] ??
"https://thumbs.dreamstime.com/b/no-image-available-icon-flat-vector-no-image-available-icon-flat-vector-illustration-132482953.jpg",
previewLink: item['volumeInfo']['previewLink'] ?? "",
);
bookList.add(book);
}
```

?? 는 해당 변수에 담긴 값이 null일 경우, 즉 값이 없을 경우 뒤에 오는 값을 사용하겠다는 뜻입니다.
`item` 이라는 변수에 담긴 `Map` 에서는 key 값으로 데이터들을 가져올 수 있습니다.
아래 json 데이터의 `items` 리스트 안에 포함된 각각의 요소가 위 코드의 `item` 변수에 들어갔다고 생각하시면 됩니다.

만들어진 Book 객체는 25번째 줄의 `bookList.add` 를 통해 `bookList` 에 추가됩니다.
3. 매번 검색을 할 때마다 `bookList` 에 담긴 값을 모두 비워줘야 해당 검색어에 대한 결과만을 볼 수 있겠죠. 아래 코드스니펫을 복사해 `book_service.dart` 9번째 줄 맨 뒤에 붙여넣습니다.
- **[코드스니펫] bookList 비우기**
```dart
bookList.clear(); // 검색 버튼 누를때 이전 데이터들을 지워주기
```

<aside>
💡 `BookService`에서 Google Book API 데이터를 가져와 `bookList`에 저장하는 것까지 완성했습니다.
</aside>7) 검색 결과 보여주기
ListView.builder를 사용해 bookList 내의 요소들을 화면에 보여주도록 하겠습니다.아래 코드스니펫을 복사해
main.dart파일의 104-106 번째 줄을 지우고 붙여넣어주세요.[코드스니펫] SearchPage / ListView.builder
body: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ListView.separated( itemCount: bookService.bookList.length, separatorBuilder: (context, index) { return Divider(); }, itemBuilder: (context, index) { Book book = bookService.bookList.elementAt(index); return ListTile(); }, ), ),

2. 112번째 줄에 있는 Book 을 클릭해 아래 사진처럼 Quick Fix(`Ctrl/Cmd + .`)를 누른 뒤 `Import library ‘book.dart’` 를 선택합니다.

4번째 줄에 Import 문이 생기며 에러가 사라졌습니다.

3. 이제 ListTile 내에 Book의 내용을 보여주도록 하겠습니다. ListTile 의 소괄호 사이에 `title: Text(book.title)` 라고 입력해보세요.

위와 같이 검색 결과가 에뮬레이터에 잘 표시되는 것을 확인할 수 있습니다.
4. 이제 나머지 요소들도 화면에 보여주도록 하겠습니다. 아래 코드스니펫을 복사해 114번째 줄을 **지우고** 붙여넣어주세요.
- **[코드스니펫] ListTile**
```dart
return ListTile(
onTap: () {},
leading: Image.network(
book.thumbnail,
fit: BoxFit.fitHeight,
),
title: Text(
book.title,
style: TextStyle(fontSize: 16),
),
subtitle: Text(
book.subtitle,
style: TextStyle(color: Colors.grey),
),
trailing: IconButton(
onPressed: () {},
icon: Icon(Icons.star_border),
),
);
```

`ListTile` 의 각 요소는 아래와 같이 배치됩니다.

다른 검색어를 입력해보세요. 검색이 잘 구현되었나요?
화면에 검색 결과가 뜨지 않는군요. 이는 검색을 통해 `bookList` 는 수정되었지만, 이후 화면을 **새로고침하지 않아서** 생기는 문제입니다.
`notifyListeners();` 를 `book_service.dart` 의 29번째 줄 아래에 추가해줍니다.
(위치에 주의해주세요!)

<aside>
💡 `notifyListeners();` 는 해당 `Service` 의 `Consumer` 로 등록된 모든 위젯의 `builder` 함수를 재호출해 화면을 새로고침합니다.
</aside>

5. 이제 검색 결과는 잘 나오지만, `Debug Console` 에 아래와 같은 에러가 발생하고 있습니다.

이는 `BookService` 의 `search` 함수에서 이전 데이터를 지워줄 때 순간적으로 `bookList` 가 비어서 생기는 문제입니다.

아래 코드스니펫을 복사해 `main.dart` 112번째 줄 뒤에 붙여넣어주세요.
- **[코드스니펫] bookList 가 비어있을 때 처리**
```dart
if (bookService.bookList.isEmpty) return SizedBox();
```

`bookList` 가 비어있을 때는 `elementAt` 함수를 호출하지 않고 바로 return 하도록 해 `bookList` 의 `range` 를 벗어나는 인덱스로 접근이 일어나지 않도록 합니다.
이제 에러 없이 잘 동작합니다!05. 좋아요 구현하기
구현 목표
1) 파일 분리
main.dart 의 115번째 줄에 있는 ListTile 을 우클릭해
Refactor(Ctrl + Shift + R)을 선택하고,
Extract Widget을 선택해주세요.
위젯의 이름을 입력하는 칸이 나오면 아래와 같이
BookTile이라고 적은 후 엔터를 눌러줍니다.
ListTile내에 있던 코드가BookTile이라는 이름의 별도의 위젯으로 분리된 것을 확인할 수 있습니다.
2)
BookService에 좋아요 기능 추가먼저 좋아요 한 책들을 담는 List 를 만들겠습니다.
아래 코드스니펫을 복사해
book_service.dart7번째 줄 맨 뒤에 붙여넣어주세요.[코드스니펫] likedBookList
List<Book> likedBookList = [];

2. 이제 **좋아요 아이콘을 누르면 호출될 함수**를 추가하겠습니다.
<aside>
💡 해당 함수에서 구현되어야 할 기능은 아래와 같습니다.
- **좋아요가 눌러져 있지 않은 경우 (`likedBookList` 에 없는 경우)
⇒** 좋아요 추가 (`likedBookList` 에 추가)
- **좋아요가 이미 눌러져있다면 (`likedBookList` 에 있는 경우)
⇒** 좋아요 취소 (`likedBookList` 에서 제거)
</aside>
아래 코드스니펫을 복사해 `book_service.dart` 8번째 줄 맨 뒤에 붙여넣어주세요.
- **[코드스니펫] toggleLikeBook**
```dart
void toggleLikeBook({required Book book}) {
String bookId = book.id;
if (likedBookList.map((book) => book.id).contains(bookId)) {
likedBookList.removeWhere((book) => book.id == bookId);
} else {
likedBookList.add(book);
}
notifyListeners();
}
```

<aside>
💡 위 코드들에 대한 설명입니다.
`**likedBookList.map((book) => book.id)**` : likedBookList 의 요소들을 순회하며 id 들만 뽑아 새로 iterable(List와 비슷) 을 만듭니다.
`**likedBookList.removeWhere((book) => book.id == bookId)`** : toggleLikeBook 함수가 인자로 받는 book 과 id 가 같은 Book 이 likedBookList 내에 있다면 제거합니다.
`**notifyListeners`** : 화면을 새로고침합니다.
</aside>
<aside>
💡 왜 `**likedBookList.contains(book)`** 과 같이 작성하지 않을까요?
클래스를 통해 생성된 두개의 객체는 처음에 대입해주는 값들(Book 에서는 title, subtitle 등)이 같더라도, 서로 다른 존재입니다.
코드로 작성해보면 아래와 같습니다.
```dart
var book1 = Book(id: "1", title: "톰 소여의 모험");
var book2 = Book(id: "1", title: "톰 소여의 모험");
print(book1 == book2) // false
```
우리가 작성한 코드에서도 똑같은 일이 일어납니다. 서로 다른 객체로 인식하기 때문에 contains 는 항상 false 를 반환할 수밖에 없습니다.
```dart
var likedBookList = [Book(id: "1", title: "톰 소여의 모험")];
var book2 = Book(id: "1", title: "톰 소여의 모험");
print(likedBookList.contains(book2)) // false
```
이를 해결하기 위해서 `==` 연산자를 덮어씌워 비교 로직을 새로 작성하거나, `Equatable` 등의 플러그인 등을 사용하기도 합니다. 핵심은 두개의 객체가 같은 데이터를 가리키고 있는지를 어떤 식으로 알아낼 것이냐는 점입니다.
우리는 Google Book API 가 제공하는 id (고유한 값입니다) 를 이용해서 비교 로직을 작성했습니다.
`**likedBookList.map((book) => book.id)**` 와 같이 id 들이 담긴 iterable(리스트와 유사합니다) 를 만들고, 이것이 우리가 좋아요 버튼을 누른 Book 의 id 를 포함하는지 비교해, 이미 좋아요가 눌린 책인지 아닌지를 구분할 수 있겠죠!
</aside>
3. `ListTile` 의 trailing 에 있는 좋아요 버튼을 누르면 `toggleLikeBook` 함수를 호출하고, 좋아요 여부에 따라 아이콘의 색깔을 바꿔주도록 하겠습니다.
먼저 위에서 분리한 `BookTile` 위젯에서 `BookService` 를 사용할 수 있도록 해야합니다. 다시 Provider 사용법을 복기해볼까요?
<aside>
💡 Provider 에서 Service 를 사용하는 방법
1. `Consumer<클래스명>` : 클래스 정보 갱신시 화면을 새로고침 해야 할 때 사용
2. `context.read<클래스명>` : 1회성으로 클래스 접근할 때 사용 (화면 새로고침이 필요 없을 때)
</aside>
우리가 만든 `BookTile` 은 좋아요를 누를 때마다 trailing 에 있는 아이콘의 색깔이 바뀌어야 합니다. 즉 새로고침이 일어나는 위젯입니다.
하지만, `BookTile` 을 포함하고 있는 **SearchPage** 가 `Consumer`로 감싸져 있기 때문에, `BookTile` 은 별개의 `Consumer` 로 감싸지 않더라도 함께 새로고침이 일어나게 됩니다.
그러므로, `BookTile` 에서는 `context.read` 만 사용해서 BookService 에 접근해주겠습니다.
아래 코드스니펫을 복사해 main.dart 134번째 줄 맨 뒤에 붙여넣어주세요
- **[코드스니펫] context.read<BookService>**
```dart
BookService bookService = context.read<BookService>();
```

이제 `bookService` 라는 이름으로 `BookService` 클래스에 있는 변수와 함수를 사용할 수 있습니다.
아래 코드스니펫을 복사해 `main.dart` 의 152, 153번째 줄을 지우고 붙여넣어주세요.
- **[코드스니펫] ListTile trailing 아이콘**
```dart
onPressed: () {
bookService.toggleLikeBook(book: book);
},
icon: bookService.likedBookList.map((book) => book.id).contains(book.id)
? Icon(
Icons.star,
color: Colors.amber,
)
: Icon(Icons.star_border),
```

<aside>
💡 위 코드들에 대한 설명입니다.
`**bookService.toggleLikeBook(book: book)**` : BookService 에 생성해준 toggleLikeBook 에 해당 book 데이터를 인자로 넘겨 호출해줍니다.
`**bookService.likedBookList.map((book) => book.id).contains(book.id)`** : bookService 에 있는 likedBookList(좋아요 한 책들 담겨있는 리스트) 가 해당 book 을 담고있는지, 즉 이미 좋아요를 누른 상태인지 확인합니다.
아래 삼항연산자 (조건 ? a : b) 를 통해 좋아요가 눌러진 상태라면 노란색 별 아이콘을 보여줍니다.
</aside>
아래와 같이 좋아요를 누를 때 화면이 잘 갱신되는 것을 볼 수 있습니다.
3)
LikedBookPage에서 좋아요 한 책들 모아보기먼저 LikedBookPage 에서도 likedBookList 의 변화에 따라 화면이 갱신되도록 해야합니다.
Consumer를 사용해Scaffold를 감싸주겠습니다. 171번째 줄의 Scaffold 를 클릭한 뒤 Quick Fix(Ctrl/Cmd + .)를 눌러Wrap with Builder를 선택해주세요.
아래와 같이
Scaffold위젯을Builder위젯이 감쌉니다.Consumer위젯과 형식이 비슷한Builder를 사용해 모양을 잡아줬습니다.
아래 이미지와 같이 79번째 라인의
Builder를Consumer<BookService>로 변경해주세요.context뒤에bookService와child도 추가해줍니다.
그리고 줄 정렬을 예쁘게 해주기 위해 177번째 라인의 중괄호와 소괄호 사이(위 화살표)에 콤마(
,)를 찍은 뒤 저장해주세요. 저장하면 코드가 정렬됩니다.
아래 코드스니펫을 복사해
main.dart의 174-176 번째 줄을 지우고 붙여넣어주세요. 위 SearchPage 에 작성한 코드를 그대로 가져왔습니다.[코드스니펫] LikedBookPage / BookTile
body: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ListView.separated( itemCount: bookService.bookList.length, separatorBuilder: (context, index) { return Divider(); }, itemBuilder: (context, index) { if (bookService.bookList.isEmpty) return SizedBox(); Book book = bookService.bookList.elementAt(index); return BookTile(book: book); }, ), ),

좋아요 한 책만 모아보기 위해서는 위에 밑줄친 `bookList` 대신에 `likedBookList` 를 사용하면 되겠죠! 모두 아래와 같이 수정해봅시다.

이제 좋아요 탭에서 아래와 같이 좋아요 누른 책들을 모아볼 수 있습니다.
06. 책 상세 페이지 WebView로 보여주기
구현 목표
1) WebView 란?
대표적인 예시가 네이버 앱입니다. 앱에 들어가보시면 네이버 모바일 웹사이트와 거의 동일한 UI 를 볼 수 있습니다. 껍데기 부분만 앱으로 만들고 내부 위젯에 WebView 를 사용해 웹사이트를 그대로 띄우는 방식으로 구현되어 있다는 것을 알 수 있겠죠!

이외에도 당근마켓, 토스와 같은 앱들도 내부 기능 일부를 WebView 를 활용해 구현했습니다.
2) webview_flutter 패키지 설치하기
해당 패키지 사이트는 아래와 같습니다.
[코드스니펫] Pub.dev / webview_flutter
https://pub.dev/packages/webview_flutter
강의 영상과 조금 다른 부분이니 주의해서 봐주세요! webview_flutter 패키지가 업데이트 되면서 아래 강의 내용대로 진행하면 에러가 뜹니다. 이전 버전 패키지를 설치함으로서 이 에러를 해결해주도록 하겠습니다
아래 코드스니펫을 복사해주세요.
[코드스니펫] webview_flutter 3.0.4 버전 설치 스크립트
flutter pub add webview_flutter:^3.0.4
VSCode
View→Terminal을 선택해주세요.
Terminal창에 복사한flutter pub add webview_flutter:^3.0.4를 붙여넣고 엔터를 눌러 실행해 주세요.
아래와 같이 나오면 정상적으로 작동한 것입니다.
pubspec.yaml파일을 열어서 아래와 같이 41번째 라인에webview_flutter: ^3.0.4가 있으면 설치가 완료된 것입니다.
이제 아래부터는 강의 내용과 같습니다!
android/app/build.gradle파일을 열고(경로에 유의하세요!)
50 번째 줄의flutter.minSdkVersion을19로 수정해줍니다.
android { defaultConfig { minSdkVersion 19 } }아래와 같이 작성해주면 됩니다.

webview_flutter 를 앱에서 사용하기 위해서는 앱을 재설치해야합니다.
먼저 앱을 정지하고

main.dart를 열고 우측 상단에서Run Without Debugging을 클릭해 다시 앱을 설치 / 실행해줍니다.
<aside>
💡 WebView 를 앱에서 사용할 준비가 완료되었습니다.
</aside>3) WebViewPage 위젯 만들기
webview_flutter 의 설치가 끝났으니, 이제 각 Book 의
previewLink로 이동하는 기능을 구현해보겠습니다.
먼저 WebViewPage 라는 StatelessWidget 을 하나 만들어주겠습니다. 아래 코드스니펫을 복사해 192번째 줄 맨 뒤에 붙여넣어주세요.
[코드스니펫] WebViewPage
class WebViewPage extends StatelessWidget { WebViewPage({super.key, required this.url}); String url; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Colors.grey, title: Text(url), ), body: WebView(initialUrl: url), ); } }

2. 206번째 줄에 에러가 발생했군요. 에러가 발생한 `WebView` 를 클릭한 뒤 Quick Fix(`Ctrl/Cmd + .`)를 누르고, `Import library ‘package:webview_flutter/webview_flutter.dart` 를 선택해주세요.

3번째 줄에 Import 문이 추가되면서 에러가 해결됩니다.

3. 코드를 자세히 뜯어볼까요?

**WebViewPage** 위젯으로 이동하면서 url 이라는 인자를 넘겨받고, 이를 `WebView` 위젯에 다시 인자로 넘겨 웹페이지를 `Scaffold` 의 body 내에 띄워주는 코드입니다.4) 웹 URL 연결하기
아래 코드스니펫을 복사해
ListTile의onTap함수 중괄호 사이 (139번째 줄) 에 붙여넣어줍니다.[코드스니펫] BookTile / ListTile / onTap
Navigator.push( context, MaterialPageRoute( builder: (context) => WebViewPage( url: book.previewLink.replaceFirst("http", "https"), ), ), );

`ListTile` 을 누르면 방금 만든 **WebViewPage** 로 이동하도록 했습니다. 이 때 해당 book의 `previewLink` 을 인자로 전달해, 해당 링크를 웹뷰로 열도록 합니다.
<aside>
💡 Book 의 `previewLink` 의 경우 http 로 시작하기 때문에 보안 정책상 웹뷰로 열리지 않습니다. 이 때문에 `replaceFirst`를 사용해 해당 url 의 http 를 https 로 바꿔주었습니다.
http, https 에 대한 보다 자세한 내용이 궁금하시다면 아래 링크를 참고해주세요.
[**HTTP와 HTTPS 차이점**](https://brunch.co.kr/@hyoi0303/10)
</aside>
이제 에뮬레이터에서 검색 후에 각각의 BookTile 을 클릭해봅시다.
아래와 같이 앱 내에서 해당 웹페이지를 잘 보여주는 것을 확인할 수 있습니다.
최종 코드
main.dartimport 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'book.dart'; import 'book_service.dart'; void main() { runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (context) => BookService()), ], child: const MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: HomePage(), ); } } class HomePage extends StatefulWidget { HomePage({Key? key}) : super(key: key); @override State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { var bottomNavIndex = 0; @override Widget build(BuildContext context) { return Scaffold( body: [ SearchPage(), LikedBookPage(), ].elementAt(bottomNavIndex), bottomNavigationBar: BottomNavigationBar( selectedItemColor: Colors.black, unselectedItemColor: Colors.grey, showUnselectedLabels: true, selectedFontSize: 12, unselectedFontSize: 12, iconSize: 28, type: BottomNavigationBarType.fixed, onTap: (value) { setState(() { bottomNavIndex = value; }); }, items: [ BottomNavigationBarItem( icon: Icon(Icons.search), label: '검색', ), BottomNavigationBarItem( icon: Icon(Icons.star), label: '좋아요', ), ], currentIndex: bottomNavIndex, ), ); } } class SearchPage extends StatelessWidget { SearchPage({super.key}); @override Widget build(BuildContext context) { return Consumer<BookService>( builder: (context, bookService, child) { return Scaffold( appBar: AppBar( backgroundColor: Colors.white, toolbarHeight: 80, title: TextField( onSubmitted: (value) { bookService.search(value); }, cursorColor: Colors.grey, decoration: InputDecoration( prefixIcon: Icon(Icons.search, color: Colors.grey), hintText: "작품, 감독, 배우, 컬렉션, 유저 등", border: OutlineInputBorder( borderSide: BorderSide(color: Colors.white), borderRadius: BorderRadius.all(Radius.circular(10)), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: Colors.grey), borderRadius: BorderRadius.all(Radius.circular(10)), ), ), ), ), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ListView.separated( itemCount: bookService.bookList.length, separatorBuilder: (context, index) { return Divider(); }, itemBuilder: (context, index) { if (bookService.bookList.isEmpty) return SizedBox(); Book book = bookService.bookList.elementAt(index); return BookTile(book: book); }, ), ), ); }, ); } } class BookTile extends StatelessWidget { const BookTile({ Key? key, required this.book, }) : super(key: key); final Book book; @override Widget build(BuildContext context) { BookService bookService = context.read<BookService>(); return ListTile( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => WebViewPage( url: book.previewLink.replaceFirst("http", "https"), ), ), ); }, leading: Image.network( book.thumbnail, fit: BoxFit.fitHeight, ), title: Text( book.title, style: TextStyle(fontSize: 16), ), subtitle: Text( book.subtitle, style: TextStyle(color: Colors.grey), ), trailing: IconButton( onPressed: () { bookService.toggleLikeBook(book: book); }, icon: bookService.likedBookList.map((book) => book.id).contains(book.id) ? Icon( Icons.star, color: Colors.amber, ) : Icon(Icons.star_border), ), ); } } class LikedBookPage extends StatelessWidget { const LikedBookPage({super.key}); @override Widget build(BuildContext context) { return Consumer<BookService>( builder: (context, bookService, child) { return Scaffold( body: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ListView.separated( itemCount: bookService.likedBookList.length, separatorBuilder: (context, index) { return Divider(); }, itemBuilder: (context, index) { if (bookService.likedBookList.isEmpty) return SizedBox(); Book book = bookService.likedBookList.elementAt(index); return BookTile(book: book); }, ), ), ); }, ); } } class WebViewPage extends StatelessWidget { WebViewPage({super.key, required this.url}); String url; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Colors.grey, title: Text(url), ), body: WebView(initialUrl: url), ); } }book.dartclass Book { String id; String title; String subtitle; String thumbnail; // 썸네일 이미지 링크 String previewLink; // ListTile 을 눌렀을 때 이동하는 링크 Book({ required this.id, required this.title, required this.subtitle, required this.thumbnail, required this.previewLink, }); }book_service.dartimport 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'book.dart'; class BookService extends ChangeNotifier { List<Book> bookList = []; // 책 목록 List<Book> likedBookList = []; void toggleLikeBook({required Book book}) { String bookId = book.id; if (likedBookList.map((book) => book.id).contains(bookId)) { likedBookList.removeWhere((book) => book.id == bookId); } else { likedBookList.add(book); } notifyListeners(); } void search(String q) async { bookList.clear(); // 검색 버튼 누를때 이전 데이터들을 지워주기 if (q.isNotEmpty) { Response res = await Dio().get( "https://www.googleapis.com/books/v1/volumes?q=$q&startIndex=0&maxResults=40", ); List items = res.data["items"]; for (Map<String, dynamic> item in items) { Book book = Book( id: item['id'], title: item['volumeInfo']['title'] ?? "", subtitle: item['volumeInfo']['subtitle'] ?? "", thumbnail: item['volumeInfo']['imageLinks']?['thumbnail'] ?? "https://thumbs.dreamstime.com/b/no-image-available-icon-flat-vector-no-image-available-icon-flat-vector-illustration-132482953.jpg", previewLink: item['volumeInfo']['previewLink'] ?? "", ); bookList.add(book); } } notifyListeners(); } }
07. 숙제 - 왓챠피디아 추가기능 구현
1) Google Book API 에서 제공하는 다른 정보 보여주기
1) 구현 목표
2) 숙제 답안
main.dartimport 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'book.dart'; import 'book_service.dart'; void main() { runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (context) => BookService()), ], child: const MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: HomePage(), ); } } class HomePage extends StatefulWidget { HomePage({Key? key}) : super(key: key); @override State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { var bottomNavIndex = 0; @override Widget build(BuildContext context) { return Scaffold( body: [ SearchPage(), LikedBookPage(), ].elementAt(bottomNavIndex), bottomNavigationBar: BottomNavigationBar( selectedItemColor: Colors.black, unselectedItemColor: Colors.grey, showUnselectedLabels: true, selectedFontSize: 12, unselectedFontSize: 12, iconSize: 28, type: BottomNavigationBarType.fixed, onTap: (value) { setState(() { bottomNavIndex = value; }); }, items: [ BottomNavigationBarItem( icon: Icon(Icons.search), label: '검색', ), BottomNavigationBarItem( icon: Icon(Icons.star), label: '좋아요', ), ], currentIndex: bottomNavIndex, ), ); } } class SearchPage extends StatelessWidget { SearchPage({super.key}); @override Widget build(BuildContext context) { return Consumer<BookService>( builder: (context, bookService, child) { return Scaffold( appBar: AppBar( backgroundColor: Colors.white, toolbarHeight: 80, title: TextField( onSubmitted: (value) { bookService.search(value); }, cursorColor: Colors.grey, decoration: InputDecoration( prefixIcon: Icon(Icons.search, color: Colors.grey), hintText: "작품, 감독, 배우, 컬렉션, 유저 등", border: OutlineInputBorder( borderSide: BorderSide(color: Colors.white), borderRadius: BorderRadius.all(Radius.circular(10)), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: Colors.grey), borderRadius: BorderRadius.all(Radius.circular(10)), ), ), ), ), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ListView.separated( itemCount: bookService.bookList.length, separatorBuilder: (context, index) { return Divider(); }, itemBuilder: (context, index) { if (bookService.bookList.isEmpty) return SizedBox(); Book book = bookService.bookList.elementAt(index); return BookTile(book: book); }, ), ), ); }, ); } } class BookTile extends StatelessWidget { const BookTile({ Key? key, required this.book, }) : super(key: key); final Book book; @override Widget build(BuildContext context) { BookService bookService = context.read<BookService>(); return ListTile( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => WebViewPage( url: book.previewLink.replaceFirst("http", "https"), ), ), ); }, leading: Image.network( book.thumbnail, fit: BoxFit.fitHeight, ), title: Text( book.title, style: TextStyle(fontSize: 16), ), subtitle: Text( "${book.authors.join(", ")}\n${book.publishedDate}", style: TextStyle(color: Colors.grey), ), trailing: IconButton( onPressed: () { bookService.toggleLikeBook(book: book); }, icon: bookService.likedBookList.map((book) => book.id).contains(book.id) ? Icon( Icons.star, color: Colors.amber, ) : Icon(Icons.star_border), ), ); } } class LikedBookPage extends StatelessWidget { const LikedBookPage({super.key}); @override Widget build(BuildContext context) { return Consumer<BookService>( builder: (context, bookService, child) { return Scaffold( body: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ListView.separated( itemCount: bookService.likedBookList.length, separatorBuilder: (context, index) { return Divider(); }, itemBuilder: (context, index) { if (bookService.likedBookList.isEmpty) return SizedBox(); Book book = bookService.likedBookList.elementAt(index); return BookTile(book: book); }, ), ), ); }, ); } } class WebViewPage extends StatelessWidget { WebViewPage({super.key, required this.url}); String url; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Colors.grey, title: Text(url), ), body: WebView(initialUrl: url), ); } }book.dartclass Book { String id; String title; String subtitle; List authors; String publishedDate; String thumbnail; // 썸네일 이미지 링크 String previewLink; // ListTile 을 눌렀을 때 이동하는 링크 Book({ required this.id, required this.title, required this.subtitle, required this.authors, required this.publishedDate, required this.thumbnail, required this.previewLink, }); }book_service.dartimport 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'book.dart'; class BookService extends ChangeNotifier { List<Book> bookList = []; // 책 목록 List<Book> likedBookList = []; void toggleLikeBook({required Book book}) { String bookId = book.id; if (likedBookList.map((book) => book.id).contains(bookId)) { likedBookList.removeWhere((book) => book.id == bookId); } else { likedBookList.add(book); } notifyListeners(); } void search(String q) async { bookList.clear(); // 검색 버튼 누를때 이전 데이터들을 지워주기 if (q.isNotEmpty) { Response res = await Dio().get( "https://www.googleapis.com/books/v1/volumes?q=$q&startIndex=0&maxResults=40", ); List items = res.data["items"]; for (Map<String, dynamic> item in items) { Book book = Book( id: item['id'], title: item['volumeInfo']['title'] ?? "", subtitle: item['volumeInfo']['subtitle'] ?? "", authors: item['volumeInfo']['authors'] ?? [], publishedDate: item['volumeInfo']['publishedDate'] ?? "", thumbnail: item['volumeInfo']['imageLinks']?['thumbnail'] ?? "https://thumbs.dreamstime.com/b/no-image-available-icon-flat-vector-no-image-available-icon-flat-vector-illustration-132482953.jpg", previewLink: item['volumeInfo']['previewLink'] ?? "", ); bookList.add(book); } } notifyListeners(); } }
2) 좋아요 누른 책 목록 기기에 저장하기
1) 구현 목표
2) 숙제 답안
main.dartimport 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'book.dart'; import 'book_service.dart'; late SharedPreferences prefs; void main() async { WidgetsFlutterBinding.ensureInitialized(); prefs = await SharedPreferences.getInstance(); runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (context) => BookService()), ], child: const MyApp(), ), ); } class MyApp extends StatelessWidget { const MyApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, home: HomePage(), ); } } class HomePage extends StatefulWidget { HomePage({Key? key}) : super(key: key); @override State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { var bottomNavIndex = 0; @override Widget build(BuildContext context) { return Scaffold( body: [ SearchPage(), LikedBookPage(), ].elementAt(bottomNavIndex), bottomNavigationBar: BottomNavigationBar( selectedItemColor: Colors.black, unselectedItemColor: Colors.grey, showUnselectedLabels: true, selectedFontSize: 12, unselectedFontSize: 12, iconSize: 28, type: BottomNavigationBarType.fixed, onTap: (value) { setState(() { bottomNavIndex = value; }); }, items: [ BottomNavigationBarItem( icon: Icon(Icons.search), label: '검색', ), BottomNavigationBarItem( icon: Icon(Icons.star), label: '좋아요', ), ], currentIndex: bottomNavIndex, ), ); } } class SearchPage extends StatelessWidget { SearchPage({super.key}); @override Widget build(BuildContext context) { return Consumer<BookService>( builder: (context, bookService, child) { return Scaffold( appBar: AppBar( backgroundColor: Colors.white, toolbarHeight: 80, title: TextField( onSubmitted: (value) { bookService.search(value); }, cursorColor: Colors.grey, decoration: InputDecoration( prefixIcon: Icon(Icons.search, color: Colors.grey), hintText: "작품, 감독, 배우, 컬렉션, 유저 등", border: OutlineInputBorder( borderSide: BorderSide(color: Colors.white), borderRadius: BorderRadius.all(Radius.circular(10)), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide(color: Colors.grey), borderRadius: BorderRadius.all(Radius.circular(10)), ), ), ), ), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ListView.separated( itemCount: bookService.bookList.length, separatorBuilder: (context, index) { return Divider(); }, itemBuilder: (context, index) { if (bookService.bookList.isEmpty) return SizedBox(); Book book = bookService.bookList.elementAt(index); return BookTile(book: book); }, ), ), ); }, ); } } class BookTile extends StatelessWidget { const BookTile({ Key? key, required this.book, }) : super(key: key); final Book book; @override Widget build(BuildContext context) { BookService bookService = context.read<BookService>(); return ListTile( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) => WebViewPage( url: book.previewLink.replaceFirst("http", "https"), ), ), ); }, leading: Image.network( book.thumbnail, fit: BoxFit.fitHeight, ), title: Text( book.title, style: TextStyle(fontSize: 16), ), subtitle: Text( "${book.authors.join(", ")}\n${book.publishedDate}", style: TextStyle(color: Colors.grey), ), trailing: IconButton( onPressed: () { bookService.toggleLikeBook(book: book); }, icon: bookService.likedBookList.map((book) => book.id).contains(book.id) ? Icon( Icons.star, color: Colors.amber, ) : Icon(Icons.star_border), ), ); } } class LikedBookPage extends StatelessWidget { const LikedBookPage({super.key}); @override Widget build(BuildContext context) { return Consumer<BookService>( builder: (context, bookService, child) { return Scaffold( body: Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: ListView.separated( itemCount: bookService.likedBookList.length, separatorBuilder: (context, index) { return Divider(); }, itemBuilder: (context, index) { if (bookService.likedBookList.isEmpty) return SizedBox(); Book book = bookService.likedBookList.elementAt(index); return BookTile(book: book); }, ), ), ); }, ); } } class WebViewPage extends StatelessWidget { WebViewPage({super.key, required this.url}); String url; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( backgroundColor: Colors.grey, title: Text(url), ), body: WebView(initialUrl: url), ); } }book.dartclass Book { String id; String title; String subtitle; List authors; String publishedDate; String thumbnail; // 썸네일 이미지 링크 String previewLink; // ListTile 을 눌렀을 때 이동하는 링크 Book({ required this.id, required this.title, required this.subtitle, required this.authors, required this.publishedDate, required this.thumbnail, required this.previewLink, }); Map toJson() { return { "id": id, "title": title, "subtitle": subtitle, "authors": authors, "publishedDate": publishedDate, "thumbnail": thumbnail, "previewLink": previewLink, }; } factory Book.fromJson(json) { return Book( id: json['id'], title: json['title'], subtitle: json['subtitle'], authors: json['authors'], publishedDate: json['publishedDate'], thumbnail: json['thumbnail'], previewLink: json['previewLink'], ); } }book_service.dartimport 'dart:convert'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'book.dart'; import 'main.dart'; class BookService extends ChangeNotifier { BookService() { loadLikedBookList(); } List<Book> bookList = []; // 책 목록 List<Book> likedBookList = []; void toggleLikeBook({required Book book}) { String bookId = book.id; if (likedBookList.map((book) => book.id).contains(bookId)) { likedBookList.removeWhere((book) => book.id == bookId); } else { likedBookList.add(book); } notifyListeners(); saveLikedBookList(); } void search(String q) async { bookList.clear(); // 검색 버튼 누를때 이전 데이터들을 지워주기 if (q.isNotEmpty) { Response res = await Dio().get( "https://www.googleapis.com/books/v1/volumes?q=$q&startIndex=0&maxResults=40", ); List items = res.data["items"]; for (Map<String, dynamic> item in items) { Book book = Book( id: item['id'], title: item['volumeInfo']['title'] ?? "", subtitle: item['volumeInfo']['subtitle'] ?? "", authors: item['volumeInfo']['authors'] ?? [], publishedDate: item['volumeInfo']['publishedDate'] ?? "", thumbnail: item['volumeInfo']['imageLinks']?['thumbnail'] ?? "https://thumbs.dreamstime.com/b/no-image-available-icon-flat-vector-no-image-available-icon-flat-vector-illustration-132482953.jpg", previewLink: item['volumeInfo']['previewLink'] ?? "", ); bookList.add(book); } } notifyListeners(); } saveLikedBookList() { List likedBookJsonList = likedBookList.map((book) => book.toJson()).toList(); // [{"content": "1"}, {"content": "2"}] String jsonString = jsonEncode(likedBookJsonList); // '[{"content": "1"}, {"content": "2"}]' prefs.setString('likedBookList', jsonString); } loadLikedBookList() { String? jsonString = prefs.getString('likedBookList'); // '[{"content": "1"}, {"content": "2"}]' if (jsonString == null) return; // null 이면 로드하지 않음 List likedBookJsonList = jsonDecode(jsonString); // [{"content": "1"}, {"content": "2"}] likedBookList = likedBookJsonList.map((json) => Book.fromJson(json)).toList(); } }