Flutter 로 웹툰 앱 만들기 (5)

조미서·2023년 11월 29일
0
post-thumbnail

이 글은 노마드 코더 - Flutter 로 웹툰 앱 만들기를 참고하여 작성하였습니다.
작성자 : 조미서
개발 환경 : Mac OS, Android Studio, Visual Studio Code

🧑🏻‍💻#6 WEBTOON APP

#6.12 ApiService

API URL에 Webtoon의 ID를 붙여 해당 Webtoon에 대한 정보를 얻을 수 있다 이번강의에서는 이 URL을 fetch 해 볼 것이다.

WebtoonModel을 fetch했던 것처럼 title, about, genre, age, thumb로 이루어진 새로운 모델을 만든다 -> webtoon_datail_model.dart

WebtoonDetailModel이라는 class를 하나 만들고 프로퍼티들을 정의한다.
그리고 fromJson constructor를 정의한다.

webtoon_detail_model 코드

class WebtoonDetailModel {
  final String title, about, genre, age;

  WebtoonDetailModel.fromJson(Map<String, dynamic> json)
      : title = json['title'],
        about = json['about'],
        genre = json['genre'],
        age = json['age'];
}

이제 api_service 코드에서 WebtoonDetailModel을 return하도록 한다.

api_service 코드 (일부)

  static Future<WebtoonDetailModel> getToonById(String id) async {
    final url = Uri.parse("$baseUrl/$id");	// url을 만들고
    final response = await http.get(url); 	// 해당 URL로 request를 보내고
    if (response.statusCode == 200) { 	// request가 성공적이었다면
      final webtoon = jsonDecode(response.body); // String 타입인 response.body를 받아서 json으로 바꿔주기
      return WebtoonDetailModel.fromJson(webtoon); // 바꾼 json을 WebtoonDetailModel의 constructor로 전달 
      // WebtoonDetailModel은 이 json을 받아서
      // title에 json의 title 값을 할당...나머지 프로퍼티도 동일
    }
    throw Error;
  }

동일한 방법으로 episode도 fetch하자.

id, title, rating, date로 이루어진 새로운 모델을 만든다 -> webtoon_episode_model.dart

WebtoonEpisodeModel이라는 class를 하나 만들고 프로퍼티들을 정의한다.
그리고 fromJson constructor를 정의한다.

webtoon_episode_model 코드

class WebtoonEpisodeModel {
  final String id, title, rating, date;

  WebtoonEpisodeModel.fromJson(Map<String, dynamic> json)
  // json Map으로 class를 초기화할 생성자
      : id = json['id'],
        title = json['title'],
        rating = json['rating'],
        date = json['date']; 
}

이제 api_service 코드에서 WebtoonEpisodeModel 메소드를 return하도록 한다.
이 때 episode들은 List를 만들어서 반환하므로 WebtoonDetailModel을 반환하는 방법과 약간의 차이를 보인다.

api_service 코드 (일부)

  static Future<List<WebtoonEpisodeModel>> getLatestEpisodesById(
      String id) async {
    List<WebtoonEpisodeModel> episodesInstances = [];
    final url = Uri.parse("$baseUrl/$id/episodes");
    final response = await http.get(url);
    if (response.statusCode == 200) {
      final episodes = jsonDecode(response.body);
      for (var episode in episodes) {
        episodesInstances.add(WebtoonEpisodeModel.fromJson(episode));
      }
      return episodesInstances;
    }
    throw Error;
  }

지금까지 한 내용들을 정리해보면
field가 몇 개 있는 평범한 class인 Model을 생성한다.
이를 json으로 초기화한다.

그리고 api_service에서 두 개의 메소드를 만들었다.
하나는 ID로 webtoon을 한 개 받아오는 메소드 (baseUrl과 id를 가지고 request를 보냄 그리고 서버로부터 받은 json을 가지고 model을 만들었다.)

다른 method는 ID값에 따른 최신 에피소드 리스트를 받아온다. (에피소드들을 json으로 decode하고 각각의 json 에피소드마다 새로운 WebtoonEpisodeModel을 생성하고 나서 이 model들의 instance들을 instanceList에 담아 아주 긴 List를 반환해준다)


#6.13 Futures

전 강의에서 만들었던 두 개의 메소드를 DetailScreen에서 사용하기 위해서 home_screen에서 했던 것(Future를 가져오는 service를 호출하고 FutureBuilder를 사용해서 Future가 완료되는 것을 기다리고 그 데이터를 가지고 UI를 구현함)처럼 Detailscreen에서 이 방식을 그대로 쓸 수는 없다 (getTooonById와 getLatestEpisodesById에는 모두 id가 필요하여 그대로 쓰기에는 약간의 문제가 있다)

