4주차 - API 사용법 익히기 (왓챠피디아)

  • PDF 파일

    4주차-API_사용법익히기(왓챠피디아).pdf

  • 단축키 모음

    • 새로고침 F5
    • 저장
      • Windows: Ctrl + S
      • macOS: command + S
    • 전체선택
      • Windows: Ctrl + A
      • macOS: command + A
    • 잘라내기
      • Windows: Ctrl + X
      • macOS: command + X
    • 콘솔창 줄바꿈
      • shift + enter
    • 코드정렬
      • Windows: Ctrl + Alt + L
      • macOS: option + command + L
    • 들여쓰기
      • Tab
      • 들여쓰기 취소 : Shift + Tab
    • 주석
      • Windows: Ctrl + /
      • macOS: command + /

[수업 목표]

  • API 이해하기
  • 비동기 이해하기
  • API를 이용한 앱 만들기
  • Provider 를 이용한 상태관리 익숙해지기
  • WebView 사용해보기

[목차]


01. API 이해하기

  • 1) API는 무엇인가요?

    Untitled

    <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를 이해하기 위한 배경 지식

    • 클라이언트와 서버

      Untitled

    • 프로토콜(Protocol)

      http.png

    • HTTP

      • 요청(Request)

        • URL : 목적지

          출처 - [https://sitechecker.pro/](https://sitechecker.pro/)

          출처 - https://sitechecker.pro/

        • 메소드(method) : 원하는 액션

          • GET : 조회

          • POST : 생성 / 수정 / 삭제

          • [코드스니펫] HTTP 요청 메소드

              https://developer.mozilla.org/ko/docs/Web/HTTP/Methods
        • 파라미터(Parameter)

          Untitled

          실제 예제를 보도록 하겠습니다. 코드스니펫을 복사해서 새 탭에서 열어주세요.

            <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 문서가 열립니다.

<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라는 파라미터에 고양이 라는 단어를 검색하여 책 데이터를 가져왔습니다.

    Untitled

  • [코드스니펫] DartPad google 책 검색 API

      https://dartpad.dev/?id=1519f6b1a7ac26cf42e4e302750650e0

    Run 버튼을 눌러 보면 Console에 JSON 응답이 출력되는 것을 볼 수 있습니다.

    Screen Shot 2022-09-18 at 7.05.42 PM.png

02. 비동기 이해하기

  • 1) 동기 & 비동기

    비동기 방식이 소요시간은 더 짧습니다.

    비동기 방식이 소요시간은 더 짧습니다.

    http (1).png

  • 2) async & await

    코드스니펫을 복사해 새 탭에서 붙여넣어 DartPad를 열어주세요.

<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를 열어주세요.

![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 프로젝트 생성

    1. VSCode를 실행해주세요.

    2. ViewCommand Palette를 선택해주세요.

      Untitled

    3. 명령어를 검색하는 팝업창이 뜨면, flutter라고 입력한 뒤 Flutter: New Project를 선택해주세요.

      Untitled

    4. Application을 선택해주세요.

      Untitled

    5. 프로젝트를 시작할 폴더를 선택하는 화면이 나오면 flutter 폴더를 선택한 뒤 Select a folder to create the project in 버튼을 눌러 주세요.

    6. 프로젝트 이름을 watcha_pedia으로 입력해주세요.

      Screen Shot 2022-09-18 at 9.00.52 PM.png

      만약 중간에 아래와 같은 팝업이 뜬다면, 체크박스를 선택한 뒤 파란 버튼을 클릭해주세요. (팝업이 안보이시면 넘어가주세요!)

      Untitled

    7. 다음과 같이 프로젝트가 생성되고, 다음으로 불필요한 힌트를 숨기도록 하겠습니다.

      코드스니펫을 복사해서 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) 에뮬레이터 실행하기

    1. VSCode 우측 하단에 Chrome (web-javascript)를 클릭해주세요.
      (에뮬레이터가 이미 실행중이라면 3번으로 이동해 주세요.)

      Untitled

    2. Start Pixel 2 API 29 mobile emulator를 선택해주세요.

      Untitled

      잠시 기다리면 Android 에뮬레이터가 실행됩니다.

      Untitled

    3. VSCode 우측 상단에 아래 화살표를 눌러 Run Without Debugging을 눌러주세요.

      Untitled

      에뮬레이터에 아래와 같이 탭으로 전환되는 화면이 나오면 준비 완료!

      Untitled

04. API 연결하기

  • 구현 목표

    Untitled

  • 1) 통신 패키지 dio 설치하기

    1. 코드스니펫을 복사해 새 탭에서 열어주세요.

    2. dio 패키지의 Installing 페이지가 열리면 flutter pub add dio 우측 아이콘을 눌러서 복사해주세요.

      Untitled

    3. VSCode ViewTerminal을 선택해주세요.

      Untitled

    4. Terminal창에 복사한 flutter pub add dio를 붙여넣고 엔터를 눌러 실행해 주세요.
      아래와 같이 나오면 정상적으로 작동한 것입니다.

      Untitled

    5. pubspec.yaml 파일을 열어서 아래와 같이 40번째 라인에 dio가 있으면 설치가 완료된 것입니다.

      Untitled

  • 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 응답값 자세히 보기

    1. 일단 API를 호출해 보도록 하겠습니다. 코드스니펫을 복사해 새 탭에서 열어주세요.
