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>
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/775c9eae-15a7-4ec8-8460-872d1be881c9/Untitled.png)
<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 : 목적지
메소드(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>
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/5cd59c04-73e0-4835-a87f-5d2ed76b52de/Untitled.png)
<aside>
💡 주소창에 `hello`를 `good`으로 변경한 뒤 엔터를 눌러보면 검색창에 `good`이 작성되어 있는 것을 보실 수 있습니다.
이와 같이 네이버 서버에 내가 알고 싶은 단어를 `query`라는 키에 값으로 전달하여 결과를 받아올 수 있습니다.
</aside>
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/4eccae2b-8d7c-4853-9727-6aa11471031e/Untitled.png)
- 응답(Response)
- 웹페이지
<aside>
💡 웹 브라우저 주소창에 `naver.com`이라고 입력하면 네이버 웹페이지를 응답해줍니다.
</aside>
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/fd350dfc-c579-47d0-84ba-f17fc10c0152/Untitled.png)
- 데이터
<aside>
💡 개발자가 아닌 이상 평소에 볼 일이 없지만 웹페이지가 아닌 데이터만 응답해주기도 합니다.
</aside>
코드스니펫을 복사해 새 탭에서 접속해주세요.
- [**[코드스니펫] json 데이터 응답 URL**](https://jsonplaceholder.typicode.com/posts)
```dart
https://jsonplaceholder.typicode.com/posts
```
<aside>
💡 데이터를 보내줄 때에도 정해진 형식에 따라 주는데, 보통 `JSON`이라는 형식을 많이 사용합니다.
</aside>
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/cfe18aff-da00-43a9-a7be-cff6184a29a8/Untitled.png)
- 크롬 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에 추가` 버튼을 눌러주세요.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/276bbe51-2df3-4a77-862d-2835f367844d/Untitled.png)
2. `확장 프로그램 추가` 버튼을 눌러서 설치를 진행해주세요.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/775d4ffb-9a25-4f1f-aa8c-2df5eca4719a/Untitled.png)
3. JSON 샘플 웹페이지를 새로고침 해주시면 아래와 같이 좀 더 가독성 있게 JSON 구조를 볼 수 있습니다.
![Screen Shot 2022-09-18 at 6.46.32 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/a00eaad3-598a-48ac-8d11-945349a45da6/Screen_Shot_2022-09-18_at_6.46.32_PM.png)
<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>
![_http (1).png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/2c13ec2d-379c-4fc3-a46c-50cb451dc2a7/_http_(1).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=검색어`
![bookApi (1).png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/c691e45b-6c91-421c-9d0c-0934dca56b7a/bookApi_(1).png)
</aside>
<aside>
💡 **응답(Response)**
- JSON
![bookApiResponse.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/8a170df0-5846-422f-8537-30f84495a4ac/bookApiResponse.png)
</aside>
4) Google Book API 사용해보기
q
라는 파라미터에고양이
라는 단어를 검색하여 책 데이터를 가져왔습니다.[코드스니펫] DartPad google 책 검색 API
https://dartpad.dev/?id=1519f6b1a7ac26cf42e4e302750650e0
Run
버튼을 눌러 보면 Console에 JSON 응답이 출력되는 것을 볼 수 있습니다.
02. 비동기 이해하기
1) 동기 & 비동기
비동기 방식이 소요시간은 더 짧습니다.
2) async & await
코드스니펫을 복사해 새 탭에서 붙여넣어 DartPad를 열어주세요.
-
https://dartpad.dev/?id=1984d6cae83118a401f59f8f0034c8e4
-
<aside>
💡 console을 통해 실행 순서를 보면, `print("2");`의 경우 1초 뒤 실행되기 때문에 **비동기** 방식으로 실행되어 1 → 3 → 2 순서대로 실행된 것을 볼 수 있습니다.
</aside>
![Screen Shot 2022-09-18 at 7.25.47 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/809b2f90-9be0-4de6-b914-9dd36351a865/Screen_Shot_2022-09-18_at_7.25.47_PM.png)
<aside>
💡 위 코드를 **동기** 방식으로 실행해 1 → 2 → 3 순서대로 출력되도록 만들어 보겠습니다. 코드스니펫을 복사해서 새 탭에 붙여넣어 DartPad를 열어주세요.
- **[[코드스니펫] DartPad async & await](https://dartpad.dev/?id=83d6c5a955cc9230d3f44ea5106236a7)**
```dart
https://dartpad.dev/?id=83d6c5a955cc9230d3f44ea5106236a7
```
![Screen Shot 2022-09-18 at 7.26.18 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/1c50e0ca-cc6f-490d-bca8-7be11c43ce32/Screen_Shot_2022-09-18_at_7.26.18_PM.png)
</aside>
<aside>
💡 **비동기** 코드인 7번째 줄 앞에 `await`을 붙이고, 해당 코드가 속해있는 `main`함수의 소괄호와 중괄호 사이에 `async`라고 적어주면 **비동기** 방식으로 실행되는 코드를 **동기** 방식으로 실행할 수 있습니다.
![async & await.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/b94b2f90-89c9-46b8-b7fd-dd65473a2c70/async__await.png)
</aside>
3) Future
코드스니펫을 복사해 새 탭에서 붙여넣어 DartPad를 열어주세요.
-
https://dartpad.dev/?id=a6734196a437a06985388c183dc78c44
-
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/abee377c-58b1-456b-b175-1c45e2f2f4fd/Untitled.png)
<aside>
💡 `async`가 붙은 함수는 내부에 코드 실행이 완료되기를 기다리는 `await` 코드가 있을 수 있기 때문에, 미래에 언젠간 값을 반환한다는 의미로 반환 값의 타입을 `Future<반환타입>` 형태로 작성해 줍니다.
![future (1).png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/30ef794c-2efd-4d92-b66a-4976bc6c1fd6/future_(1).png)
실행 순서를 보면 9번째 줄에 `await`이 되어있으므로, 해당 코드가 끝날 때까지 기다립니다. 따라서 13번째 반환 값은 9번 째 줄이 끝난 미래에 반환이 되므로 8번째 줄에 반환 타입을 `Future<String>` 이라고 표시한다고 이해하시면 됩니다.
</aside>
<aside>
💡 해당 함수를 호출하는 쪽에서도 `await`을 함수 앞에 붙여주면 **동기** 방식으로 결과가 응답될 때까지 기다리기 때문에 `Future`를 벗겨낸 타입으로 반환 값을 받을 수 있습니다.
![future (2).png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/9d1e291a-87c2-448a-92e7-2a80bb2b730f/future_(2).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
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/8470956b-ccc2-4d75-86d8-83042bafaf22/Untitled.png)
- 어떤 의미인지 궁금하신 분들을 위해
- `main.dart` 파일을 열어보시면 파란 실선이 있습니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/d1393a08-a362-4be3-bd9b-835e184f5ef3/Untitled.png)
- 파란 줄은, 개선할 여지가 있는 부분을 VSCode가 알려주는 표시입니다.
12번째 라인에 마우스를 올리면 아래와 같이 설명이 뜹니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/7fd5cee4-d935-4d2d-9c35-e11d4f3c6cae/Untitled.png)
위젯이 변경될 일이 없기 때문에 `const`라는 키워드를 앞에 붙여 상수로 선언하라는 힌트입니다.
<aside>
💡 상수로 만들면 어떤 이점이 있나요?
상수로 선언된 위젯들은 화면을 새로 고침 할 때 해당 위젯들은 변경을 하지 않기 때문에 스킵하여 성능상 이점이 있습니다.
</aside>
아래와 같이 `Icon`앞에 `const` 키워드를 붙여주시면 됩니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/2aef9ee8-c4ef-4c42-bd08-d358c4620341/Untitled.png)
- 지금은 학습 단계이니 눈에 띄지 않도록 해주도록 하겠습니다.
8. Provider 패키지를 시작하는 코드에서 사용하고 있으므로 패키지 설치부터 진행하도록 하겠습니다. `View` → `Terminal`을 선택해주세요.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/db3f319b-cb6e-4e78-96ce-52685882adbe/Untitled.png)
아래 코드 스니펫을 복사해서 터미널에 붙여넣고 실행해 주세요.
- **[코드스니펫] provider 패키지 설치**
```dart
flutter pub add provider
```
![Screen Shot 2022-09-18 at 9.52.23 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/28d0f310-52b4-4da2-b179-4890f65467eb/Screen_Shot_2022-09-18_at_9.52.23_PM.png)
`pubspec.yaml`을 열어서 39번째 라인에 `provider`가 있으면 설치가 잘 되신 겁니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/89172053-32a7-489d-837d-e51351df92bf/Untitled.png)
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` 폴더 밑에 아래와 같이 파일들이 있으면 됩니다.
![Screen Shot 2022-09-18 at 9.38.21 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/b4bf8f96-ef8e-4164-9d2b-d25cd968b318/Screen_Shot_2022-09-18_at_9.38.21_PM.png)
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
![Screen Shot 2022-09-18 at 11.49.35 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f722ad7b-42b5-4cae-948d-b4d0112d1de0/Screen_Shot_2022-09-18_at_11.49.35_PM.png)
<aside>
💡 위와 같이 GET방식으로 해당 URL로 요청시 JSON 응답이 옵니다.
`thumbnail` 이라고 적힌 부분에 파란 링크를 클릭해보시면 책 표지 사진이 나옵니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/1171cf1d-9e93-46aa-bd43-7605fd043ef4/Untitled.png)
</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`로 변경해 보면 아래와 같은 결과가 나옵니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/8ad130cb-92db-4864-b2cf-c56eba35b8de/Untitled.png)
`totalItems` 라는 요소 안에 조회된 책의 전채 개수가 나오는 것을 확인할 수 있습니다.
`items` 안에 있는 토글 버튼(화살표 표시) 를 눌러 요소를 두개 접어봅시다. `items` 안에 각각의 책에 대한 정보가 마치 `Map` 과 같은 형태로 담겨 있습니다.
![Screen Shot 2022-09-19 at 12.01.06 AM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/ad41601b-38c4-434b-80fb-8abd97b0bcc0/Screen_Shot_2022-09-19_at_12.01.06_AM.png)
<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, }); }
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/8c770840-ef46-4dbf-9606-bc3e5a3f735c/Untitled.png)
5) Google Book API 호출하기
키보드에서 완료 버튼을 누르면,
TextField
에 입력된 문자열을 인자로 받는onSubmitted
함수가 실행됩니다.BookService
내에 검색 로직을 추가한 뒤, 이를onSubmitted
에서 호출하도록 하겠습니다.아래 코드스니펫을 복사해
book_service.dart
6번째 줄 맨 뒤에 붙여넣어주세요.[코드스니펫] 책 목록 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); } }
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/6883dea5-fbac-4b66-b500-decdb75b40e7/Untitled.png)
3. 에러를 해결하겠습니다. 10번째 줄의 `Dio()` 을 클릭한 뒤 Quick Fix(`Ctrl/Cmd + .`)를 눌러 `Import library ‘package:dio/dio.dart’` 를 선택해주세요.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/725da2df-eb4e-4a8e-aa89-2bbb683c6f97/Untitled.png)
4. 첫번째 줄에 Import 구문이 추가되며 에러가 해결되었습니다. 아까 설치해준 `Dio` 패키지를 이제 사용할 수 있습니다.
![Screen Shot 2022-09-19 at 1.49.06 AM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/55679400-1d20-455b-b18a-d1d0689e6b47/Screen_Shot_2022-09-19_at_1.49.06_AM.png)
5. `SearchPage` 로 돌아와 `BookService` 의 `search` 함수를 사용할 수 있도록 해봅시다.
저번 시간에 배운 `Provider` 내용을 복기해봅시다. Service 내의 변수나 함수에 접근하기 위해서는 아래 두가지 방법을 사용합니다.
<aside>
💡 Provider 에서 Service 를 사용하는 방법
1. `Consumer<클래스명>` : 클래스 정보 갱신시 화면을 새로고침 해야 할 때 사용
2. `context.read<클래스명>` : 1회성으로 클래스 접근할 때 사용 (화면 새로고침이 필요 없을 때)
</aside>
우리는 검색이 일어날 때마다 화면에 책 목록을 다시 그려줄 것이므로, `Consumer` 를 사용해야 합니다.
`main.dart` 의 79번째 줄의 `Scaffold` 를 누르고, 왼쪽의 전구를 클릭해 `Wrap with Builder` 를 선택합니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/d76c4285-7faf-48bf-bfc7-77155363ea95/Untitled.png)
아래와 같이 `Scaffold` 위젯을 `Builder` 위젯이 감쌉니다. `Consumer` 위젯과 형식이 비슷한 `Builder` 를 사용해 모양을 잡아줬습니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/945394a9-d0d2-4e15-a04b-799356ca346e/Untitled.png)
6. 아래 이미지와 같이 79번째 라인의 `Builder`를 `Consumer<BookService>`로 변경해주세요.
`context` 뒤에 `bookService` 와 `child` 도 추가해줍니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/e172af43-21dc-4931-badb-859a84bfa6c8/Untitled.png)
그리고 줄 정렬을 예쁘게 해주기 위해 105번째 라인의 중괄호와 소괄호 사이에 콤마(`,`)를 찍은 뒤 저장해주세요.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/505edd08-2861-4d71-86d6-c0263d2c040b/Untitled.png)
저장하면 코드가 정렬됩니다.
<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);
```
저장하면 아래 이미지와 같이 코드가 정렬됩니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/c1260462-b5e2-4366-9dd5-9afb0d152096/Untitled.png)
8. 이제 키보드에 값을 입력하고 Debug Console 을 확인해볼까요? VS Code 에서 `View → Debug Console` 를 눌러 콘솔을 켜줍니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/25bb78ef-a5f0-4bde-b411-db7bc6775fd1/Untitled.png)
아래와 같이 dog 라는 키워드를 입력하고 검색을 해보면 콘솔에 리스트가 출력되는 것을 확인할 수 있습니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/a3ff8ec6-b13c-4c28-b29d-deb8d2dd6eb6/Untitled.png)
이 리스트의 정체는 무엇일까요? 아까 **BookService** 에 만들어둔 `search` 함수를 찬찬히 뜯어봅시다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/64154232-0b2d-445f-8f9c-2f0c809f573f/Untitled.png)
응답으로 온 데이터 (`res.data`) 에 `items` 라는 Key 로 Value 를 가져오는 코드네요!
`res.data` 에 담겨오는 데이터는 바로…
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/e4040b39-0799-4785-9d80-0d7b3cece952/Untitled.png)
아까 보았던 **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 에서 맨 위 두개의 요소는 토글 버튼을 눌러 접어뒀습니다.](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/e4040b39-0799-4785-9d80-0d7b3cece952/Untitled.png)
items 에서 맨 위 두개의 요소는 토글 버튼을 눌러 접어뒀습니다.
보시는 바와 같이 큰 `Map(맵)` 내에 items 라는 `key` 의 `value` 로 `List(배열)` 가 있고, 이 안에 다시 `Map(맵)`이 들어 있는 형태입니다.
<aside>
💡 List 안에 여러개의 `Map`이 들어있습니다. 따라서 **반복문**을 통해 해당 `List`에 있는 모든 `Map`을 각각 `Book` 클래스의 객체로 만들어 `bookList` 에 추가해주면 되겠군요!
</aside>
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/fb6daa22-f1c6-444d-b43b-25ab9c6ab172/Untitled.png)
2. 데이터를 잘 가져오는 데 성공했으니, 이제는 이를 사용하기 편하게 `Book` 클래스의 객체로 만들겠습니다. 위에서 `book.dart` 에 아래와 같이 `Book` 클래스를 만들었습니다. 코드를 다시 볼까요?
![Screen Shot 2022-09-19 at 2.49.25 AM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/01adf851-c3fb-47c5-80f2-c89b62727396/Screen_Shot_2022-09-19_at_2.49.25_AM.png)
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일 경우, 즉 값이 없을 경우 뒤에 오는 값을 사용하겠다는 뜻입니다.](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/e2a12f29-d6a8-4ba3-9d23-f32e7ad59a0f/Untitled.png)
?? 는 해당 변수에 담긴 값이 null일 경우, 즉 값이 없을 경우 뒤에 오는 값을 사용하겠다는 뜻입니다.
`item` 이라는 변수에 담긴 `Map` 에서는 key 값으로 데이터들을 가져올 수 있습니다.
아래 json 데이터의 `items` 리스트 안에 포함된 각각의 요소가 위 코드의 `item` 변수에 들어갔다고 생각하시면 됩니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/92325eba-d64c-475c-837e-8f00d3d89d7d/Untitled.png)
만들어진 Book 객체는 25번째 줄의 `bookList.add` 를 통해 `bookList` 에 추가됩니다.
3. 매번 검색을 할 때마다 `bookList` 에 담긴 값을 모두 비워줘야 해당 검색어에 대한 결과만을 볼 수 있겠죠. 아래 코드스니펫을 복사해 `book_service.dart` 9번째 줄 맨 뒤에 붙여넣습니다.
- **[코드스니펫] bookList 비우기**
```dart
bookList.clear(); // 검색 버튼 누를때 이전 데이터들을 지워주기
```
![Screen Shot 2022-09-19 at 3.34.27 AM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/3b9d33a1-ded3-45f4-bc71-e77d6494239b/Screen_Shot_2022-09-19_at_3.34.27_AM.png)
<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(); }, ), ),
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/44ac70cb-05dc-4a2c-8a7f-c5b51d604413/Untitled.png)
2. 112번째 줄에 있는 Book 을 클릭해 아래 사진처럼 Quick Fix(`Ctrl/Cmd + .`)를 누른 뒤 `Import library ‘book.dart’` 를 선택합니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/586a27ce-98b9-466f-8d6e-0ee18394bcaf/Untitled.png)
4번째 줄에 Import 문이 생기며 에러가 사라졌습니다.
![Screen Shot 2022-09-19 at 10.04.08 AM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/4ea57bdd-bd69-4a49-bb92-930f88bc23ca/Screen_Shot_2022-09-19_at_10.04.08_AM.png)
3. 이제 ListTile 내에 Book의 내용을 보여주도록 하겠습니다. ListTile 의 소괄호 사이에 `title: Text(book.title)` 라고 입력해보세요.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/3e16c593-35ef-43cf-b2c7-7d9ee7f36a4d/Untitled.png)
위와 같이 검색 결과가 에뮬레이터에 잘 표시되는 것을 확인할 수 있습니다.
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),
),
);
```
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/a8a676df-6cd0-465b-adfe-2aa4a85c46e8/Untitled.png)
`ListTile` 의 각 요소는 아래와 같이 배치됩니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/7639eb36-5b37-43b8-87d0-7cf97013552e/Untitled.png)
다른 검색어를 입력해보세요. 검색이 잘 구현되었나요?
화면에 검색 결과가 뜨지 않는군요. 이는 검색을 통해 `bookList` 는 수정되었지만, 이후 화면을 **새로고침하지 않아서** 생기는 문제입니다.
`notifyListeners();` 를 `book_service.dart` 의 29번째 줄 아래에 추가해줍니다.
(위치에 주의해주세요!)
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/4a23b383-94e7-4805-a5f7-bea5a93d6d36/Untitled.png)
<aside>
💡 `notifyListeners();` 는 해당 `Service` 의 `Consumer` 로 등록된 모든 위젯의 `builder` 함수를 재호출해 화면을 새로고침합니다.
</aside>
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/036862cd-218a-401b-93e4-afe0c590ee1c/Untitled.png)
5. 이제 검색 결과는 잘 나오지만, `Debug Console` 에 아래와 같은 에러가 발생하고 있습니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/0af5da55-36c1-4648-8497-80b1ca2ed385/Untitled.png)
이는 `BookService` 의 `search` 함수에서 이전 데이터를 지워줄 때 순간적으로 `bookList` 가 비어서 생기는 문제입니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/de463a28-727c-43f5-ae14-a37fd7fb1641/Untitled.png)
아래 코드스니펫을 복사해 `main.dart` 112번째 줄 뒤에 붙여넣어주세요.
- **[코드스니펫] bookList 가 비어있을 때 처리**
```dart
if (bookService.bookList.isEmpty) return SizedBox();
```
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/19dad2a7-7193-41d0-97da-874a0efaffde/Untitled.png)
`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.dart
7번째 줄 맨 뒤에 붙여넣어주세요.[코드스니펫] likedBookList
List<Book> likedBookList = [];
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/08c2094c-88ff-45c7-a30f-d36353445eba/Untitled.png)
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();
}
```
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/730739ac-1558-4e4b-9567-ce6efc17d47b/Untitled.png)
<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>();
```
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/9673abd3-e54a-4e1f-8ce8-76fa61a8665a/Untitled.png)
이제 `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),
```
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/fe835df4-045e-4fdc-b019-acd55bed32e4/Untitled.png)
<aside>
💡 위 코드들에 대한 설명입니다.
`**bookService.toggleLikeBook(book: book)**` : BookService 에 생성해준 toggleLikeBook 에 해당 book 데이터를 인자로 넘겨 호출해줍니다.
`**bookService.likedBookList.map((book) => book.id).contains(book.id)`** : bookService 에 있는 likedBookList(좋아요 한 책들 담겨있는 리스트) 가 해당 book 을 담고있는지, 즉 이미 좋아요를 누른 상태인지 확인합니다.
아래 삼항연산자 (조건 ? a : b) 를 통해 좋아요가 눌러진 상태라면 노란색 별 아이콘을 보여줍니다.
</aside>
아래와 같이 좋아요를 누를 때 화면이 잘 갱신되는 것을 볼 수 있습니다.
![Screen Shot 2022-09-19 at 12.06.51 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/12e8db01-a01f-449c-8174-436c43da5c97/Screen_Shot_2022-09-19_at_12.06.51_PM.png)
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); }, ), ),
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/548eb9ab-6c0c-449f-b847-943299ca4d7b/Untitled.png)
좋아요 한 책만 모아보기 위해서는 위에 밑줄친 `bookList` 대신에 `likedBookList` 를 사용하면 되겠죠! 모두 아래와 같이 수정해봅시다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/3d1e194e-4014-45c6-9e51-76f63128f334/Untitled.png)
이제 좋아요 탭에서 아래와 같이 좋아요 누른 책들을 모아볼 수 있습니다.
![Screen Shot 2022-09-19 at 12.49.08 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/1f969888-e359-4b43-8895-506eaad7bb27/Screen_Shot_2022-09-19_at_12.49.08_PM.png)
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), ); } }
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f549f171-9a60-42a5-868b-b94c08e27e62/Untitled.png)
2. 206번째 줄에 에러가 발생했군요. 에러가 발생한 `WebView` 를 클릭한 뒤 Quick Fix(`Ctrl/Cmd + .`)를 누르고, `Import library ‘package:webview_flutter/webview_flutter.dart` 를 선택해주세요.
![Screen Shot 2022-09-19 at 3.17.21 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/e59b7f90-e05c-4a4d-8193-bc4267b127e1/Screen_Shot_2022-09-19_at_3.17.21_PM.png)
3번째 줄에 Import 문이 추가되면서 에러가 해결됩니다.
![Screen Shot 2022-09-19 at 3.17.51 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f3026c0d-bd4d-49d8-a5c1-62d8c1f18457/Screen_Shot_2022-09-19_at_3.17.51_PM.png)
3. 코드를 자세히 뜯어볼까요?
![Screen Shot 2022-09-19 at 3.18.28 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/be60e694-dae6-46c9-ac11-0cbe5470d9d9/Screen_Shot_2022-09-19_at_3.18.28_PM.png)
**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"), ), ), );
![Screen Shot 2022-09-19 at 3.20.05 PM.png](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/54280695-d3b7-4957-b6c6-06e86cb57f87/Screen_Shot_2022-09-19_at_3.20.05_PM.png)
`ListTile` 을 누르면 방금 만든 **WebViewPage** 로 이동하도록 했습니다. 이 때 해당 book의 `previewLink` 을 인자로 전달해, 해당 링크를 웹뷰로 열도록 합니다.
<aside>
💡 Book 의 `previewLink` 의 경우 http 로 시작하기 때문에 보안 정책상 웹뷰로 열리지 않습니다. 이 때문에 `replaceFirst`를 사용해 해당 url 의 http 를 https 로 바꿔주었습니다.
http, https 에 대한 보다 자세한 내용이 궁금하시다면 아래 링크를 참고해주세요.
[**HTTP와 HTTPS 차이점**](https://brunch.co.kr/@hyoi0303/10)
</aside>
이제 에뮬레이터에서 검색 후에 각각의 BookTile 을 클릭해봅시다.
아래와 같이 앱 내에서 해당 웹페이지를 잘 보여주는 것을 확인할 수 있습니다.
![Untitled](https://s3-us-west-2.amazonaws.com/secure.notion-static.com/ab54991c-1b5a-4ae9-9855-91967b4aadb4/Untitled.gif)
최종 코드
main.dart
import '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.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, }); }
book_service.dart
import '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.dart
import '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.dart
class 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.dart
import '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.dart
import '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.dart
class 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.dart
import '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(); } }