이 문제를 해결하기 위해서는 먼저 DetailScreen을 StatefulWidget으로 바꿔야한다. 그렇게 되면 title, id와 같은 부분이 widget.이 붙여서 생성되는 것을 볼 수 있다. -> State의 build method가 State가 속한 StatefulWidget의 data를 받아오는 방법

이제 Future가 State class에 위치하길 바라며 코드를 작성하면 error가 나타난다. (constructor에서 widget이 참조될 수 없기 때문에)
그러하여 late Future<WebtoonDetailModel> webtoon;을 import 해주고
late Future<List<WebtoonEpisodeModel>>episodes;도 똑같이 import 해준다.
(late modifier가 초기화하고 싶은 property가 있지만 constructor에서는 불가능한 경우 함수로 초기화할 수 있도록 해준다)
initState()에서 정의해준다.

  
  void initState() {
    super.initState();
    webtoon = ApiService.getToonById(widget.id);
    episodes = ApiService.getLatestEpisodesById(widget.id);
  }

initState()에서는 widget.id에 접근할 수 있다. -> 별개의 class에서 작업하고 있기에 widget.을 적어주어야 한다. (현재 State를 extend하는 class에 있는데 데이터는 StatefulWidget인 DetailScreen으로 전달된다)


#6.14 Detail Info

FutureBuilder로 UI를 더 수정해 썸네일 클릭시 썸네일 밑에 세부내용과 장르 및 연령이 나타나도록 해주었다.

detail_screen 코드

import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_detail_model.dart';
import 'package:webtoon/models/webtoon_episode_model.dart';
import 'package:webtoon/services/api_service.dart';

class DetailScreen extends StatefulWidget {
  final String title, thumb, id;

  const DetailScreen({
    super.key,
    required this.title,
    required this.thumb,
    required this.id,
  });

  
  State<DetailScreen> createState() => _DetailScreenState();
}

class _DetailScreenState extends State<DetailScreen> {
  late Future<WebtoonDetailModel> webtoon;
  late Future<List<WebtoonEpisodeModel>> episodes;

  
  void initState() {
    super.initState();
    webtoon = ApiService.getToonById(widget.id);
    episodes = ApiService.getLatestEpisodesById(widget.id);
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.white,
        appBar: AppBar(
          elevation: 2,
          backgroundColor: Colors.white,
          foregroundColor: Colors.green,
          title: Text(
            widget.title,
            style: const TextStyle(
              fontSize: 24,
            ),
          ),
        ),
        body: Column(
          children: [
            const SizedBox(
              height: 50,
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Hero(
                  tag: widget.id,
                  child: Container(
                    width: 250,
                    clipBehavior: Clip.hardEdge,
                    decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(10),
                        boxShadow: [
                          BoxShadow(
                            blurRadius: 15,
                            offset: const Offset(10, 10),
                            color: Colors.black.withOpacity(1),
                          ),
                        ]),
                    child: Image.network(
                      widget.thumb,
                      headers: const {
                        'Referer': 'https://comic.naver.com',
                      },
                    ),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 25),
            FutureBuilder(
              future: webtoon,
              builder: ((context, snapshot) {
                if (snapshot.hasData) {
                  return Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 50),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          snapshot.data!.about,
                          style: const TextStyle(fontSize: 16),
                        ),
                        const SizedBox(height: 15),
                        Text(
                          '${snapshot.data!.genre} / ${snapshot.data!.age}',
                          style: const TextStyle(fontSize: 16),
                        ),
                      ],
                    ),
                  );
                }
                return const Text("...");
              }),
            )
          ],
        ));
  }
}

실행화면


snapshot에 data가 들어올 때까지는 ... 표시가 나타나고 데이터가 들어오면 웹툰의 세부내용과 장르, 연령에 대한 정보를 표시하도록 한 것을 볼 수 있다.


#6.15 Episodes

ListView를 사용하지 않고 에피소드들 출력 (개수가 10개로 한정되어 있다고 할 때)

detail_screen 코드

import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_detail_model.dart';
import 'package:webtoon/models/webtoon_episode_model.dart';
import 'package:webtoon/services/api_service.dart';

class DetailScreen extends StatefulWidget {
  final String title, thumb, id;

  const DetailScreen({
    super.key,
    required this.title,
    required this.thumb,
    required this.id,
  });

  
  State<DetailScreen> createState() => _DetailScreenState();
}

