Flutter デスクトップ アプリケーションを作成する

Flutter は、1 つのコードベースからネイティブにコンパイルして、モバイル、ウェブ、デスクトップの美しいアプリケーションを作成できる Google の UI ツールキットです。この Codelab では、GitHub API にアクセスして、リポジトリ、割り当てられた問題、pull リクエストを取得する Flutter デスクトップ アプリを作成します。このタスクでは、ネイティブ API およびデスクトップ アプリケーションとやり取りするプラグインを作成して使用し、また、GitHub の API 用に型安全なクライアント ライブラリを作成するコード生成を使用します。

学習内容

  • Flutter デスクトップ アプリケーションを作成する方法
  • デスクトップで OAuth2 を使用して認証を行う方法
  • コード生成で Flutter の GraphQL を使用する方法
  • Flutter プラグインを作成してネイティブ API と統合する方法

作成するアプリの概要

この Codelab では、Flutter SDK を使用して、GitHub GraphQL API 統合を使用するデスクトップ アプリケーションを作成します。アプリでは次の処理を行います。

  • GitHub に対する認証処理を行う。
  • GitHub v4 API からのデータを取得する。
  • Windows 用、macOS 用、Linux 用 Flutter プラグインを作成する。
  • ネイティブ デスクトップ アプリケーションに対する Flutter UI のホットリロードを開発する。

これから作成する、Windows で動作するデスクトップ アプリケーションのスクリーンショットを次に示します。

775e773e58e53e85.png

この Codelab では、Flutter デスクトップ アプリに GraphQL 機能を追加することに重点を置きます。関連のない概念やコードブロックについては詳しく触れず、コピーして貼り付ければ済む形で提供します。

この Codelab で学びたいことは次のどれですか?

このトピックは初めてなので、簡単に概要を知りたい。 このトピックについてはある程度知っているが、復習したい。 プロジェクトで使用するサンプルコードを探している。 特定の項目に関する説明を確認したい。

開発はデプロイする予定のプラットフォームで行う必要があります。そのため、Windows のデスクトップ アプリを開発する場合は、適切なビルドチェーンにアクセスできるように Windows で開発する必要があります。

すべてのオペレーティング システムで、開発に Flutter SDKエディタの 2 つのソフトウェアが必要です。

さらに、オペレーティング システムごとの要件があります。詳細は flutter.dev/desktop に記載されています。

Flutter でデスクトップ アプリケーションの開発を始める

1 回限りの構成の変更でデスクトップ サポートを設定する必要があります。

$ flutter config --enable-windows-desktop # for the Windows runner
$ flutter config --enable-macos-desktop   # for the macOS runner
$ flutter config --enable-linux-desktop   # for the Linux runner

デスクトップ版 Flutter が有効になっていることを確認するには、次のコマンドを実行します。

$ flutter devices
1 connected device:

Windows (desktop) • windows    • windows-x64    • Microsoft Windows [Version 10.0.19041.508]
macOS (desktop)   • macos      • darwin-x64     • macOS 11.2.3 20D91 darwin-x64
Linux (desktop)   • linux      • linux-x64      • Linux

上の出力に該当するデスクトップの行が表示されない場合は、次のことを確認してください。

  • 開発対象のプラットフォームで開発しているか。
  • flutter config を実行すると、macOS が enable-[os]-desktop: true で有効になっていると表示されるか。
  • flutter channel を実行すると、現在のチャネルとして devmaster のどちらかが表示されるか。stablebeta のチャネルではコードが動作しないため、この確認が必要になります。

Flutter コマンドライン ツールを使って Flutter プロジェクトを作成することで、デスクトップ アプリ向けに Flutter のコーディングを簡単に開始できます。また、IDE で UI から Flutter プロジェクトを作成するワークフローが提供されている場合もあります。

$ flutter create github_graphql_client
Creating project github_graphql_client...
[Eliding listing of created files]
Wrote 127 files.

All done!
In order to run your application, type:

  $ cd github_graphql_client
  $ flutter run

To enable null safety, type:

  $ cd github_graphql_client
  $ dart migrate --apply-changes

Your application code is in github_graphql_client/lib/main.dart.

macOS と Linux では、次のようにプロジェクトを移行して、null 安全を有効にします。

$ cd github_graphql_client
$ dart migrate --apply-changes

Windows の場合は、次のようになります。

PS C:\src\> cd github_graphql_client
PS C:\src\github_graphql_client> dart migrate --apply-changes

この Codelab を簡略化するために、Android、iOS、ウェブのサポート ファイルは削除します。これらのファイルは、デスクトップ アプリケーション用の Flutter には不要です。ファイルを削除することで、この Codelab 中に間違ったものを実行する危険がなくなります。

macOS および Linux の場合:

$ rm -r android ios web

Windows の場合:

PS C:\src\github_graphql_client> rmdir android
PS C:\src\github_graphql_client> rmdir ios
PS C:\src\github_graphql_client> rmdir web

問題なく動作していることを確認するために、以下のように、ボイラープレート Flutter アプリケーションをデスクトップ アプリケーションとして実行します。または、IDE でこのプロジェクトを開き、その機能を使用してアプリケーションを実行します。さきほどの作業のおかげで、デスクトップ アプリケーションとして実行する以外の選択肢はなくなっているはずです。

$ flutter run
Launching lib/main.dart on macOS in debug mode...
Building macOS application...
Activating Dart DevTools...                                         4.2s
Syncing files to device macOS...                                    55ms

Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h Repeat this help message.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).
An Observatory debugger and profiler on macOS is available at: http://127.0.0.1:56370/WURs0AbCsEY=/

Flutter DevTools, a Flutter debugger and profiler, on macOS is available at:
http://127.0.0.1:56397?uri=http%3A%2F%2F127.0.0.1%3A56370%2FWURs0AbCsEY%3D%2F

💪 Running with sound null safety 💪

次のようなアプリケーション ウィンドウが画面に表示されるはずです。フローティング操作ボタンをクリックして、インクリメントが期待どおりに動作していることを確認します。テーマの色を変更したり、lib/main.dart_incrementCounter メソッドの動作を変更したりして、ホットリロードを試すこともできます。

Windows で動作中のアプリケーションは次のようになります。

