Flutter API 通信で Qiita の記事を GET !

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2680230/f5939162-0d31-fbb4-ed7c-00904120a701.png)

# はじめに
今回は会社のイベントで **Advent Calendar** に参加しての記事になります。

私が Qiita の記事を書こうとしたキッカケは、今回の「2024 Qiita Advent Calendar 」です。
このキッカケがなければ、Qiita に投稿せず記事を拝見するだけだったと思います。
今までの Qiita 記事は、この Advent Calendar 記事を書くための練習、今回はいつも以上に綺麗に書きたいと思います！
（このイベントが終わっても、Qiita への記事は続けます）

今回は、前回の「[Flutter サンプルを動かして「おぉ」ってなりました](https://qiita.com/magic10r/items/b89dfc2c45eb5b71241b)」から、さらに一歩進んで **「Qiita APIを利用して、Flutter で Qiita 記事を表示するアプリ」** を作ります

# 開発環境
- OS : Windows 11 Pro
- Flutter : 3.24.3
- Dart : 3.5.3
- Cursor(IDE) : 0.42.3
- Android Studio : Koala Feature Drop | 2024.1.2

# 完成アプリ
![Flutter_FirstSampleApp.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2680230/945f9e04-209f-213e-fc92-268e67648530.gif)

## 画面遷移
```mermaid
stateDiagram-v2
    [*] --> 「検索＆検索結果」画面
    「検索＆検索結果」画面 --> 「検索＆検索結果」画面:検索条件入力 + Enter で検索結果一覧を表示
    「検索＆検索結果」画面 --> 「記事」画面:検索結果一覧から選択
    「記事」画面　--> 「検索＆検索結果」画面:WebView の"戻る"
```

## ディレクトリ＆ファイル
説明に必要な個所だけ
```
flutter_qiita_search
├─lib
│  │  main.dart                   -> アプリのエントリーポイント
│  │
│  ├─models
│  │      article.dart            -> 記事情報
│  │      user.dart               -> ユーザー情報
│  │      
│  ├─screens
│  │      article_screen.dart     -> 記事画面
│  │      search_screen.dart      -> 検索＆検索結果画面
│  │
│  └─widgets
│          article_container.dart -> 記事を表示するUIパーツ
│  .env                           -> 非公開情報
│  .flutter-plugins
│  .flutter-plugins-dependencies
│  analysis_options.yaml
│  pubspec.lock
│  pubspec.yaml
└  README.md
```

# 開発のポイント
前回から 1 歩進んだ箇所を"ポイント"として記載します。
- パッケージの利用
- API 通信
- Widget での UI 構築
- 画面遷移

## パッケージの利用
「[Flutter > flutter clean > flutter pub get で解決できない、、、](https://qiita.com/magic10r/items/9aeb4153ed565d240d1f)」で投稿させて頂いた通り、ハマってしまったパッケージのインポートです。
バージョンなどの詳細は、`pubspec.yaml` ファイルをご覧ください。

### flutter_dotenv
- Qiita へのアクセストークンをコード上にベタ打ちしないため、`.env` ファイルで公開するプログラムソースからアクセストークンを除外します
    ```
    ///　メイン処理
    Future<void> main() async { // 非同期処理となる為、main関数をFutureに変更
      await dotenv.load(fileName: '.env'); // .envファイルを読み込み
      runApp(const MainApp());
    }
    ```
- 当たり前ですけど、作成した `.env` ファイルは　`gitignore` ファイルで Git から対象外にします
    ```
    # env
    .env
    ````
#### ポイント
- assets 登録
    - `pubspec.yaml` ファイルに `assets` **登録を忘れずに**！！！
        ```
        flutter:
          uses-material-design: true
          assets:
            - .env
        ```

### http
- http 通信します
    ```
    Future<List<Article>> searchQiita(String keyword) async {
    // 1. http通信に必要なデータを準備をする
    // URL、クエリパラメータの設定
    final uri = Uri.https('qiita.com', '/api/v2/items', {
      'query': 'title:$keyword',
      'per_page': '10',
    });
    
    // 2. Qiita APIにリクエストを送る
    final http.Response res = await http.get(uri, headers: {
      'Authorization': 'Bearer $token',
    });
    ```

#### ポイント
- FlutterSDK の [`http`](https://pub.dev/packages/http) と被るため、`http` パッケージはインポートで別名として利用する
    ```
    // http通信で使用。httpという変数を通して、httpパッケージにアクセス
    import 'package:http/http.dart' as http;
    ```

### intl
- 日付をフォーマットします
    ```
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 投稿日
        Text(
          DateFormat('yyyy/MM/dd').format(article.createdAt),
          style: const TextStyle(
            color: Colors.white,
            fontSize: 12,
          ),
        ),
    ```
    - 今回は利用しませんでしたが、日付フォーマットの他にも NumberFormat などがあります

### webview_flutter
- アプリ内で Qiita 記事（ Web サイト)を開きます
    ```
    /// 記事画面のステートレスエンドポイント。
    class _ArticleScreenState extends State<ArticleScreen> {
      late WebViewController controller = WebViewController()
        ..loadRequest(
          Uri.parse(widget.article.url),
        );

    ```

#### ポイント
- カスケードオペレータ(..)
    - ドットを２つ繋げたオペレーターを使う事で、１つのクラスの処理を連鎖的に実行
- late キーワード
    - 変数の初期化を遅らせる。変数が宣言された時点ではなく、後で初期化したい場合に利用する

## API 通信
Qiita API にリクエストを送り、レスポンスを加工して画面に表示します。

### Qiita API エンドポイント
-  `[GET] https://qiita.com/api/v2/items`
    -  ブラウザからエンドポイントにアクセスすると、スゴイ量の JSON なので注意

### アクセストークン
- アクセストークンは、ページ下部の"参考（感謝）"をご覧ください

### Qiita API 通信
- Qiita API をアクセストークンを使って呼び出します
    ```
    Future<List<Article>> searchQiita(String keyword) async {
        // 1. http通信に必要なデータを準備をする
        // URL、クエリパラメータの設定
        final uri = Uri.https('qiita.com', '/api/v2/items', {
          'query': 'title:$keyword',
          'per_page': '10',
        });
        // アクセストークンの取得
        final String token = dotenv.env['QIITA_ACCESS_TOKEN'] ?? '';
        
        // 2. Qiita APIにリクエストを送る
        final http.Response res = await http.get(uri, headers: {
          'Authorization': 'Bearer $token',
        });
        
        // 3. 戻り値をArticleクラスの配列に変換
        // 4. 変換したArticleクラスの配列を返す(returnする)
        if (res.statusCode == 200) {
          // レスポンスをモデルクラスへ変換
          final List<dynamic> body = jsonDecode(res.body);
          return body.map((dynamic json) => Article.fromJson(json)).toList();
        } else {
          return [];
        }
    }
    ```

### ポイント
- 非同期処理
    - Future
        - 非同期処理の宣言
    - async
        - 非同期処理だけど同期させる
    - await
        - 非同期処理が完了するまで待つ
-  ?? 演算子
    -  nullの場合に右側の値が適応される
- レスポンスをモデルクラスへ変換
    - ここは、お決まりパターンのようです

## Widget での UI 構築
各画面を Widget で構築します

### 「検索＆検索結果」画面
- 検索条件 : `TextField`
    - 余白を入れたいので `TextField` を `Padding` で囲んでます
    - `onSubmitted` で、検索処理を実行
    - `setState` で、`articles` を保持します
- 検索結果一覧 : ListView
    - `ListView` で、複数レコード時には縦スクロールを表示
    - レコード単位の表示は `ArticleContainer` で表示
    ```
       // Widget
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          // 画面にAppBarを作成
          appBar: AppBar(
            title: const Text('Qiita Search Test'),
          ),
          body: Column(
            children: [
              // 検索ボックス
              Padding(
                // ← Paddingで囲む
                padding: const EdgeInsets.symmetric(
                  vertical: 12,
                  horizontal: 36,
                ),
                child: TextField(
                  style: TextStyle(
                    // ← TextStyleを渡す
                    fontSize: 18,
                    color: Colors.black,
                  ),
                  decoration: InputDecoration(
                    // ← InputDecorationを渡す。Inputの装飾
                    hintText: '検索ワードを入力してください',
                  ),
                  onSubmitted: (String value) async {
                    final results = await searchQiita(value); // ← 検索処理を実行する
                    setState(() => articles = results); // 検索結果を代入
                  },
                ),
              ),
              // 検索結果一覧
              Expanded(
                child: ListView(
                  children: articles
                      .map((article) => ArticleContainer(article: article))
                      .toList(),
                ),
              ),
            ],
          ),
        );
      }
    ```

#### 検索結果のレコード
- 検索結果は `ArticleContainer` で表示
- Widget から利用される各 UI パーツは、`xxxContainer` として定義
- 余白を入れたいので `Padding` で囲んでます
- コンストラクタで、記事情報を受け取る
- `child: Column` と `Row` で表示の骨格を作る
- `Icons.favorite`で、ハートマークのアイコンを表示。"いいね"の数もハートの下に表示
- `NetworkImage(article.user.profileImageUrl)`で、投稿者のアイコンを表示
- `crossAxisAlignment` プロパティで、配置を下部へ修正
    ```
      // Widget
      @override
      Widget build(BuildContext context) {
        return Padding(
          padding: const EdgeInsets.symmetric(
            vertical: 12,
            horizontal: 16,
          ),
          child: GestureDetector( // GestureDetectorでContainerを囲う
            onTap: () {
              Navigator.of(context).push(
                MaterialPageRoute(
                  builder: ((context) => ArticleScreen(article: article)),
                ),
              );
            },
            child: GestureDetector(
              onTap: () {
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: ((context) => ArticleScreen(article: article)),
                  ),
                );
              },
              child: Container( 
                height: 180, // 高さを指定
                padding: const EdgeInsets.symmetric( 
                  // 内側の余白を指定
                  horizontal: 20,
                  vertical: 16,
                ),
                decoration: const BoxDecoration( // Boxの装飾
                  color: Color(0xFF55C500), // ← 背景色を指定
                  borderRadius: BorderRadius.all(
                    Radius.circular(32),// ← 角丸を設定
                  ),
                ),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    // 投稿日
                    Text(
                      DateFormat('yyyy/MM/dd').format(article.createdAt),
                      style: const TextStyle(
                        color: Colors.white,
                        fontSize: 12,
                      ),
                    ),
                    // タイトル
                    Text(
                      article.title,
                      maxLines: 2,
                      overflow: TextOverflow.ellipsis,
                      style: const TextStyle(
                        fontSize: 16,
                        fontWeight: FontWeight.bold,
                        color: Colors.white,
                      ),
                    ),
                    // タグ
                    Text(
                      '#${article.tags.join(' #')}',
                      style: const TextStyle(
                        fontSize: 12,
                        color: Colors.white,
                        fontStyle: FontStyle.italic,
                      ),
                    ),
                    // 記事の下の部分
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      crossAxisAlignment: CrossAxisAlignment.end,
                      children: [
                        // ハートアイコンといいね数
                        Column(
                          children: [
                            const Icon(
                              Icons.favorite,
                              color: Colors.white,
                            ),
                            Text(
                              article.likesCount.toString(),
                              style: const TextStyle(
                                fontSize: 12,
                                color: Colors.white,
                              ),
                            ),
                          ],
                        ),
                        // 投稿者のアイコンと投稿者名
                        Column(
                          crossAxisAlignment: CrossAxisAlignment.end,
                          children: [
                            CircleAvatar(
                              radius: 26,
                              backgroundImage: NetworkImage(article.user.profileImageUrl),
                            ),
                            const SizedBox(height: 4),
                            Text(
                              article.user.id,
                              style: const TextStyle(
                                fontSize: 12,
                                color: Colors.white,
                              ),
                            ),
                          ],
                        ),
                      ],
                    ),
                  ],
                )
              ),
            ),
          )
        );
      }
    ```

### 「記事表示」画面
- 記事詳細の "URL" を元に "WebView" で Web サイトを表示
- WebViewController と WebViewWidget
    - WebViewController : 開く記事の URL を設定
    - WebViewWidget : WebViewController を渡すと表示される
    ```
    /// 記事画面のステートレスエンドポイント。
    class _ArticleScreenState extends State<ArticleScreen> {
      late WebViewController controller = WebViewController()
        // カスケードオペレータ(..)を使って、コンストラクタの後にloadRequestを実行
        ..loadRequest(
          Uri.parse(widget.article.url),
        );
    
      // Widget
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          // 画面にAppBarを作成
          appBar: AppBar(
            title: const Text('Article Page'),
          ),
          body: WebViewWidget(controller: controller),
        );
      }
    }
    ```

## 画面遷移
- `GestureDetector` Widget を利用して、タップイベントを実装します
- `Navigator` クラスに `MaterialPageRoute` を使って、`ArticleScreen` を遷移先として渡します
    ```
    child: GestureDetector(
      onTap: () {
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: ((context) => ArticleScreen(article: article)),
          ),
        );
      },
      child: Container( 
    ```

### Flutter 画面遷移イメージ
- `Navigator` クラスに対して、`Route` クラスでラップしたページを渡す事でページの遷移します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2680230/81cf30c3-9a8b-a07e-b9a2-3594e6019dcc.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2680230/60621aad-204d-8241-0a75-5e6b4ec9a3ff.png)


# おわりに
前回から、比べると作成時間が大幅にかかりました。
上記で記載した通り新しく覚えた箇所も多かったですが、コードにコメントを多く入れて理解する事を努力しました。

また、前回も「カッコが多い」と感じましたが、今回はもっとカッコが多いです。。。 
特に `article_containar` で検索結果を 1 レコードごと Widge で処理する部分は、ぐちゃぐちゃです。
この"ぐちゃぐちゃ感"は、いつか慣れてくるのでしょうか？

まだまだ、自分のアイディアを Flutter で 1 から表現するには勉強不足なので、もう 1 歩 Flutter の勉強を続けたいと思います。

**まだ理解しきれてない箇所**
- カッコでぐちゃぐちゃなコードを綺麗にする方法
- コンストラクタのキーの使い方
- Widget デザインの決め方
- `ArticleScreen` で、`setState` しないけど？

---

**参考（感謝）**
- [GitHub](https://github.com/mt-magic/flutter_qiita_search)
- [いちから始めるFlutterモバイルアプリ開発](https://zenn.dev/heyhey1028/books/flutter-basics)
- [Qiita API アクセストークン発行方法](https://qiita.com/maiamea/items/680cca06f7825595cba0)
- [Qiita API v2ドキュメント - Qiita Developer](https://qiita.com/api/v2/docs#get-apiv2items)
- [FlutterでIntlパッケージを使う（超簡易）](https://qiita.com/kemmowner/items/33113661cda55141417e)
- [カスケード演算子 ” .. ” とは?](https://blog.flutteruniv.com/dart-cascades/#toc1)
- [Dartの非同期処理を本気で学ぶ](https://zenn.dev/urasan/articles/f6613470658de1)
- [テキストから図が生成できるMermaidについてのザックリ解説](https://qiita.com/b-mente/items/97a4296666faccd53a72)
- [マーメイド#11 状態遷移図1](https://qiita.com/hirokiwa/items/fd4296e8ae087d67ad43)