Flutter

【Flutter】API通信(dio)でお天気アプリを作る【解説付き】

こんにちは、アプリ開発者のテルです!

「FlutterでのAPI通信のやり方がわからない」とお悩みではないでしょうか?

テル

本記事ではそんな悩みを解決していきます!

本記事を読むメリット
  1. FlutterでのAPI通信のやり方をサンプルで理解できる
  2. Riverpod + freezed + dioの使い方がわかるようになる
  3. コードを公開しているので、自分の環境で確かめることができる

API通信(dio)でお天気アプリを作る

事前準備

パッケージをインストール

今回使用するパッケージは以下の通りです。

dependencies:
  build_runner: ^2.1.8
  dio: ^4.0.4
  flutter:
    sdk: flutter
  flutter_keyboard_visibility: ^5.2.0
  flutter_riverpod: ^1.0.3
  freezed: ^1.1.1
  freezed_annotation: ^1.1.0
  json_serializable: ^6.1.5

dev_dependencies:
  flutter_lints: ^1.0.0
  flutter_test:
    sdk: flutter

プロジェクト構成

リポジトリ構成

完成イメージ

本アプリは2画面のみの構成になります。1画面目で入力した都市のお天気情報をAPIから取得し、2画面目で取得した内容を表示するというシンプルなアプリです。

今回はOpenWeatherApiを使用します。アプリを作り始める前にサインインをし、appIdを押さえておいてください。

OpenWeatherApi:https://openweathermap.org/current

それでは、Riverpod + freezed + dioを用いて上記のアプリを作っていきましょう!

ソースコード

main.dart

import 'package:clima/config/config.dart';
import 'package:clima/view/input_page.dart';
import 'package:clima/view/result_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Clima',
      theme: ThemeData(
        appBarTheme: kAppBarTheme,
        scaffoldBackgroundColor: kScaffoldBackgroundColor,
        inputDecorationTheme: InputDecorationTheme(
          border: kTextFieldStyle,
          focusedBorder: kTextFieldStyle,
          enabledBorder: kTextFieldStyle,
        ),
      ),
      routes: {
        '/': (context) => InputPage(),
        '/result': ((context) => const ResultPage()),
      },
    );
  }
}

model / weather.dart

まず初めに、APIから取得したい内容のクラスを作成していきましょう。今回は気温、体感気温、湿度、気圧という4つの天気情報を取得していきます。

クラスの作成にはfreezedを使用しています。freezedを使用することで、不変クラスを作成することができます。

import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

// build_runnerを使うことで自動生成されるファイル
part 'weather.freezed.dart';
part 'weather.g.dart';

// 天気情報モデル
@freezed
class Weather with _$Weather {
  factory Weather({
    int? timezone,
    int? id,
    String? name,
    WeatherMain? main,
  }) = _Weather;
  factory Weather.fromJson(Map<String, dynamic> json) =>
      _$WeatherFromJson(json);
}

// 詳しい詳細情報
@freezed
class WeatherMain with _$WeatherMain {
  factory WeatherMain({
    double? temp,
    double? feels_like,
    double? temp_min,
    double? temp_max,
    int? pressure,
    int? humidity,
  }) = _WeatherMain;
  factory WeatherMain.fromJson(Map<String, dynamic> json) =>
      _$WeatherMainFromJson(json);
}

freezedでクラスを作成したら、下記のコマンドを回してください。

flutter pub run build_runner watch --delete-conflicting-outputs

これにより、ファイルが自動生成されます。–delete-conflicting-outputsを加えることで、クラスに変更が加えられる度に再生成されます。

modelフォルダが上記のようになっていれば問題ありません。これで取得したいデータを保持するクラスを作成することができました。

service / weather_api_client.dart

次に、APIを呼び出しデータを取得するためのクラスを作っていきます。

APIの取得にはdioを使用しています。

import 'dart:convert';
import 'package:clima/models/weather.dart';
import 'package:dio/dio.dart';

// APIを呼び出し、データを取得する
class WeatherApiClient {
  Future<Weather?> fetchWeather(String? location) async {
    final dio = Dio();
    const appId = '4b0e4756a7f3015c0d08c2f0263c224a&units=metric';
    final url =
        'https://api.openweathermap.org/data/2.5/weather?q=$location&appid=$appId';
    var response = await dio.get(url);
    if (response.statusCode == 200) {
      try {
        return Weather.fromJson(response.data);
      } catch (e) {
        print(e);
        throw e;
      }
    }
  }
}

これで、APIを取得するためのクラスを作成することができました。

repository / repository.dart

APIを取得するためのメソッドをrepositoryから呼び出します。メソッドを切り分けることで、APIを取得するためのメソッドだと理解しやすくなります。

import 'package:clima/models/weather.dart';
import 'package:clima/services/weather_api_client.dart';

// APIを取得するためのメソッドをRepositoryから呼び出す
class Repository {
  final _api = WeatherApiClient();
  dynamic fetchWeather(String? location) async {
    return _api.fetchWeather(location);
  }
}

これで、APIを取得するためのメソッドをrepositoryに切り分けることができました。

view_model / provider.dart

次に、取得したAPIの状態を非同期で管理できるようにしていきます。状態管理にはRiverpodを使用しています。

今回は、非同期処理を行うのに適しているFutureProviderを使用していきます。

import 'package:clima/models/weather.dart';
import 'package:clima/repositories/repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// 都市名の状態を管理するためのProviderを作成
final cityNameProvider = StateProvider((ref) => 'Tokyo');