ea232028115f24c.png

次のセクションでは、OAuth2 を使用して GitHub の認証を行います。

デスクトップでの認証

Android、iOS、ウェブで Flutter を使用する場合、認証パッケージには多くの選択肢があります。デスクトップ向けの開発には、これがあてはまりません。現時点ではゼロから認証の統合を構築する必要がありますが、パッケージの作成者がデスクトップ版 Flutter サポートを実装すると状況は変化します。

GitHub OAuth アプリケーションを登録する

GitHub の API を使用するデスクトップ アプリケーションを作成するには、最初に認証を行う必要があります。いくつかの選択肢がありますが、ユーザーに最良のエクスペリエンスを提供するには、ユーザーをブラウザで GitHub の OAuth2 ログインフローに誘導することをおすすめします。こうすると、2 段階認証プロセスの処理が可能となり、パスワード マネージャーとの統合も簡単です。

アプリケーションを GitHub の OAuth2 フローに登録するには、github.com にアクセスし、GitHub の OAuth App を構築するの最初の手順のみを行います。以降の手順は、起動するアプリケーションがある場合には重要ですが、Codelab の学習中は必要ありません。

OAuth アプリの作成終盤のステップ 8 では、認証コールバック URL を指定するよう求められます。デスクトップ アプリの場合には、コールバック URL に「http://localhost/」と入力します。GitHub の OAuth2 フローは、localhost のコールバック URL を定義することで、任意のポートを許可し、エフェメラル ローカル ハイポートでウェブサーバーが動作するように設定されました。これにより、OAuth 処理の途中で、ユーザーに対してアプリケーションに OAuth コードトークンをコピーするよう求めることがなくなります。

次のスクリーンショットは、GitHub OAuth アプリケーションを作成する際にフォームに入力する内容の例を示しています。

be454222e07f01d9.png

GitHub 管理インターフェースで OAuth アプリを登録すると、クライアント ID とクライアント シークレットが与えられます。後でこれらの値が必要になった場合は、GitHub の [Developer settings] から取得できます。有効な OAuth2 認証 URL を作成するには、アプリケーションでこれらの認証情報を使用する必要があります。OAuth2 フローを処理するために oauth2 Dart パッケージを使用し、ユーザーのウェブブラウザを起動するために url_launcher Flutter プラグインを使用します。

pubspec.yaml に oauth2 と url_launcher を追加する

アプリケーションにパッケージ依存関係を追加するには、次のように pubspec.yaml ファイルにエントリを追加します。

pubspec.yaml

name: github_graphql_client
description: Github client using Github API V4 (GraphQL)
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=2.12.0 <3.0.0'

dependencies:
  flutter:
    sdk: flutter
  http: ^0.13.1        # Add this line,
  oauth2: ^2.0.0       # and this line,
  url_launcher: ^6.0.2 # and this one too.

dev_dependencies:
  flutter_test:
    sdk: flutter
  pedantic: ^1.11.0

flutter:
  uses-material-design: true

クライアント認証情報を追加する

次のようにして、クライアント認証情報を新しいファイル lib/github_oauth_credentials.dart に追加します。

lib/github_oauth_credentials.dart

// TODO(CodelabUser): Create an OAuth App
const githubClientId = 'YOUR_GITHUB_CLIENT_ID_HERE';
const githubClientSecret = 'YOUR_GITHUB_CLIENT_SECRET_HERE';

// OAuth scopes for repository and user information
const githubScopes = ['repo', 'read:org'];

前の手順で入手したクライアント認証情報をコピーして、このファイルに貼り付けます。

デスクトップ OAuth2 フローを作成する

デスクトップ OAuth2 フローが入ったウィジェットを作成します。このロジックは少々複雑です。まず一時的なウェブサーバーを立ち上げ、ユーザーをウェブブラウザで GitHub のエンドポイントにリダイレクトします。そして、このユーザーがブラウザで認証フローを完了するのを待ち、GitHub からのリダイレクト コールを処理しなければなりません。このリダイレクト コールに含まれるコードは、別途 GitHub の API サーバーを呼び出して OAuth2 トークンに変換する必要があります。

lib/src/github_login.dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:oauth2/oauth2.dart' as oauth2;
import 'package:url_launcher/url_launcher.dart';

final _authorizationEndpoint =
    Uri.parse('https://github.com/login/oauth/authorize');
final _tokenEndpoint = Uri.parse('https://github.com/login/oauth/access_token');

class GithubLoginWidget extends StatefulWidget {
  const GithubLoginWidget({
    required this.builder,
    required this.githubClientId,
    required this.githubClientSecret,
    required this.githubScopes,
  });
  final AuthenticatedBuilder builder;
  final String githubClientId;
  final String githubClientSecret;
  final List<String> githubScopes;

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

typedef AuthenticatedBuilder = Widget Function(
    BuildContext context, oauth2.Client client);

class _GithubLoginState extends State<GithubLoginWidget> {
  HttpServer? _redirectServer;
  oauth2.Client? _client;

  @override
  Widget build(BuildContext context) {
    final client = _client;
    if (client != null) {
      return widget.builder(context, client);
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Github Login'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: () async {
            await _redirectServer?.close();
            // Bind to an ephemeral port on localhost
            _redirectServer = await HttpServer.bind('localhost', 0);
            var authenticatedHttpClient = await _getOAuth2Client(
                Uri.parse('http://localhost:${_redirectServer!.port}/auth'));
            setState(() {
              _client = authenticatedHttpClient;
            });
          },
          child: const Text('Login to Github'),
        ),
      ),
    );
  }

  Future<oauth2.Client> _getOAuth2Client(Uri redirectUrl) async {
    if (widget.githubClientId.isEmpty || widget.githubClientSecret.isEmpty) {
      throw const GithubLoginException(
          'githubClientId and githubClientSecret must be not empty. '
          'See `lib/github_oauth_credentials.dart` for more detail.');
    }
    var grant = oauth2.AuthorizationCodeGrant(
      widget.githubClientId,
      _authorizationEndpoint,
      _tokenEndpoint,
      secret: widget.githubClientSecret,
      httpClient: _JsonAcceptingHttpClient(),
    );
    var authorizationUrl =
        grant.getAuthorizationUrl(redirectUrl, scopes: widget.githubScopes);

    await _redirect(authorizationUrl);
    var responseQueryParameters = await _listen();
    var client =
        await grant.handleAuthorizationResponse(responseQueryParameters);
    return client;
  }

  Future<void> _redirect(Uri authorizationUrl) async {
    var url = authorizationUrl.toString();
    if (await canLaunch(url)) {
      await launch(url);
    } else {
      throw GithubLoginException('Could not launch $url');
    }
  }

  Future<Map<String, String>> _listen() async {
    var request = await _redirectServer!.first;
    var params = request.uri.queryParameters;
    request.response.statusCode = 200;
    request.response.headers.set('content-type', 'text/plain');
    request.response.writeln('Authenticated! You can close this tab.');
    await request.response.close();
    await _redirectServer!.close();
    _redirectServer = null;
    return params;
  }
}