class _DetailScreenState extends State<DetailScreen> {
  late Future<WebtoonDetailModel> webtoon;
  late Future<List<WebtoonEpisodeModel>> episodes;

  
  void initState() {
    super.initState();
    webtoon = ApiService.getToonById(widget.id);
    episodes = ApiService.getLatestEpisodesById(widget.id);
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
        backgroundColor: Colors.white,
        appBar: AppBar(
          elevation: 2,
          backgroundColor: Colors.white,
          foregroundColor: Colors.green,
          title: Text(
            widget.title,
            style: const TextStyle(
              fontSize: 24,
            ),
          ),
        ),
        body: Column(
          children: [
            const SizedBox(
              height: 50,
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Hero(
                  tag: widget.id,
                  child: Container(
                    width: 250,
                    clipBehavior: Clip.hardEdge,
                    decoration: BoxDecoration(
                        borderRadius: BorderRadius.circular(10),
                        boxShadow: [
                          BoxShadow(
                            blurRadius: 15,
                            offset: const Offset(10, 10),
                            color: Colors.black.withOpacity(1),
                          ),
                        ]),
                    child: Image.network(
                      widget.thumb,
                      headers: const {
                        'Referer': 'https://comic.naver.com',
                      },
                    ),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 25),
            FutureBuilder(
              future: webtoon,
              builder: ((context, snapshot) {
                if (snapshot.hasData) {
                  return Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 50),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          snapshot.data!.about,
                          style: const TextStyle(fontSize: 16),
                        ),
                        const SizedBox(height: 15),
                        Text(
                          '${snapshot.data!.genre} / ${snapshot.data!.age}',
                          style: const TextStyle(fontSize: 16),
                        ),
                      ],
                    ),
                  );
                }
                return const Text("...");
              }),
            ),
            const SizedBox(height: 50),
            FutureBuilder(
                future: episodes,
                builder: (context, snapshot) {
                  if (snapshot.hasData) {
                    return Column(
                      children: [
                        for (var episode in snapshot.data!.length > 10
                            ? snapshot.data!.sublist(0, 10)
                            : snapshot.data!)
                          Text(episode.title)
                      ],
                    );
                  }
                  return Container();
                })
          ],
        ));
  }
}

실행화면

이제 Text가 아닌 episode 버튼을 만들어보자.
이때 중요했던 것은 overflow가 날 때 body에서 Padding 부분을 singleChildScrollView widget으로 감싸서 이를 해결한다.

그리고 화살표를 끝에 두기 위해 horizontal 방향이 Row의 mainaxis이므로 MainAxisalignment.spaceBetween을 사용하여 이를 해결한다.

detail_screen 코드

import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_detail_model.dart';
import 'package:webtoon/models/webtoon_episode_model.dart';
import 'package:webtoon/services/api_service.dart';

class DetailScreen extends StatefulWidget {
  final String title, thumb, id;

  const DetailScreen({
    super.key,
    required this.title,
    required this.thumb,
    required this.id,
  });

  
  State<DetailScreen> createState() => _DetailScreenState();
}

class _DetailScreenState extends State<DetailScreen> {
  late Future<WebtoonDetailModel> webtoon;
  late Future<List<WebtoonEpisodeModel>> episodes;

  
  void initState() {
    super.initState();
    webtoon = ApiService.getToonById(widget.id);
    episodes = ApiService.getLatestEpisodesById(widget.id);
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        elevation: 2,
        backgroundColor: Colors.white,
        foregroundColor: Colors.green,
        title: Text(
          widget.title,
          style: const TextStyle(
            fontSize: 24,
          ),
        ),
      ),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(50),
          child: Column(
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Hero(
                    tag: widget.id,
                    child: Container(
                      width: 250,
                      clipBehavior: Clip.hardEdge,
                      decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(10),
                          boxShadow: [
                            BoxShadow(
                              blurRadius: 15,
                              offset: const Offset(10, 10),
                              color: Colors.black.withOpacity(1),
                            ),
                          ]),
                      child: Image.network(
                        widget.thumb,
                        headers: const {
                          'Referer': 'https://comic.naver.com',
                        },
                      ),
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 25),
              FutureBuilder(
                future: webtoon,
                builder: ((context, snapshot) {
                  if (snapshot.hasData) {
                    return Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          snapshot.data!.about,
                          style: const TextStyle(fontSize: 16),
                        ),
                        const SizedBox(height: 15),
                        Text(
                          '${snapshot.data!.genre} / ${snapshot.data!.age}',
                          style: const TextStyle(fontSize: 16),
                        ),
                      ],
                    );
                  }
                  return const Text("...");
                }),
              ),
              const SizedBox(height: 25),
              FutureBuilder(
                future: episodes,
                builder: (context, snapshot) {
                  if (snapshot.hasData) {
                    return Column(
                      children: [
                        for (var episode in snapshot.data!.length > 10
                            ? snapshot.data!.sublist(0, 10)
                            : snapshot.data!)
                          Container(
                            margin: const EdgeInsets.only(bottom: 10),
                            decoration: BoxDecoration(
                              borderRadius: BorderRadius.circular(20),
                              color: Colors.green.shade400,
                            ),
                            child: Padding(
                              padding: const EdgeInsets.symmetric(
                                  vertical: 10, horizontal: 20),
                              child: Row(
                                mainAxisAlignment:
                                    MainAxisAlignment.spaceBetween,
                                children: [
                                  Text(
                                    episode.title,
                                    style: const TextStyle(
                                      color: Colors.white,
                                      fontSize: 16,
                                    ),
                                  ),
                                  const Icon(
                                    Icons.chevron_right_rounded,
                                    color: Colors.white,
                                  ),
                                ],
                              ),
                            ),
                          ),
                      ],
                    );
                  }
                  return Container();
                },
              )
            ],
          ),
        ),
      ),
    );
  }
}