// Repository(APIの取得)を管理するためのProviderを作成
final repostitoryProvider = Provider((ref) => Repository());

// APIの取得を非同期で管理するためのProviderを作成
final dataProvider = FutureProvider.autoDispose<Weather>((ref) async {
  // Repositoryのインスタンスを取得
  final repository = ref.read(repostitoryProvider);
  // 都市名の状態を監視
  final cityName = ref.watch(cityNameProvider.notifier);
  // 都市名を組み込み、APIを取得する
  return await repository.fetchWeather(cityName.state);
});

これにより、APIの取得を非同期で行えるようになりました。

view / input_page.dart

最後に、お天気アプリのUI(見た目)を作っていきます。

まずは、1画面目の「都市名を入力するView」です。

import 'package:clima/components/button_widget.dart';
import 'package:clima/config/config.dart';
import 'package:clima/repositories/repository.dart';
import 'package:clima/view_models/provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class InputPage extends ConsumerWidget {
  final controller = TextEditingController();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Providerの監視
    final cityName = ref.watch(cityNameProvider.notifier);
    return KeyboardDismissOnTap(
      child: Scaffold(
        body: Center(
          child: SingleChildScrollView(
            reverse: true,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Text('Clima', style: kTitleTextStyle),
                const SizedBox(height: 20.0),
                Image.asset(
                  'assets/icon.png',
                  width: 160,
                  height: 160,
                ),
                const SizedBox(height: 30.0),
                Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 40.0),
                  child: TextFormField(
                    controller: controller,
                    onChanged: (value) {
                      cityName.state = value;
                      print(cityName.state);
                    },
                    keyboardType: TextInputType.emailAddress,
                    textAlign: TextAlign.center,
                    decoration: InputDecoration(
                      hintText: '都市名を入力してください',
                      hintStyle: kTextFieldLabelStyle,
                      labelText: 'City Name',
                      labelStyle: kTextFieldLabelStyle,
                      fillColor: Colors.white,
                      filled: true,
                      focusedBorder: kTextFieldStyle,
                      enabledBorder: kTextFieldStyle,
                    ),
                    style: kTextFieldLabelStyle,
                  ),
                ),
                const SizedBox(height: 20.0),
                ButtonWidget(
                  label: '検索',
                  press: () {
                    // 都市名をURLに組み込む
                    Repository repository = Repository();
                    repository.fetchWeather(cityName.state);
                    Navigator.pushNamed(context, '/result');
                  },
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

view / result_page.dart

最後に、2画面目の「取得したAPI情報を反映させるView」です。

FutureProviderのwhenメソッドを使用することで、Viewの反映、ローディング時、エラー時の3つの処理を書くことができるようになっています。

import 'package:clima/components/add_info.dart';
import 'package:clima/components/button_widget.dart';
import 'package:clima/config/config.dart';
import 'package:clima/models/weather.dart';
import 'package:clima/repositories/repository.dart';
import 'package:clima/view_models/provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class ResultPage extends ConsumerWidget {
  const ResultPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // dataProviderの監視
    final asyncValue = ref.watch(dataProvider);
    // cityNameProviderの監視
    final cityName = ref.watch(cityNameProvider.notifier);
    return Scaffold(
      appBar: AppBar(title: const Text('Clima')),
      body: Center(
        child: asyncValue.when(
          loading: () => const CircularProgressIndicator(),
          error: (e, _) => Text(e.toString()),
          data: (data) => Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Container(
                padding: const EdgeInsets.all(20.0),
                decoration: kCityNameContainerStyle,
                child: Text(cityName.state, style: kCityNameTextStyle),
              ),
              const SizedBox(height: 60.0),
              Column(
                children: const [
                  Text('Additional Information', style: kAddInfoTextStyle),
                  SizedBox(height: 20.0),
                  Divider(color: Colors.black54, height: 1.0),
                ],
              ),
              const SizedBox(height: 10.0),
              Column(
                children: [
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      AddInfo(
                          label: '気温', value: data.main!.temp.toString() + '°'),
                      AddInfo(
                        label: '体感気温',
                        value: data.main!.feels_like.toString() + '°',
                      ),
                    ],
                  ),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      AddInfo(
                        label: '湿度',
                        value: data.main!.humidity.toString() + '%',
                      ),
                      AddInfo(
                        label: '気圧',
                        value: data.main!.pressure.toString() + 'hPa',
                      ),
                    ],
                  ),
                  const SizedBox(height: 30.0),
                  ButtonWidget(
                    label: '更新',
                    press: () async {
                      ref.refresh(dataProvider);
                    },
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

大変お疲れ様でした!以上でアプリは完成です!

今回ご紹介したアプリ全体のソースコードはこちらです。再利用に用いる「components」と「config」も記載されています。

よろしければ、ご参考にどうぞ。

GitHub:https://github.com/terupro/clima

まとめ

今回は、API通信(dio)でお天気アプリを作る方法をご紹介しました。

dioを使うことで、API通信を楽に実装することができます。ご紹介したサンプルを参考に、自分で色々と試してみてください。

▼Flutterの効率的な勉強法を下記でご紹介しています。

【2022年版】Flutterの効率的な勉強法!具体的な手順を解説

本記事が参考になっていれば幸いです。

最後までご覧いただきありがとうございました。ではまた!

参考文献
Flutter関連の書籍を出版しました!