class _JsonAcceptingHttpClient extends http.BaseClient {
  final _httpClient = http.Client();
  @override
  Future<http.StreamedResponse> send(http.BaseRequest request) {
    request.headers['Accept'] = 'application/json';
    return _httpClient.send(request);
  }
}

class GithubLoginException implements Exception {
  const GithubLoginException(this.message);
  final String message;
  @override
  String toString() => message;
}

デスクトップ上で Flutter と Dart を使用する機能がわかるため、このコードに時間をかける価値はあります。コードは複雑ですが、多くの機能は比較的使いやすいウィジェットにカプセル化されています。

このウィジェットは、一時的なウェブサーバーを公開し、安全な HTTP リクエストを発行します。macOS では、どちらの機能もエンタイトルメント ファイルを通じてリクエストする必要があります。

クライアントとサーバーのエンタイトルメントを変更する(macOS のみ)

macOS デスクトップ アプリとしてウェブ リクエストを発行しウェブサーバーを実行するには、アプリケーションのエンタイトルメントを変更する必要があります。詳しくはエンタイトルメントとアプリ サンドボックスの説明をご覧ください。

macos/Runner/DebugProfile.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <key>com.apple.security.cs.allow-jit</key>
        <true/>
        <key>com.apple.security.network.server</key>
        <true/>
        <!-- Add this entry -->
        <key>com.apple.security.network.client</key>
        <true/>
</dict>
</plist>

製品版ビルドのリリース エンタイトルメントも変更する必要があります。

macos/Runner/Release.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
        <key>com.apple.security.app-sandbox</key>
        <true/>
        <!-- Add the following two entries -->
        <key>com.apple.security.network.server</key>
        <true/>
        <key>com.apple.security.network.client</key>
        <true/>
</dict>
</plist>

すべてをまとめる

ここまでで、新しい OAuth アプリを設定し、プロジェクトに必要なパッケージとプラグインが設定されました。また、OAuth 認証フローをカプセル化するウィジェットを作成しました。さらに、エンタイトルメントを使用して、アプリが macOS でネットワーク クライアント、サーバーの両方として機能するようにしました。必要な構成要素をすべて用意できたので、これらをすべて lib/main.dart ファイルにまとめます。

lib/main.dart

