FlutterでAPI連携＋HTMLの描画をする

## 初めに

FlutterのサーバーサイドはFirebaseばかり触っていたのですが、
普通にAPI連携もやっとかないと！と思い備忘録として記事を書きました。

今回は、Google Books APIを用いて簡易的な書籍リストと一覧表示をします。
また、APIのレスポンスの中にHTMLが含まれているため、Flutterで簡易的にhtmlタグを表示する内容について記述していきます。

## 使用パッケージ
- go_router: ^3.1.1 
- url_launcher: ^6.1.2
- flutter_html: ^2.2.1
- http: ^0.13.4

## 最終ゴール
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/912fd281-8032-bb65-fd77-ae5b1733d5c7.png" width="200">

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/76f2f3d9-b596-4739-661c-7bc5631d522d.png" width="200">


## まずは全体のコード

サクッとコードを見ていきましょう。
主要部分の解説は、コードの下に記述していきます。

```dart:main.dart
import 'package:flutter/material.dart';
import 'package:flutter_rest_api/detail_page.dart';
import 'package:go_router/go_router.dart';

import 'package:http/http.dart' as http;
import 'dart:convert';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  MyApp({Key? key}) : super(key: key);

  @override
  // GoRouterの設定
  Widget build(BuildContext context) => MaterialApp.router(
        routeInformationParser: _router.routeInformationParser,
        routerDelegate: _router.routerDelegate,
      );
  // ルーティングの設定
  final GoRouter _router = GoRouter(
    routes: <GoRoute>[
      GoRoute(
        path: '/',
        builder: (BuildContext context, GoRouterState state) =>
            const MyHomePage(title: 'Flutter REST API'),
      ),
      GoRoute(
        path: '/book/:id', // 本のIDを引き渡す
        builder: (context, state) {
          // パスパラメータの値を取得するには state.params を使用
          final String id = state.params['id']!;
          return BookDetailPage(id: id);
        },
      ),
    ],
    initialLocation: '/',
  );
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  // APIのレスポンスを格納する
  List items = [];
  // APIリクエスト
  Future<void> getData() async {
    var response = await http.get(Uri.https(
        'www.googleapis.com', // ベースURL
        '/books/v1/volumes', // パス
        {'q': '{Flutter}', 'maxResults': '35', 'langRestrict': 'ja'})); // パラメーター

    var jsonResponse = jsonDecode(response.body);

    setState(() {
      items = jsonResponse['items'];
    });
  }

  @override
  void initState() {
    super.initState();
    getData();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter REST API'),
      ),
      // データのリストを簡易的に表示
      body: ListView.builder(
        itemCount: items.length,
        itemBuilder: (BuildContext context, int index) {
          return Card(
            child: Column(
              children: <Widget>[
                items[index] != null
                    ? GestureDetector(
                        child: ListTile(
                          // サムネ（書籍画像）
                          leading: Image.network(
                            items[index]['volumeInfo']['imageLinks']
                                    ['thumbnail'] ??
                                '',
                          ),
                          // 書籍タイトル
                          title:
                              Text(items[index]['volumeInfo']['title'] ?? ''),
                          // 発行年月日
                          subtitle: Text(items[index]['volumeInfo']
                                  ['publishedDate'] ??
                              ''),
                        ),
                        // IDを詳細画面に引き継ぐ
                        onTap: () {
                          // 詳細画面へ遷移
                          context.go('/book/${items[index]['id']}');
                        },
                      )
                    : const SizedBox(),
              ],
            ),
          );
        },
      ),
    );
  }
}
```

