flutter+dioで通信の進捗を表示する

# はじめに
　APIの通信の進捗やファイルのダウンロードの進捗を表示をdioパッケージを用いて実現する方法を書いたものです。
実行環境は以下のとおりです。(一部省略)

```
$ flutter doctor -v
[✓] Flutter (Channel stable, 3.27.4, on macOS 15.3.2 24D81 darwin-arm64, locale ja-JP)
    • Flutter version 3.27.4 on channel stable at /Users/name/fvm/versions/3.27.4
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision d8a9f9a52e (6 weeks ago), 2025-01-31 16:07:18 -0500
    • Engine revision 82bd5b7209
    • Dart version 3.6.2
    • DevTools version 2.40.3
[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
[✓] Network resources
    • All expected network resources are available.
```

# 作るもの
全体のコードは[こちらのgithubリポジトリ](https://github.com/you22fy/show_progress_sample)を参照してください。
README.mdにデモ動画を添付しています。


# APIの実装
まずはある程度時間がかかり、かつ、そこそこのデータを取得できるサンプルのAPIを作成します。
`server/server.dart` に相当します。

コードは全体として以下のとおりです。

```dart
import 'dart:io';
import 'dart:convert';

Future<void> main() async {
  final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 8080);
  print('サーバー起動: http://${server.address.host}:${server.port}');
  const clientLocalHost = 'http://localhost:3000';

  await for (HttpRequest request in server) {
    request.response.headers
        .add('Access-Control-Allow-Origin', clientLocalHost);
    request.response.headers.add('Access-Control-Allow-Credentials', 'true');
    request.response.headers.add('Access-Control-Allow-Headers',
        'Origin, X-Requested-With, Content-Type, Accept');
    request.response.headers
        .add('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');

    if (request.method == 'OPTIONS') {
      request.response.statusCode = HttpStatus.ok;
      await request.response.close();
      continue;
    }

    // 1000個のチャンクを10msごとに
    const int chunks = 1000;
    const duration = Duration(milliseconds: 10);

    List<List<int>> chunkBytesList = [];
    for (int i = 0; i < chunks; i++) {
      final dummyData = {
        'chunk': i + 1,
        'message': 'This is chunk ${i + 1}',
        'data':
            List.generate(20, (index) => 'Item ${index + 1} in chunk ${i + 1}'),
        'timestamp': DateTime.now().toIso8601String(),
      };

      final chunkStr = "${jsonEncode(dummyData)}\n";
      final chunkBytes = utf8.encode(chunkStr);
      chunkBytesList.add(chunkBytes);
    }
    final totalBytes =
        chunkBytesList.fold(0, (sum, element) => sum + element.length);

    request.response.headers.contentLength = totalBytes;
    request.response.headers.contentType = ContentType.json;

    for (var chunkBytes in chunkBytesList) {
      request.response.add(chunkBytes);
      await request.response.flush();
      await Future.delayed(duration);
    }

    await request.response.close();
  }
}

```

ここで重要なのはレスポンスヘッダに[Content-Length](https://developer.mozilla.org/ja/docs/Web/HTTP/Reference/Headers/Content-Length)が設定されていることです。
実装だとこの部分です。
```dart
...
    request.response.headers.contentLength = totalBytes;
...
```

これは、そのAPIが返す合計のサイズが格納されるフィールドでありこれを用いて進捗表示を行います。

つまり、呼び出し側で 100 * (現在の取得長) / (Content-Length) を計算して進捗を計算することにより実現するということです。

したがって、このフィールドが適切に設定されていない場合は正しく進捗を表示することはできません。


# flutter側の実装
API通信には [dio](https://pub.dev/packages/dio)を使用します。

`flutter pub add dio ` にて追加しておきましょう。

`lib/main.dart`のコードを以下に示します。

```dart
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'API通信の進捗状況を表示するデモアプリ',
      home: const HomePage(),
    );
  }
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  HomePageState createState() => HomePageState();
}

class HomePageState extends State<HomePage> {
  final Dio dio = Dio();
  double progress = 0.0;
  String progressText = "0%";
  String responseData = "";
  bool loading = false;

  Future<void> callApi() async {
    setState(() {
      loading = true;
      responseData = "";
      progress = 0.0;
      progressText = "0%";
    });

    if (kIsWeb) {
      (dio.httpClientAdapter as dynamic).withCredentials = true;
    }

    try {
      final response = await dio.get(
        "http://127.0.0.1:8080",
        options: Options(responseType: ResponseType.plain),
        onReceiveProgress: (int received, int total) {
          debugPrint("received: $received, total: $total");
          if (total > 0) {
            double currentProgress = received / total;
            setState(() {
              progress = currentProgress;
              progressText = "${(currentProgress * 100).toStringAsFixed(0)}%";
            });
          } else {
            setState(() {
              progressText = "読み込み中...";
            });
          }
        },
      );
      setState(() {
        responseData = response.data;
      });
    } catch (e) {
      setState(() {
        responseData = "Error: $e";
      });
    } finally {
      setState(() {
        loading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Flutter Web CORS Demo"),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            ElevatedButton(
              onPressed: loading ? null : callApi,
              child: const Text("API呼び出し"),
            ),
            const SizedBox(height: 20),
            LinearProgressIndicator(
              value: progress.isFinite ? progress : 0.0,
              minHeight: 10,
            ),
            const SizedBox(height: 20),
            Text(
              "進捗: $progressText",
              style: const TextStyle(fontSize: 20),
            ),
            const SizedBox(height: 20),
            Expanded(
              child: SingleChildScrollView(
                child: Text(responseData),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

```


この中で重要なのは以下の部分です。
```
      final response = await dio.get(
        "http://127.0.0.1:8080",
        options: Options(responseType: ResponseType.plain),
        onReceiveProgress: (int received, int total) {
          debugPrint("received: $received, total: $total");
          if (total > 0) {
            double currentProgress = received / total;
            setState(() {
              progress = currentProgress;
              progressText = "${(currentProgress * 100).toStringAsFixed(0)}%";
            });
          } else {
            setState(() {
              progressText = "読み込み中...";
            });
          }
        },
      );

```

[onReceiveProgress](https://pub.dev/documentation/dio/latest/dio/RequestOptions/onReceiveProgress.html) 属性を使うことで現在の取得長と合計長の両方を受け取ることができます。

これを用いて適宜計算を行うことで進捗を表示することができます。

# 動かす
実行する際は以下のコマンドでサンプルのAPIとアプリを立ち上げましょう。
```sh
# API実行
dart run server/server.dart

# アプリ実行
flutter run -d web-server --web-port 3000

```

# さいごに
最後までお読みいただきありがとうございました。

当記事に誤字脱字や内容の誤り等ありましたらコメント等で補足訂正していただけると幸いです。