import 'package:flutter/material.dart';
import 'github_oauth_credentials.dart';
import 'src/github_login.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GitHub GraphQL API Client',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(title: 'GitHub GraphQL API Client'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        return Scaffold(
          appBar: AppBar(
            title: Text(title),
          ),
          body: Center(
            child: Text(
              'You are logged in to GitHub!',
            ),
          ),
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

この Flutter アプリケーションを実行すると、最初に GitHub OAuth ログインフローを開始するボタンが表示されます。ボタンをクリックして、ウェブブラウザでログインフローを完了すると、アプリはログイン済みの状態になります。

OAuth 認証を習得したので、GitHub GraphQL API の使用に進みましょう。

GraphQL の概要

graphql.org によると、GraphQL は、API 内のデータに関する包括的かつ理解しやすい記述を提供し、クライアントは必要な情報だけを要求できるようになる、ということです。これは、API に対して UI の特定部分への入力に焦点を絞った問い合わせを行えるという点で、実際にデベロッパーにとって大きなメリットとなります。

GitHub の v4 API は GraphQL で定義されており、実際のデータで GraphQL を使ってみるのに適したプレイグラウンドとなっています。GitHub には GraphiQL を利用した GitHub GraphQL Explorer が用意されており、GitHub の GraphQL API に対する GraphQL クエリを作成できます。GitHub GraphQL Explorer の使用方法について詳しくは、GitHub の Explorer の利用をご覧ください。

この Codelab では、gql パッケージを使用して、Explorer で作成するクエリに対して型安全なマーシャリング コードを生成します。

さらに依存関係を追加する

コード生成を使用して GraphQL クライアント ライブラリを作成するには、build_runner と多数の gql パッケージが必要です。まず、以下の依存関係を pubspec.yaml ファイルに追加します。

pubspec.yaml

name: github_graphql_client
description: Github client using Github API V4 (GraphQL)
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=2.12.0 <3.0.0'

dependencies:
  flutter:
    sdk: flutter
  gql: ^0.13.0-0          # Add from here
  gql_exec: ^0.3.0-0      #
  gql_link: ^0.4.0-0      #
  gql_http_link: ^0.4.0-0 # to here.
  http: ^0.13.1
  oauth2: ^2.0.0
  url_launcher: ^6.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^1.10.0   # Add this line,
  gql_build: ^0.2.0-0     # and this one.
  pedantic: ^1.11.0

flutter:
  uses-material-design: true

GitHub の GraphQL スキーマを取得する

API を記述するスキーマが GitHub から公開されています。コード生成ツールでクエリに対して型安全なクライアント ライブラリを作成できるように、このスキーマをキャッシュします。

macOS および Linux の場合:

$ mkdir -p lib/third_party/github_graphql_schema
$ curl -o ./lib/third_party/github_graphql_schema/schema.docs.graphql \
  https://docs.github.com/public/schema.docs.graphql

Windows の場合:

PS C:\src\github_graphql_client> mkdir .\lib\third_party\github_graphql_schema\
PS C:\src\github_graphql_client> curl -o .\lib\third_party\github_graphql_schema\schema.docs.graphql https://docs.github.com/public/schema.docs.graphql

どちらのコマンドでも、コード生成パイプラインでクエリの型チェックを行い、型安全なクライアント ライブラリを生成するために使用する schema.docs.graphql が作成されます。クエリも必要です。まず、GitHub GraphQL Explorer で最初に使用されるデフォルト クエリから始めますが、1 点だけ少し変更します。コード生成ツールで型安全なクライアント ライブラリが生成されるように、クエリに名前を付ける必要があります。

lib/src/github_gql/github_queries.graphql

query ViewerDetail {
  viewer {
    login
  }
}

build_runner を設定する

build_runner を設定するには、build.yaml にルールを追加します。ここでは、GitHub GraphQL スキーマと Explorer で作成したクエリとから、gql パッケージでどのようにコードを生成するかを設定します。

build.yaml

targets:
  $default:
    builders:
      gql_build|ast_builder:
        enabled: true
      gql_build|req_builder:
        enabled: true
        options:
          schema: github_graphql_client|lib/third_party/github_graphql_schema/schema.docs.graphql
      gql_build|serializer_builder:
        enabled: true
        options:
          schema: github_graphql_client|lib/third_party/github_graphql_schema/schema.docs.graphql
      gql_build|schema_builder:
        enabled: true
      gql_build|data_builder:
        enabled: true
        options:
          schema: github_graphql_client|lib/third_party/github_graphql_schema/schema.docs.graphql
      gql_build|var_builder:
        enabled: true
        options:
          schema: github_graphql_client|lib/third_party/github_graphql_schema/schema.docs.graphql

build_runner パッケージは非常に強力で、ここでは説明しきれません。詳細を知るには、Kevin Moore による Code generation with the Dart build system(YouTube)をご覧ください。

これですべてのパーツが揃ったので、build_runner を実行して GraphQL のクライアント ライブラリを生成できます。

$ flutter pub run build_runner build --delete-conflicting-outputs

lib/third_party/github_graphql_schema/lib/src/github_gql/, を見てみると、新たに多数のコードが生成されていることがわかります。

すべてをまとめる(2 度目)

ここで、すべての GraphQL の部品を lib/main.dart ファイルに統合します。

lib/main.dart

import 'package:flutter/material.dart';
import 'package:gql_exec/gql_exec.dart';
import 'package:gql_link/gql_link.dart';
import 'package:gql_http_link/gql_http_link.dart';
import 'github_oauth_credentials.dart';
import 'src/github_gql/github_queries.data.gql.dart';
import 'src/github_gql/github_queries.req.gql.dart';
import 'src/github_login.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GitHub GraphQL API Client',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(title: 'GitHub GraphQL API Client'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        final link = HttpLink(
          'https://api.github.com/graphql',
          httpClient: httpClient,
        );
        return FutureBuilder<GViewerDetailData_viewer>(
          future: viewerDetail(link),
          builder: (context, snapshot) {
            return Scaffold(
              appBar: AppBar(
                title: Text(title),
              ),
              body: Center(
                child: Text(
                  snapshot.hasData
                      ? 'Hello ${snapshot.data!.login}!'
                      : 'Retrieving viewer login details...',
                ),
              ),
            );
          },
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

Future<GViewerDetailData_viewer> viewerDetail(Link link) async {
  final req = GViewerDetail((b) => b);
  final result = await link
      .request(Request(
        operation: req.operation,
        variables: req.vars.toJson(),
      ))
      .first;
  final errors = result.errors;
  if (errors != null && errors.isNotEmpty) {
    throw QueryException(errors);
  }
  return GViewerDetailData.fromJson(result.data!)!.viewer;
}

class QueryException implements Exception {
  QueryException(this.errors);
  List<GraphQLError> errors;
  @override
  String toString() {
    return 'Query Exception: ${errors.map((err) => '$err').join(',')}';
  }
}

この Flutter アプリケーションを実行すると、GitHub OAuth ログインフローを開始するボタンが表示されます。このボタンをクリックして、ウェブブラウザでログインフローを完了すると、アプリにログインした状態になります。

次のステップでは、現在のコードベースにある不便を解消します。ウェブブラウザでアプリケーションを認証した後に、アプリケーションをフォアグラウンドに戻します。

不便を解消する

現在のコードには不便な面があります。認証フローの後で GitHub がアプリケーションを認証しても、ウェブブラウザのページが表示されたままです。自動的にアプリケーションに戻るのが理想的です。この問題を解決するには、使用しているデスクトップ プラットフォーム用の Flutter プラグインを作成する必要があります。

Windows 用、macOS 用、Linux 用 Flutter プラグインを作成する

OAuth フローの完了後にアプリケーションを自動的に他のアプリケーション ウィンドウの前に出すには、ネイティブ コードが必要です。macOS の場合、必要な API は NSApplicationactivate(ignoringOtherApps:) インスタンス メソッドです。Linux の場合は gtk_window_present、Windows の場合については Stack Overflow を使用します。これらの API を呼び出すには、Flutter プラグインを作成する必要があります。

flutter を使用すると新しいプラグイン プロジェクトを作成できます。

$ cd .. # step outside of the github_graphql_client project
$ flutter create -t plugin --platforms=linux,macos,windows window_to_front

プラグインを null 安全に移行し、サンプルアプリを削除します。macOS または Linux の場合の手順は次のとおりです。

$ cd window_to_front
$ dart migrate --apply-changes
$ rm -r example

Windows の場合は次のとおりです。

PS C:\src> cd window_to_front
PS C:\src\window_to_front> dart migrate --apply-changes
PS C:\src\window_to_front> rmdir example

生成された pubspec.yaml が次のようになっていることを確認します。

../window_to_front/pubspec.yaml

name: window_to_front
description: A new flutter plugin project.
version: 0.0.1

environment:
  sdk: '>=2.12.0 <3.0.0'
  flutter: ">=1.20.0"

dependencies:
  flutter:
    sdk: flutter

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  plugin:
    platforms:
      linux:
        pluginClass: WindowToFrontPlugin
      macos:
        pluginClass: WindowToFrontPlugin
      windows:
        pluginClass: WindowToFrontPlugin

このプラグインは、macOS 用、Linux 用、Windows 用に構成されています。ここで、ウィンドウを前に出す Swift コードを追加します。macos/Classes/WindowToFrontPlugin.swift を次のように編集します。

../window_to_front/macos/Classes/WindowToFrontPlugin.swift

import Cocoa
import FlutterMacOS

public class WindowToFrontPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "window_to_front", binaryMessenger: registrar.messenger)
    let instance = WindowToFrontPlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch call.method {
    // Add from here
    case "activate":
      NSApplication.shared.activate(ignoringOtherApps: true)
      result(nil)
    // to here.
    // Delete the getPlatformVersion case,
    // as we won't be using it.
    default:
      result(FlutterMethodNotImplemented)
    }
  }
}

Linux プラグインで同じことを行うには、linux/window_to_front_plugin.cc の内容を次のように置き換えます。

../window_to_front/linux/window_to_front_plugin.cc

#include "include/window_to_front/window_to_front_plugin.h"

#include <flutter_linux/flutter_linux.h>
#include <gtk/gtk.h>
#include <sys/utsname.h>

#define WINDOW_TO_FRONT_PLUGIN(obj) \
  (G_TYPE_CHECK_INSTANCE_CAST((obj), window_to_front_plugin_get_type(), \
                              WindowToFrontPlugin))

struct _WindowToFrontPlugin {
  GObject parent_instance;

  FlPluginRegistrar* registrar;
};

G_DEFINE_TYPE(WindowToFrontPlugin, window_to_front_plugin, g_object_get_type())

// Called when a method call is received from Flutter.
static void window_to_front_plugin_handle_method_call(
    WindowToFrontPlugin* self,
    FlMethodCall* method_call) {
  g_autoptr(FlMethodResponse) response = nullptr;

  const gchar* method = fl_method_call_get_name(method_call);

  if (strcmp(method, "activate") == 0) {
    FlView* view = fl_plugin_registrar_get_view(self->registrar);
    if (view != nullptr) {
      GtkWindow* window = GTK_WINDOW(gtk_widget_get_toplevel(GTK_WIDGET(view)));
      gtk_window_present(window);
    }

    response = FL_METHOD_RESPONSE(fl_method_success_response_new(nullptr));
  } else {
    response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new());
  }

  fl_method_call_respond(method_call, response, nullptr);
}