![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 클래스 수정하기

    1. 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 호출하기

    1. 키보드에서 완료 버튼을 누르면, TextField 에 입력된 문자열을 인자로 받는 onSubmitted 함수가 실행됩니다.

      Untitled

    2. 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)

    1. 콘솔에서는 JSON 구조가 잘 안보이니, 샘플을 보면서 원하는 데이터만 뽑아보도록 하겠습니다. 코드스니펫을 복사해 새 탭에서 열어주세요.

    ![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) 검색 결과 보여주기

    1. 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) 파일 분리

    1. main.dart 의 115번째 줄에 있는 ListTile 을 우클릭해 Refactor(Ctrl + Shift + R)을 선택하고,

      Screen Shot 2022-09-19 at 11.05.03 AM.png

      Extract Widget 을 선택해주세요.

      Untitled

      위젯의 이름을 입력하는 칸이 나오면 아래와 같이 BookTile 이라고 적은 후 엔터를 눌러줍니다.

      Screen Shot 2022-09-19 at 11.07.03 AM.png

      ListTile 내에 있던 코드가 BookTile 이라는 이름의 별도의 위젯으로 분리된 것을 확인할 수 있습니다.

      Untitled

  • 2) BookService 에 좋아요 기능 추가

    1. 먼저 좋아요 한 책들을 담는 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 에서 좋아요 한 책들 모아보기

    1. 먼저 LikedBookPage 에서도 likedBookList 의 변화에 따라 화면이 갱신되도록 해야합니다.

      Consumer 를 사용해 Scaffold 를 감싸주겠습니다. 171번째 줄의 Scaffold 를 클릭한 뒤 Quick Fix(Ctrl/Cmd + .)를 눌러 Wrap with Builder 를 선택해주세요.

      Untitled

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

      Untitled

    3. 아래 이미지와 같이 79번째 라인의 BuilderConsumer<BookService>로 변경해주세요.

      context 뒤에 bookServicechild 도 추가해줍니다.

      Untitled

      그리고 줄 정렬을 예쁘게 해주기 위해 177번째 라인의 중괄호와 소괄호 사이(위 화살표)에 콤마(,)를 찍은 뒤 저장해주세요. 저장하면 코드가 정렬됩니다.

      Screen Shot 2022-09-19 at 12.45.27 PM.png

    4. 아래 코드스니펫을 복사해 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 를 사용해 웹사이트를 그대로 띄우는 방식으로 구현되어 있다는 것을 알 수 있겠죠!

    Untitled

    이외에도 당근마켓, 토스와 같은 앱들도 내부 기능 일부를 WebView 를 활용해 구현했습니다.

  • 2) webview_flutter 패키지 설치하기

    1. 해당 패키지 사이트는 아래와 같습니다.

    2. 강의 영상과 조금 다른 부분이니 주의해서 봐주세요! webview_flutter 패키지가 업데이트 되면서 아래 강의 내용대로 진행하면 에러가 뜹니다. 이전 버전 패키지를 설치함으로서 이 에러를 해결해주도록 하겠습니다

      아래 코드스니펫을 복사해주세요.

      • [코드스니펫] webview_flutter 3.0.4 버전 설치 스크립트

          flutter pub add webview_flutter:^3.0.4
    3. VSCode ViewTerminal을 선택해주세요.

      Untitled

    4. Terminal창에 복사한 flutter pub add webview_flutter:^3.0.4 를 붙여넣고 엔터를 눌러 실행해 주세요.
      아래와 같이 나오면 정상적으로 작동한 것입니다.

      Screenshot 2022-12-22 at 5.12.45 PM.png

    5. pubspec.yaml 파일을 열어서 아래와 같이 41번째 라인에 webview_flutter: ^3.0.4가 있으면 설치가 완료된 것입니다.

      Screen Shot 2022-09-19 at 1.01.36 PM.png

      이제 아래부터는 강의 내용과 같습니다!

    6. android/app/build.gradle 파일을 열고(경로에 유의하세요!)
      50 번째 줄의 flutter.minSdkVersion19 로 수정해줍니다.

      Untitled

       android {
           defaultConfig {
               minSdkVersion 19
           }
       }

      아래와 같이 작성해주면 됩니다.

      Untitled

    7. webview_flutter 를 앱에서 사용하기 위해서는 앱을 재설치해야합니다.

      먼저 앱을 정지하고

      Untitled

      main.dart 를 열고 우측 상단에서 Run Without Debugging 을 클릭해 다시 앱을 설치 / 실행해줍니다.

      Untitled

  • <aside>
    💡 WebView 를 앱에서 사용할 준비가 완료되었습니다.
    
    </aside>
    • 3) WebViewPage 위젯 만들기

      1. webview_flutter 의 설치가 끝났으니, 이제 각 Book 의 previewLink 로 이동하는 기능을 구현해보겠습니다.

        Untitled

        먼저 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 연결하기

      아래 코드스니펫을 복사해 ListTileonTap 함수 중괄호 사이 (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();
          }
        }
  • + Recent posts