```dart:detail_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_html/flutter_html.dart';
import 'package:flutter_rest_api/debug_function.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:url_launcher/url_launcher.dart';

class BookDetailPage extends StatefulWidget {
  const BookDetailPage({Key? key, required this.id}) : super(key: key);

  final String id;

  @override
  State<BookDetailPage> createState() => _BookDetailPageState();
}

class _BookDetailPageState extends State<BookDetailPage> {
  // APIのレスポンスを格納する
  late Map<String, dynamic> bookInfo;
  late Map<String, dynamic> saleInfo;
  bool isFetching = true;

  Future<void> getData() async {
    var response = await http.get(Uri.https(
        'www.googleapis.com',
        '/books/v1/volumes/${widget.id}'));
    var jsonResponse = jsonDecode(response.body);

    setState(() {
      bookInfo =  Map<String,dynamic>.from(jsonResponse['volumeInfo']);
      saleInfo =  Map<String,dynamic>.from(jsonResponse['saleInfo']);
      isFetching = false;
    });
  }

  // 外部リンクへの遷移
  void _launchURL(String linkText) async {
    var url = Uri.parse('https://developer.android.com/?hl=ja');
    // 正しいURLかチェックする
    if (await canLaunchUrl(url)) {
      // URLを開く
      await launchUrl(url);
    } else {
      throw 'Could not launch $url';
    }
  }

  String _authorsText(List authors){
    String text = '';
    int index = 1;
    for(final author in authors){
      text += author;
      if(index != authors.length){
        text += '/';
      }
      index ++;
    }
    return text;
  }

  @override
  void initState() {
    getData();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return isFetching ? Scaffold(
      appBar: AppBar(
        leading: IconButton(
          icon: const Icon(Icons.arrow_back),
          onPressed: () => context.go('/'),
        ),
        title: const Text('Loading...'),
      ),
      body: const Center(child: CircularProgressIndicator()),
    ) : Scaffold(
      appBar: AppBar(
        title: const Text('書籍詳細'),
        leading: IconButton(
          icon: const Icon(Icons.arrow_back),
          onPressed: () => context.go('/'),
        ),
      ),
      body: ListView(
        children: <Widget>[
          const SizedBox(height: 15),
          Center(
            child: Text(bookInfo['title'], style: const TextStyle(
              fontSize: 20,
              fontWeight: FontWeight.w400,
            ),maxLines: 3,),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 20),
            child: SizedBox(
              height: 160,
              child: GestureDetector(
                // child: Container(color: Colors.red, width: 30, height: 30),
                child: Image.network(bookInfo['imageLinks']['small']),
                onTap: () {
                  _launchURL(saleInfo['buyLink']);
                }
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('販売価格'),
                const SizedBox(width: 20),
                Text('¥ ${saleInfo['listPrice']['amount'].toString()}'),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('著者'),
                const SizedBox(width: 20),
                Text(_authorsText(bookInfo['authors'])),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 8),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('出版社'),
                const SizedBox(width: 20),
                Text(bookInfo['publisher']),
              ],
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 15),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                const Padding(
                  padding: EdgeInsets.symmetric(vertical: 10),
                  child: Text('書籍概要', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
                ),
                Html(data: bookInfo['description']),
              ],
            ),
          ),

        ],
      ),
    );
  }
}
```

## 主要部分の解説
### APIリクエスト
APIのリクエストをするには、[httpパッケージ](https://pub.dev/packages/http)を用いることで簡単にREST APIの処理が実装できます。作成段階ではまだ0系なものの、Likesの多さやPopularityを見たら問題はないかなと思います。

```dart:main.dart
  import 'package:http/http.dart' as http;
  import 'dart:convert';

  Future<void> getData() async {
    // GETリクエスト
    var response = await http.get(Uri.https(
        'www.googleapis.com', // ベースURL
        '/books/v1/volumes', // パス
        {'q': '{Flutter}', 'maxResults': '35', 'langRestrict': 'ja'})); // パラメータ

    var jsonResponse = jsonDecode(response.body);

    setState(() {
      items = jsonResponse['items']; // 配列に格納
    });
  }
```

### 外部ページへ遷移する
外部のページをブラウザで開きたい（htmlのaタグ的なことがしたい）場合には、[url_launcherパッケージ](https://pub.dev/packages/url_launcher)がおすすめです。

```dart:detal_page.dart
  import 'package:url_launcher/url_launcher.dart';

  void _launchURL(String linkText) async {
    var url = Uri.parse(linkText);
    // 正しいURLかチェックする
    if (await canLaunchUrl(url)) {
      // URLを開く
      await launchUrl(url);
    } else {
      throw 'Could not launch $url';
    }
  }
```

### HTMLを描画する
Flutterの内部構造を深く知っているわけではないですが、HTMLの描画をするには一手間必要です。
今回はすごく簡易的で良かったため、[flutter_htmlパッケージ](https://pub.dev/packages/flutter_html)を利用してAPIから返ってくるHTMLを描画することができました。
```dart:detal_page.dart
import 'package:flutter_html/flutter_html.dart';

// Widget的な使い方です
Html(data: bookInfo['description']),
```

この書籍概要の部分は、Columnウィジェットなどは使っておらず、全てHTMLを変換したものです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/735195/db37e4c5-a9aa-5ffa-df40-1751bbf899ab.png" width="200">


以上です。
flutter_htmlやhttpパッケージは、もっといろいろなことができます。
今回は簡易的な紹介でしたが、ぜひ興味を持った方は活用してみてください。