static void window_to_front_plugin_dispose(GObject* object) {
  G_OBJECT_CLASS(window_to_front_plugin_parent_class)->dispose(object);
}

static void window_to_front_plugin_class_init(WindowToFrontPluginClass* klass) {
  G_OBJECT_CLASS(klass)->dispose = window_to_front_plugin_dispose;
}

static void window_to_front_plugin_init(WindowToFrontPlugin* self) {}

static void method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call,
                           gpointer user_data) {
  WindowToFrontPlugin* plugin = WINDOW_TO_FRONT_PLUGIN(user_data);
  window_to_front_plugin_handle_method_call(plugin, method_call);
}

void window_to_front_plugin_register_with_registrar(FlPluginRegistrar* registrar) {
  WindowToFrontPlugin* plugin = WINDOW_TO_FRONT_PLUGIN(
      g_object_new(window_to_front_plugin_get_type(), nullptr));

  plugin->registrar = FL_PLUGIN_REGISTRAR(g_object_ref(registrar));

  g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();
  g_autoptr(FlMethodChannel) channel =
      fl_method_channel_new(fl_plugin_registrar_get_messenger(registrar),
                            "window_to_front",
                            FL_METHOD_CODEC(codec));
  fl_method_channel_set_method_call_handler(channel, method_call_cb,
                                            g_object_ref(plugin),
                                            g_object_unref);

  g_object_unref(plugin);
}

Windows プラグインでも同様に、windows/window_to_front_plugin.cc の内容を次のように置き換えます。

..\window_to_front\windows\window_to_front_plugin.cpp

#include "include/window_to_front/window_to_front_plugin.h"

// This must be included before many other Windows headers.
#include <windows.h>

#include <flutter/method_channel.h>
#include <flutter/plugin_registrar_windows.h>
#include <flutter/standard_method_codec.h>

#include <map>
#include <memory>

namespace {

class WindowToFrontPlugin : public flutter::Plugin {
 public:
  static void RegisterWithRegistrar(flutter::PluginRegistrarWindows *registrar);

  WindowToFrontPlugin(flutter::PluginRegistrarWindows *registrar);

  virtual ~WindowToFrontPlugin();

 private:
  // Called when a method is called on this plugin's channel from Dart.
  void HandleMethodCall(
      const flutter::MethodCall<flutter::EncodableValue> &method_call,
      std::unique_ptr<flutter::MethodResult<>> result);

  // The registrar for this plugin, for accessing the window.
  flutter::PluginRegistrarWindows *registrar_;
};

// static
void WindowToFrontPlugin::RegisterWithRegistrar(
    flutter::PluginRegistrarWindows *registrar) {
  auto channel =
      std::make_unique<flutter::MethodChannel<>>(
          registrar->messenger(), "window_to_front",
          &flutter::StandardMethodCodec::GetInstance());

  auto plugin = std::make_unique<WindowToFrontPlugin>(registrar);

  channel->SetMethodCallHandler(
      [plugin_pointer = plugin.get()](const auto &call, auto result) {
        plugin_pointer->HandleMethodCall(call, std::move(result));
      });

  registrar->AddPlugin(std::move(plugin));
}

WindowToFrontPlugin::WindowToFrontPlugin(flutter::PluginRegistrarWindows *registrar)
  : registrar_(registrar) {}

WindowToFrontPlugin::~WindowToFrontPlugin() {}

void WindowToFrontPlugin::HandleMethodCall(
    const flutter::MethodCall<> &method_call,
    std::unique_ptr<flutter::MethodResult<>> result) {
  if (method_call.method_name().compare("activate") == 0) {
    // See https://stackoverflow.com/a/34414846/2142626 for an explanation of how
    // this raises a window to the foreground.
    HWND m_hWnd = registrar_->GetView()->GetNativeWindow();
    HWND hCurWnd = ::GetForegroundWindow();
    DWORD dwMyID = ::GetCurrentThreadId();
    DWORD dwCurID = ::GetWindowThreadProcessId(hCurWnd, NULL);
    ::AttachThreadInput(dwCurID, dwMyID, TRUE);
    ::SetWindowPos(m_hWnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
    ::SetWindowPos(m_hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_SHOWWINDOW | SWP_NOSIZE | SWP_NOMOVE);
    ::SetForegroundWindow(m_hWnd);
    ::SetFocus(m_hWnd);
    ::SetActiveWindow(m_hWnd);
    ::AttachThreadInput(dwCurID, dwMyID, FALSE);
    result->Success();
  } else {
    result->NotImplemented();
  }
}

}  // namespace

void WindowToFrontPluginRegisterWithRegistrar(
    FlutterDesktopPluginRegistrarRef registrar) {
  WindowToFrontPlugin::RegisterWithRegistrar(
      flutter::PluginRegistrarManager::GetInstance()
          ->GetRegistrar<flutter::PluginRegistrarWindows>(registrar));
}

上記で作成したネイティブ機能を Flutter で利用できるようにするコードを追加します。

../window_to_front/lib/window_to_front.dart

import 'dart:async';

import 'package:flutter/services.dart';

class WindowToFront {
  static const MethodChannel _channel = const MethodChannel('window_to_front');
  // Add from here
  static Future<void> activate() async {
    await _channel.invokeMethod('activate');
  }
  // to here.