실행화면


#6.16 Url Launcher

전 강의에서 만든 버튼을 클릭하였을 때 브라우저로 이동할 수 있게 만들기 위하여 url_launcher 패키지를 먼저 설치해야한다.

Flutter 터미널에서

$ flutter pub add url_launcher

를 입력하여 설치를 진행하고,

다음으로 현재 ios로 작업을 하고 있었으므로

<key>LSApplicationQueriesSchemes</key>
<array>
	<string>https</string> // scheme 종류 명시
</array>

를 ios -> Runner -> info.plist에 붙여넣어 주었다.

*안드로이드로 작업을 진행하면 또 다른 XML코드를 AndroidManifest.xml에 추가하면 된다.

여기에서 중요한 것은 어떤 종류의 url을 열 것인지 명시해줘야 된다는 점이다.

url_launcher는 http url만 실행하는게 아니라 sms url이나 telephone url도 실행할 수 있다. (scheme의 종류 : https, mailto, tel, sms, file)

이 절차를 거쳤다면 프로젝트를 다시 rebuild 해주어야 적용이 된다! (hot reloading은 dart의 코드 변경만 감지하므로 이외의 파일들을 수정할 때는 rebuild 하기)

다음으로 detail_screen에서 onButtonTap이라는 메소드를 만든다.
이 메소드 내에 사용되는 launchUrl 함수는 Future를 가져다 주는 함수이므로 메소드를 비동기 처리(async)해주고 함수 앞에 await를 적어준다.

  onButtonTap() async {
    await launchUrlString(
        "https://comic.naver.com/webtoon/detail?titleId=$webtoonId&no=${episode.id}");
  }

그리고 episodes를 담당하는 부분의 container를 Episode라는 위젯으로 추출하여 이 부분을 통째로 새로운 파일에 넣는다 (episode_widget.dart)

그리고 아까 Episode 부분을 그대로 붙여넣는다.
다음으로 Container부분을 GestureDetector 위젯으로 감싸주고, onTap: onButtonTap으로 하여 버튼을 탭하면 위 함수를 실행하도록 한다.

그리고 episode에 관한 data를 가지고 있는 final field는 존재하지만, webtoon의 ID값도 받아와야 하므로, 다시 detail_screen 코드로 가서 Episode 위젯을 초기화할 때, webtoonId값도 함께 보낸다.

Episode(episode: episode,
webtoonId: widget.id)	// widget.id는 DetailScreen의 ID를 뜻한다
// 이는 바로 사용자가 클릭한 webtoon이다

이제 다시 episode_widget 코드로 돌아가서 webtoonId의 final field를 생성하고, required로 만들어준다.

그 후에 url 부분에 webtoonId를 사용하면 된다.

detail_screen 코드

import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_detail_model.dart';
import 'package:webtoon/models/webtoon_episode_model.dart';
import 'package:webtoon/services/api_service.dart';
import 'package:webtoon/widgets/episode_widget.dart';

class DetailScreen extends StatefulWidget {
  final String title, thumb, id;

  const DetailScreen({
    super.key,
    required this.title,
    required this.thumb,
    required this.id,
  });

  
  State<DetailScreen> createState() => _DetailScreenState();
}