  // Delete the getPlatformVersion getter method.
}

この Flutter プラグインは完成です。github_graphql_client プロジェクトの編集に戻りましょう。

$ cd ../github_graphql_client

依存関係を追加する

作成した Flutter プラグインは有用ですが、単独で利用してもあまり意味がありません。使用するには、Flutter アプリケーションに依存関係として追加する必要があります。

pubspec.yaml

name: github_graphql_client
description: Github client using Github API V4 (GraphQL)
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=2.12.0 <3.0.0'

dependencies:
  flutter:
    sdk: flutter
  gql: ^0.13.0-0
  gql_exec: ^0.3.0-0
  gql_link: ^0.4.0-0
  gql_http_link: ^0.4.0-0
  http: ^0.13.1
  oauth2: ^2.0.0
  url_launcher: ^6.0.2
  window_to_front:             # Add this dependency, from here
    path: '../window_to_front' # to here.

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^1.10.0
  gql_build: ^0.2.0-0
  pedantic: ^1.11.0

flutter:
  uses-material-design: true

window_to_front 依存関係に指定されたパスに注意してください。これは、pub.dev に公開されるパッケージではなく、ローカル パッケージであるため、バージョン番号ではなくパスを指定します。

すべてをまとめる(3 度目)

ここで、window_to_frontlib/main.dart ファイルに統合します。必要なのは、インポートを追加して、ネイティブ コードを適切なタイミングで呼び出すことだけです。

lib/main.dart

import 'package:flutter/material.dart';
import 'package:gql_exec/gql_exec.dart';
import 'package:gql_link/gql_link.dart';
import 'package:gql_http_link/gql_http_link.dart';
import 'package:window_to_front/window_to_front.dart'; // Add this,
import 'github_oauth_credentials.dart';
import 'src/github_gql/github_queries.data.gql.dart';
import 'src/github_gql/github_queries.req.gql.dart';
import 'src/github_login.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GitHub GraphQL API Client',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(title: 'GitHub GraphQL API Client'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        WindowToFront.activate();                      // and this.
        final link = HttpLink(
          'https://api.github.com/graphql',
          httpClient: httpClient,
        );
        return FutureBuilder<GViewerDetailData_viewer>(
          future: viewerDetail(link),
          builder: (context, snapshot) {
            return Scaffold(
              appBar: AppBar(
                title: Text(title),
              ),
              body: Center(
                child: Text(
                  snapshot.hasData
                      ? 'Hello ${snapshot.data!.login}!'
                      : 'Retrieving viewer login details...',
                ),
              ),
            );
          },
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

Future<GViewerDetailData_viewer> viewerDetail(Link link) async {
  final req = GViewerDetail((b) => b);
  final result = await link
      .request(Request(
        operation: req.operation,
        variables: req.vars.toJson(),
      ))
      .first;
  final errors = result.errors;
  if (errors != null && errors.isNotEmpty) {
    throw QueryException(errors);
  }
  return GViewerDetailData.fromJson(result.data!)!.viewer;
}

class QueryException implements Exception {
  QueryException(this.errors);
  List<GraphQLError> errors;
  @override
  String toString() {
    return 'Query Exception: ${errors.map((err) => '$err').join(',')}';
  }
}

この Flutter アプリケーションを実行すると、まったく同じ外観のアプリが表示されますが、ボタンをクリックすると動作の違いがわかります。認証に使用するウェブブラウザの前にアプリを置いた場合、ログインボタンをクリックすると、アプリケーションがウェブブラウザの後ろに回りますが、ブラウザで認証フローを完了すると、またアプリケーションが前に出ます。かなり良くなりました。

次のセクションでは、これまで学習したことに基づいて、自分が GitHub に何を持っているかを把握できるデスクトップ GitHub クライアントを作成します。アカウントのリポジトリ、作成した pull リクエスト、割り当てられた問題のリストを調べます。

このアプリケーションの構築はかなり進みましたが、できることはログインを知らせるだけです。デスクトップ GitHub クライアントに、もう少し機能を追加したいのではないでしょうか。次は、リポジトリ、pull リクエスト、割り当てられた問題を表示する機能を追加しましょう。

GraphQL を使用してリポジトリ、pull リクエスト、問題をクエリする

GitHub の情報を表示するには、それを取得する必要があります。そのため、さらに次の GraphQL クエリを追加します。

lib/src/github_gql/github_queries.graphql

query ViewerDetail {
  viewer {
    login
  }
}

// Add everything below here.

query PullRequests($count: Int!) {
  viewer {
    pullRequests(
      first: $count
      orderBy: { field: CREATED_AT, direction: DESC }
    ) {
      edges {
        node {
          repository {
            nameWithOwner
            url
          }
          author {
            login
            url
          }
          number
          url
          title
          updatedAt
          url
          state
          isDraft
          comments {
            totalCount
          }
          files {
            totalCount
          }
        }
      }
    }
  }
}

query AssignedIssues($query: String!, $count: Int!) {
  search(query: $query, type: ISSUE, first: $count) {
    edges {
      node {
        ... on Issue {
          __typename
          repository {
            nameWithOwner
            url
          }
          number
          url
          title
          author {
            login
            url
          }
          labels(last: 10) {
            nodes {
              name
              color
            }
          }
          comments {
            totalCount
          }
        }
      }
    }
  }
}

query Repositories($count: Int!) {
  viewer {
    repositories(
      first: $count
      orderBy: { field: UPDATED_AT, direction: DESC }
    ) {
      nodes {
        name
        description
        isFork
        isPrivate
        stargazers {
          totalCount
        }
        url
        issues {
          totalCount
        }
        owner {
          login
          avatarUrl
        }
      }
    }
  }
}

GraphQL のクライアント ライブラリを再度生成するために、次のコマンドを実行します。

$ flutter pub run build_runner build --delete-conflicting-outputs

最後の依存関係を追加する

上記のクエリから返されたデータをレンダリングする際には、追加のパッケージ fluttericon を使用することで、GitHub の Octicons を簡単に表示できます。

pubspec.yaml

name: github_graphql_client
description: Github client using Github API V4 (GraphQL)
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=2.12.0 <3.0.0'

dependencies:
  flutter:
    sdk: flutter
  fluttericon: ^2.0.0    # Add this dependency
  gql: ^0.13.0-0
  gql_exec: ^0.3.0-0
  gql_link: ^0.4.0-0
  gql_http_link: ^0.4.0-0
  http: ^0.13.1
  oauth2: ^2.0.0
  url_launcher: ^6.0.2
  window_to_front:
    path: '../window_to_front'

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^1.10.0
  gql_build: ^0.2.0-0
  pedantic: ^1.11.0

flutter:
  uses-material-design: true

結果を画面にレンダリングするウィジェット

上で作成した GraphQL クエリを使用して NavigationRail ウィジェットに、リポジトリ、割り当てられた問題、pull リクエストのビューを取り込みます。ナビゲーション レールにより、アプリケーション内の主要な遷移先間での移動に関して、人間工学的に優れた方式が提供されており、このことについては Material.io デザイン システムのドキュメントで説明されています。

新しいファイルを作成し、次の内容を入力します。

lib/src/github_summary.dart

import 'package:flutter/material.dart';
import 'package:fluttericon/octicons_icons.dart';
import 'package:gql_exec/gql_exec.dart';
import 'package:gql_http_link/gql_http_link.dart';
import 'package:gql_link/gql_link.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'github_gql/github_queries.data.gql.dart';
import 'github_gql/github_queries.req.gql.dart';

class GitHubSummary extends StatefulWidget {
  GitHubSummary({required http.Client client})
      : _link = HttpLink(
          'https://api.github.com/graphql',
          httpClient: client,
        );
  final HttpLink _link;
  @override
  _GitHubSummaryState createState() => _GitHubSummaryState();
}

class _GitHubSummaryState extends State<GitHubSummary> {
  int _selectedIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        NavigationRail(
          selectedIndex: _selectedIndex,
          onDestinationSelected: (int index) {
            setState(() {
              _selectedIndex = index;
            });
          },
          labelType: NavigationRailLabelType.selected,
          destinations: [
            NavigationRailDestination(
              icon: Icon(Octicons.repo),
              label: Text('Repositories'),
            ),
            NavigationRailDestination(
              icon: Icon(Octicons.issue_opened),
              label: Text('Assigned Issues'),
            ),
            NavigationRailDestination(
              icon: Icon(Octicons.git_pull_request),
              label: Text('Pull Requests'),
            ),
          ],
        ),
        VerticalDivider(thickness: 1, width: 1),
        // This is the main content.
        Expanded(
          child: IndexedStack(
            index: _selectedIndex,
            children: [
              RepositoriesList(link: widget._link),
              AssignedIssuesList(link: widget._link),
              PullRequestsList(link: widget._link),
            ],
          ),
        ),
      ],
    );
  }
}