class _DetailScreenState extends State<DetailScreen> {
  late Future<WebtoonDetailModel> webtoon;
  late Future<List<WebtoonEpisodeModel>> episodes;

  
  void initState() {
    super.initState();
    webtoon = ApiService.getToonById(widget.id);
    episodes = ApiService.getLatestEpisodesById(widget.id);
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        elevation: 2,
        backgroundColor: Colors.white,
        foregroundColor: Colors.green,
        title: Text(
          widget.title,
          style: const TextStyle(
            fontSize: 24,
          ),
        ),
      ),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(50),
          child: Column(
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Hero(
                    tag: widget.id,
                    child: Container(
                      width: 250,
                      clipBehavior: Clip.hardEdge,
                      decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(10),
                          boxShadow: [
                            BoxShadow(
                              blurRadius: 15,
                              offset: const Offset(10, 10),
                              color: Colors.black.withOpacity(1),
                            ),
                          ]),
                      child: Image.network(
                        widget.thumb,
                        headers: const {
                          'Referer': 'https://comic.naver.com',
                        },
                      ),
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 25),
              FutureBuilder(
                future: webtoon,
                builder: ((context, snapshot) {
                  if (snapshot.hasData) {
                    return Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          snapshot.data!.about,
                          style: const TextStyle(fontSize: 16),
                        ),
                        const SizedBox(height: 15),
                        Text(
                          '${snapshot.data!.genre} / ${snapshot.data!.age}',
                          style: const TextStyle(fontSize: 16),
                        ),
                      ],
                    );
                  }
                  return const Text("...");
                }),
              ),
              const SizedBox(height: 25),
              FutureBuilder(
                future: episodes,
                builder: (context, snapshot) {
                  if (snapshot.hasData) {
                    return Column(
                      children: [
                        for (var episode in snapshot.data!.length > 10
                            ? snapshot.data!.sublist(0, 10)
                            : snapshot.data!)
                          Episode(episode: episode, webtoonId: widget.id),
                      ],
                    );
                  }
                  return Container();
                },
              )
            ],
          ),
        ),
      ),
    );
  }
}

episode_widget 코드

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:webtoon/models/webtoon_episode_model.dart';

class Episode extends StatelessWidget {
  const Episode({
    Key? key,
    required this.episode,
    required this.webtoonId,
  }) : super(key: key);

  final String webtoonId;
  final WebtoonEpisodeModel episode;

  onButtonTap() async {
    await launchUrlString(
        "https://comic.naver.com/webtoon/detail?titleId=$webtoonId&no=${episode.id}");
  }

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onButtonTap,
      child: Container(
        margin: const EdgeInsets.only(bottom: 10),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(20),
          color: Colors.green.shade400,
        ),
        child: Padding(
          padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(
                episode.title,
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 16,
                ),
              ),
              const Icon(
                Icons.chevron_right_rounded,
                color: Colors.white,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

실행화면

이렇게 웹툰 에피소드 버튼을 누르면 해당 webtoonID와 해당 episodeID를 읽어오는 것을 볼 수 있다.


#6.17 Favorites

이제 마이페이지와 같은 기능으로 하트를 누르면 그거를 기억해서 핸드폰 저장소에 작은 정보를 담는 기능을 구현해보자 (어플리케이션을 다시 실행해도 사용자가 같은 webtoon에 접속하면 이전에 하트를 눌렀던 상태가 유지되도록 함)

그럼 먼저 AppBar에서 List<widget>인 actionsIconButton 을 사용하여 눌렀을 때, onHeartTap 함수를 실행하고 아이콘 모양을 바뀌도록 한다.

그 전에 핸드폰 저장소에 데이터를 담기 위해서 -> shared_preferences 패키지를 이용해야 된다.

터미널에

flutter pub add shared_preferences

입력하여 설치하고 작동방식은 먼저 핸드폰 저장소와 connection을 만들고

// Write data

// Obtain shared preferences.
final SharedPreferences prefs = await SharedPreferences.getInstance();

// Save an integer value to 'counter' key.
await prefs.setInt('counter', 10);
// Save an boolean value to 'repeat' key.
await prefs.setBool('repeat', true);
// Save an double value to 'decimal' key.
await prefs.setDouble('decimal', 1.5);
// Save an String value to 'action' key.
await prefs.setString('action', 'Start');
// Save an list of strings to 'items' key.
await prefs.setStringList('items', <String>['Earth', 'Moon', 'Sun']);

위와 같이 getInstance를 해주고 나면 각기 다른 값과 각기 다른 자료형을 저장할 수 있다.

// Read data
// Try reading data from the 'counter' key. If it doesn't exist, returns null.
final int? counter = prefs.getInt('counter');
// Try reading data from the 'repeat' key. If it doesn't exist, returns null.
final bool? repeat = prefs.getBool('repeat');
// Try reading data from the 'decimal' key. If it doesn't exist, returns null.
final double? decimal = prefs.getDouble('decimal');
// Try reading data from the 'action' key. If it doesn't exist, returns null.
final String? action = prefs.getString('action');
// Try reading data from the 'items' key. If it doesn't exist, returns null.
final List<String>? items = prefs.getStringList('items');

또한 위와 같은 방법으로 데이터를 가져온다.

그럼 이를 이용하여 사용자가 좋아요 버튼을 누를 때마다 좋아요를 누른 모든 ID의 리스트를 가져올 것이다.

이러한 좋아요를 누른 ID를 가질 리스트를 likedToons라고 해보자. 그래서 화면이 로딩되면 해당 widget의 ID가 likedToons 목록에 있는지 체크하여 만약 있다면(=true) 버튼에 이를 반영하고, 없다면 없는대로(=false) 그 상태를 아이콘에 반영한다. 그리고 사용자가 좋아요 버튼을 클릭하면 likedToons 에서 ID를 더하거나(add) 빼준다.(remove)

위와 같이 구현하기 위해
1. 먼저 새로운 class member를 만들어준다. ->
late SharedPreferences pref;

  1. initState 내부에 initPrefs();를 작성하여 호출
  2. initPrefs() 메소드 만들기
 Future initPrefs() async {
    prefs = await SharedPreferences.getInstance();
  }

1,2,3의 과정을 진행하면 사용자의 저장소에 connection이 생긴 것과 같음
새 패키지 설치 후에 rebuild 잊지 말기!

이제 사용자의 저장소 내부를 검색해 String List가 있는지 확인 -> likedToons 라는 key 이름을 통하여

prefs.getStringList('likedToons'); // initPrefs 내에 추가

하지만 여기에서 return type이 String List일 수도 아닐 수도 있다
사용자가 최초로 어플리케이션 실행 시 likedToons는 저장소에 없을 거기 때문에
이 부분을 체크해주기 위해서

  • bool isLiked = false 라는 상태를 준다.
// 위 코드를 이렇게 수정해준다
final likedToons = prefs.getStringList('likedToons');
if(likedToons != null){		// 이미 List가 존재 -> 사용자가 지금 보는 웹툰의 ID가 likedToons 안에 있는가 없는가 확인
  if(likedToons.contains(widget.id) == true){
    setState(() {
      isLiked = true; // 상태 변화
    });
  }
} else{
  await prefs.setStringList('likedToons', []);
  // prefs.setStringList(key, value)인데 value는 빈 리스트로 초기화
  // 이 부분은 딱 한 번 일어나는 케이스를 다룰 때이다. (사용자가 처음으로 앱을 실행하는 케이스)
}

상태변화 후에 setState를 해줘야 됨!! (UI를 refresh 해줘야 하므로)

이제 다시 AppBar에서 아이콘 버튼을 누르면 아이콘 모양을 바뀌도록

icon: Icon(
                isLiked ? Icons.favorite : Icons.favorite_outline,
              )

삼항연산자를 사용하여 작성해준다.

이제 onHeartTap 메서드를 만드는데 async를 붙여주고, 여기에서도 likedToons 리스트가 필요하기 때문에 final likedToons =~ 를 넣어준다. 이제 여기에서는 웹툰이 likedToons에 없다면 추가해주고, 있다면 삭제해주는 코드를 작성한다.
하지만 여기까지만 하면 핸드폰에 List가 저장되지 않은 상태이다. 그러므로 remove 또는 add가 끝난 뒤에 await prefs.setStringList('likedToons', likedToons); 이렇게 저장하는 코드를 작성해줘야 한다는 것이 중요하다

onHeartTap() async {
    final likedToons = prefs.getStringList('likedToons');
    if (likedToons != null) {
      if (isLiked) {
        likedToons.remove(widget.id);
      } else {
        likedToons.add(widget.id);
      }
      await prefs.setStringList('likedToons', likedToons);
      setState(() {
        isLiked = !isLiked;
      });
    }
  }

이 부분도 마찬가지로 isLiked 상태가 바뀌어야 하므로 setState를 해주고 안에 isLiked 상태를 바꿔주기

실행화면


실행하면 위와 같이 하트를 누르면 하트가 채워지고 또 누를 경우에 다시 비워지는 것이 반복되었다. 그리고 다시 실행을 하였을 때 하트가 채워진 상태와 비워진 상태가 그 전에 앱을 실행했을 때 설정해 놓았던 상태를 유지하는 것이 보여졌다.


🧑🏻‍💻# 끝으로 지금까지 했던 코드 Review

main.dart

import 'package:flutter/material.dart';
import 'package:webtoon/screens/home_screen.dart';
// import 'package:webtoon/services/api_service.dart';

void main() {
  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomeScreen(),
    );
  }
}

webtoon_widget.dart

import 'package:flutter/material.dart';
import 'package:webtoon/screens/detail_screen.dart';

class Webtoon extends StatelessWidget {
  final String title, thumb, id;