class RepositoriesList extends StatefulWidget {
  const RepositoriesList({required this.link});
  final Link link;
  @override
  _RepositoriesListState createState() => _RepositoriesListState(link: link);
}

class _RepositoriesListState extends State<RepositoriesList> {
  _RepositoriesListState({required Link link}) {
    _repositories = _retreiveRespositories(link);
  }
  late Future<List<GRepositoriesData_viewer_repositories_nodes>> _repositories;

  Future<List<GRepositoriesData_viewer_repositories_nodes>>
      _retreiveRespositories(Link link) async {
    final req = GRepositories((b) => b..vars.count = 100);
    final result = await link
        .request(Request(
          operation: req.operation,
          variables: req.vars.toJson(),
        ))
        .first;
    final errors = result.errors;
    if (errors != null && errors.isNotEmpty) {
      throw QueryException(errors);
    }
    return GRepositoriesData.fromJson(result.data!)!
        .viewer
        .repositories
        .nodes!
        .asList();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<GRepositoriesData_viewer_repositories_nodes>>(
      future: _repositories,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(child: Text('${snapshot.error}'));
        }
        if (!snapshot.hasData) {
          return Center(child: CircularProgressIndicator());
        }
        var repositories = snapshot.data;
        return ListView.builder(
          itemBuilder: (context, index) {
            var repository = repositories![index];
            return ListTile(
              title: Text('${repository.owner.login}/${repository.name}'),
              subtitle: Text(repository.description ?? 'No description'),
              onTap: () => _launchUrl(context, repository.url.value),
            );
          },
          itemCount: repositories!.length,
        );
      },
    );
  }
}

class AssignedIssuesList extends StatefulWidget {
  const AssignedIssuesList({required this.link});
  final Link link;
  @override
  _AssignedIssuesListState createState() =>
      _AssignedIssuesListState(link: link);
}

class _AssignedIssuesListState extends State<AssignedIssuesList> {
  _AssignedIssuesListState({required Link link}) {
    _assignedIssues = _retrieveAssignedIssues(link);
  }

  late Future<List<GAssignedIssuesData_search_edges_node__asIssue>>
      _assignedIssues;

  Future<List<GAssignedIssuesData_search_edges_node__asIssue>>
      _retrieveAssignedIssues(Link link) async {
    final viewerReq = GViewerDetail((b) => b);
    var result = await link
        .request(Request(
          operation: viewerReq.operation,
          variables: viewerReq.vars.toJson(),
        ))
        .first;
    var errors = result.errors;
    if (errors != null && errors.isNotEmpty) {
      throw QueryException(errors);
    }
    final _viewer = GViewerDetailData.fromJson(result.data!)!.viewer;

    final issuesReq = GAssignedIssues((b) => b
      ..vars.count = 100
      ..vars.query = 'is:open assignee:${_viewer.login} archived:false');

    result = await link
        .request(Request(
          operation: issuesReq.operation,
          variables: issuesReq.vars.toJson(),
        ))
        .first;
    errors = result.errors;
    if (errors != null && errors.isNotEmpty) {
      throw QueryException(errors);
    }
    return GAssignedIssuesData.fromJson(result.data!)!
        .search
        .edges!
        .map((e) => e.node)
        .whereType<GAssignedIssuesData_search_edges_node__asIssue>()
        .toList();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<GAssignedIssuesData_search_edges_node__asIssue>>(
      future: _assignedIssues,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(child: Text('${snapshot.error}'));
        }
        if (!snapshot.hasData) {
          return Center(child: CircularProgressIndicator());
        }
        var assignedIssues = snapshot.data;
        return ListView.builder(
          itemBuilder: (context, index) {
            var assignedIssue = assignedIssues![index];
            return ListTile(
              title: Text('${assignedIssue.title}'),
              subtitle: Text('${assignedIssue.repository.nameWithOwner} '
                  'Issue #${assignedIssue.number} '
                  'opened by ${assignedIssue.author!.login}'),
              onTap: () => _launchUrl(context, assignedIssue.url.value),
            );
          },
          itemCount: assignedIssues!.length,
        );
      },
    );
  }
}

class PullRequestsList extends StatefulWidget {
  const PullRequestsList({required this.link});
  final Link link;
  @override
  _PullRequestsListState createState() => _PullRequestsListState(link: link);
}

class _PullRequestsListState extends State<PullRequestsList> {
  _PullRequestsListState({required Link link}) {
    _pullRequests = _retrievePullRequests(link);
  }
  late Future<List<GPullRequestsData_viewer_pullRequests_edges_node>>
      _pullRequests;

  Future<List<GPullRequestsData_viewer_pullRequests_edges_node>>
      _retrievePullRequests(Link link) async {
    final req = GPullRequests((b) => b..vars.count = 100);
    final result = await link
        .request(Request(
          operation: req.operation,
          variables: req.vars.toJson(),
        ))
        .first;
    final errors = result.errors;
    if (errors != null && errors.isNotEmpty) {
      throw QueryException(errors);
    }
    return GPullRequestsData.fromJson(result.data!)!
        .viewer
        .pullRequests
        .edges!
        .map((e) => e.node)
        .whereType<GPullRequestsData_viewer_pullRequests_edges_node>()
        .toList();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<
        List<GPullRequestsData_viewer_pullRequests_edges_node>>(
      future: _pullRequests,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(child: Text('${snapshot.error}'));
        }
        if (!snapshot.hasData) {
          return Center(child: CircularProgressIndicator());
        }
        var pullRequests = snapshot.data;
        return ListView.builder(
          itemBuilder: (context, index) {
            var pullRequest = pullRequests![index];
            return ListTile(
              title: Text('${pullRequest.title}'),
              subtitle: Text('${pullRequest.repository.nameWithOwner} '
                  'PR #${pullRequest.number} '
                  'opened by ${pullRequest.author!.login} '
                  '(${pullRequest.state.name.toLowerCase()})'),
              onTap: () => _launchUrl(context, pullRequest.url.value),
            );
          },
          itemCount: pullRequests!.length,
        );
      },
    );
  }
}

class QueryException implements Exception {
  QueryException(this.errors);
  List<GraphQLError> errors;
  @override
  String toString() {
    return 'Query Exception: ${errors.map((err) => '$err').join(',')}';
  }
}

Future<void> _launchUrl(BuildContext context, String url) async {
  if (await canLaunch(url)) {
    await launch(url);
  } else {
    return showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('Navigation error'),
        content: Text('Could not launch $url'),
        actions: <Widget>[
          TextButton(
            onPressed: () {
              Navigator.of(context).pop();
            },
            child: Text('Close'),
          ),
        ],
      ),
    );
  }
}

大量のコードを追加しましたが、この良いところは、すべて標準的な Flutter コードであり、ウィジェットを使って関心の分離を実現している点です。このすべてを実行する次のステップに進む前に、コードを確認しましょう。

すべてをまとめる(最後)

ここで、GitHubSummarylib/main.dart ファイルに統合します。今度の変更は非常に大きなものですが、そのほとんどは削除です。lib/main.dart ファイルの内容を次のように置き換えます。

lib/main.dart

import 'package:flutter/material.dart';
import 'package:window_to_front/window_to_front.dart';
import 'github_oauth_credentials.dart';
import 'src/github_login.dart';
import 'src/github_summary.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'GitHub GraphQL API Client',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: MyHomePage(title: 'GitHub GraphQL API Client'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, client) {
        WindowToFront.activate();
        return Scaffold(
          appBar: AppBar(
            title: Text(title),
          ),
          body: GitHubSummary(client: client),
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

アプリケーションを実行すると、次のように表示されます。

775e773e58e53e85.png

おめでとうございます!

この Codelab を修了し、GitHub の GraphQL API にアクセスするデスクトップ Flutter アプリケーションを作成しました。ここでは、OAuth を使って認証された API の使用、型安全なクライアント ライブラリの作成、作成したプラグインを介してのネイティブ API の使用を経験しました。

デスクトップ版の Flutter について詳しくは、flutter.dev/desktop をご覧ください。GraphQL の詳細については、graphql.org/learn をご覧ください。最後に、Flutter と GitHub を使用する、まったく別の例については、GroovinChip による「GitHub-Activity-Feed」をご覧ください。