  const Webtoon({
    super.key,
    required this.title,
    required this.thumb,
    required this.id,
  });

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (context) =>
                DetailScreen(title: title, thumb: thumb, id: id),
            fullscreenDialog: true,
          ),
        );
      },
      child: Column(
        children: [
          Hero(
            tag: id,
            child: Container(
              width: 250,
              clipBehavior: Clip.hardEdge,
              decoration: BoxDecoration(
                  borderRadius: BorderRadius.circular(10),
                  boxShadow: [
                    BoxShadow(
                      blurRadius: 15,
                      offset: const Offset(10, 10),
                      color: Colors.black.withOpacity(1),
                    ),
                  ]),
              child: Image.network(
                thumb,
                headers: const {
                  'Referer': 'https://comic.naver.com',
                },
              ),
            ),
          ),
          const SizedBox(
            height: 10,
          ),
          Text(
            title,
            style: const TextStyle(
              fontSize: 22,
            ),
          ),
        ],
      ),
    );
  }
}

episode_widget.dart

import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:webtoon/models/webtoon_episode_model.dart';

class Episode extends StatelessWidget {
  const Episode({
    Key? key,
    required this.episode,
    required this.webtoonId,
  }) : super(key: key);

  final String webtoonId;
  final WebtoonEpisodeModel episode;

  onButtonTap() async {
    await launchUrlString(
        "https://comic.naver.com/webtoon/detail?titleId=$webtoonId&no=${episode.id}");
  }

  
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onButtonTap,
      child: Container(
        margin: const EdgeInsets.only(bottom: 10),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(20),
          color: Colors.green.shade400,
        ),
        child: Padding(
          padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
          child: Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Text(
                episode.title,
                style: const TextStyle(
                  color: Colors.white,
                  fontSize: 16,
                ),
              ),
              const Icon(
                Icons.chevron_right_rounded,
                color: Colors.white,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

api_service.dart

import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:webtoon/models/webtoon_detail_model.dart';
import 'package:webtoon/models/webtoon_episode_model.dart';
import 'package:webtoon/models/webtoon_model.dart';

class ApiService {
  static const String baseUrl =
      "https://webtoon-crawler.nomadcoders.workers.dev"; // api의 기본 url
  static const String today = "today";

  static Future<List<WebtoonModel>> getTodaysToons() async {
    List<WebtoonModel> webtoonInstances = [];
    final url = Uri.parse('$baseUrl/$today');
    final response = await http.get(url);
    if (response.statusCode == 200) {
      final webtoons = jsonDecode(response.body);
      for (var webtoon in webtoons) {
        final instance = WebtoonModel.fromJson(webtoon);
        webtoonInstances.add(instance);
      }
      return webtoonInstances;
    }
    throw Error();
  }

  static Future<WebtoonDetailModel> getToonById(String id) async {
    final url = Uri.parse("$baseUrl/$id");
    final response = await http.get(url);
    if (response.statusCode == 200) {
      final webtoon = jsonDecode(response.body);
      return WebtoonDetailModel.fromJson(webtoon);
    }
    throw Error;
  }

  static Future<List<WebtoonEpisodeModel>> getLatestEpisodesById(
      String id) async {
    List<WebtoonEpisodeModel> episodesInstances = [];
    final url = Uri.parse("$baseUrl/$id/episodes");
    final response = await http.get(url);
    if (response.statusCode == 200) {
      final episodes = jsonDecode(response.body);
      for (var episode in episodes) {
        episodesInstances.add(WebtoonEpisodeModel.fromJson(episode));
      }
      return episodesInstances;
    }
    throw Error;
  }
}

webtoon_detail.model.dart

class WebtoonDetailModel {
  final String title, about, genre, age;

  WebtoonDetailModel.fromJson(Map<String, dynamic> json)
      : title = json['title'],
        about = json['about'],
        genre = json['genre'],
        age = json['age'];
}

webtoon_episode_model.dart

class WebtoonEpisodeModel {
  final String id, title, rating, date;

  WebtoonEpisodeModel.fromJson(Map<String, dynamic> json)
      : id = json['id'],
        title = json['title'],
        rating = json['rating'],
        date = json['date'];
}

webtoon_detail_model.dart

class WebtoonDetailModel {
  final String title, about, genre, age;

  WebtoonDetailModel.fromJson(Map<String, dynamic> json)
      : title = json['title'],
        about = json['about'],
        genre = json['genre'],
        age = json['age'];
}

home_screen.dart

import 'package:flutter/material.dart';
import 'package:webtoon/models/webtoon_model.dart';
import 'package:webtoon/services/api_service.dart';
import 'package:webtoon/widgets/webtoon_widget.dart';

class HomeScreen extends StatelessWidget {
  HomeScreen({super.key});

  final Future<List<WebtoonModel>> webtoons = ApiService.getTodaysToons();

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        elevation: 2,
        backgroundColor: Colors.white,
        foregroundColor: Colors.green,
        title: const Text(
          "오늘의 웹툰",
          style: TextStyle(
            fontSize: 24,
          ),
        ),
      ),
      body: FutureBuilder(
        future: webtoons,
        builder: (context, snapshot) {
          if (snapshot.hasData) {
            return Column(
              children: [
                const SizedBox(
                  height: 50,
                ),
                Expanded(child: makeList(snapshot)),
              ],
            );
          }
          return const Center(
            child: CircularProgressIndicator(),
          );
        },
      ),
    );
  }

  ListView makeList(AsyncSnapshot<List<WebtoonModel>> snapshot) {
    return ListView.separated(
      scrollDirection: Axis.horizontal,
      itemCount: snapshot.data!.length,
      padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
      itemBuilder: (context, index) {
        var webtoon = snapshot.data![index];
        return Webtoon(
            title: webtoon.title, thumb: webtoon.thumb, id: webtoon.id);
      },
      separatorBuilder: (context, index) => const SizedBox(
        width: 40,
      ),
    );
  }
}

detail_screen.dart

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:webtoon/models/webtoon_detail_model.dart';
import 'package:webtoon/models/webtoon_episode_model.dart';
import 'package:webtoon/services/api_service.dart';
import 'package:webtoon/widgets/episode_widget.dart';

class DetailScreen extends StatefulWidget {
  final String title, thumb, id;

  const DetailScreen({
    super.key,
    required this.title,
    required this.thumb,
    required this.id,
  });

  
  State<DetailScreen> createState() => _DetailScreenState();
}

class _DetailScreenState extends State<DetailScreen> {
  late Future<WebtoonDetailModel> webtoon;
  late Future<List<WebtoonEpisodeModel>> episodes;
  late SharedPreferences prefs;
  bool isLiked = false;

  Future initPrefs() async {
    prefs = await SharedPreferences.getInstance();
    final likedToons = prefs.getStringList('likedToons');
    if (likedToons != null) {
      if (likedToons.contains(widget.id) == true) {
        setState(() {
          isLiked = true;
        });
      }
    } else {
      await prefs.setStringList('likedToons', []);
    }
  }

  
  void initState() {
    super.initState();
    webtoon = ApiService.getToonById(widget.id);
    episodes = ApiService.getLatestEpisodesById(widget.id);
    initPrefs();
  }

  onHeartTap() async {
    final likedToons = prefs.getStringList('likedToons');
    if (likedToons != null) {
      if (isLiked) {
        likedToons.remove(widget.id);
      } else {
        likedToons.add(widget.id);
      }
      await prefs.setStringList('likedToons', likedToons);
      setState(() {
        isLiked = !isLiked;
      });
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.white,
      appBar: AppBar(
        elevation: 2,
        backgroundColor: Colors.white,
        foregroundColor: Colors.green,
        actions: [
          IconButton(
              onPressed: onHeartTap,
              icon: Icon(
                isLiked ? Icons.favorite : Icons.favorite_outline,
              ))
        ],
        title: Text(
          widget.title,
          style: const TextStyle(
            fontSize: 24,
          ),
        ),
      ),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(50),
          child: Column(
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Hero(
                    tag: widget.id,
                    child: Container(
                      width: 250,
                      clipBehavior: Clip.hardEdge,
                      decoration: BoxDecoration(
                          borderRadius: BorderRadius.circular(10),
                          boxShadow: [
                            BoxShadow(
                              blurRadius: 15,
                              offset: const Offset(10, 10),
                              color: Colors.black.withOpacity(1),
                            ),
                          ]),
                      child: Image.network(
                        widget.thumb,
                        headers: const {
                          'Referer': 'https://comic.naver.com',
                        },
                      ),
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 25),
              FutureBuilder(
                future: webtoon,
                builder: ((context, snapshot) {
                  if (snapshot.hasData) {
                    return Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          snapshot.data!.about,
                          style: const TextStyle(fontSize: 16),
                        ),
                        const SizedBox(height: 15),
                        Text(
                          '${snapshot.data!.genre} / ${snapshot.data!.age}',
                          style: const TextStyle(fontSize: 16),
                        ),
                      ],
                    );
                  }
                  return const Text("...");
                }),
              ),
              const SizedBox(height: 25),
              FutureBuilder(
                future: episodes,
                builder: (context, snapshot) {
                  if (snapshot.hasData) {
                    return Column(
                      children: [
                        for (var episode in snapshot.data!.length > 10
                            ? snapshot.data!.sublist(0, 10)
                            : snapshot.data!)
                          Episode(episode: episode, webtoonId: widget.id),
                      ],
                    );
                  }
                  return Container();
                },
              )
            ],
          ),
        ),
      ),
    );
  }
}

END

0개의 댓글