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

1. はじめに

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

学習内容

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

作成するアプリの概要

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

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

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

a456fca6e2997992.png

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

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

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

2. Flutter 環境をセットアップする

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

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

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

3. 始める

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_client
Creating project github_client...
Running "flutter pub get" in github_client...                    1,103ms
Wrote 128 files.

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

  $ cd github_client
  $ flutter run

Your application code is in github_client\lib\main.dart.

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

macOS および Linux の場合:

$ rm -r android ios web

Windows の場合:

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

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

$ flutter run
Launching lib\main.dart on Windows in debug mode...
Building Windows application...
Syncing files to device Windows...                                  56ms

Flutter run key commands.
r Hot reload. 🔥🔥🔥
R Hot restart.
h List all available interactive commands.
d Detach (terminate "flutter run" but leave application running).
c Clear the screen
q Quit (terminate the application on the device).

💪 Running with sound null safety 💪

An Observatory debugger and profiler on Windows is available at: http://127.0.0.1:61920/OHTnly7_TMk=/
The Flutter DevTools debugger and profiler on Windows is available at: http://127.0.0.1:9101?uri=http://127.0.0.1:61920/OHTnly7_TMk=/

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

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

bee40fe7a8e69791.png

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

4.認証を追加する

デスクトップで認証を行う

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 を追加する

次のように flutter pub add を実行して、アプリケーションのパッケージ依存関係を追加します。

$ flutter pub add http
Resolving dependencies...
+ http 0.13.4
+ http_parser 4.0.0
  path 1.8.0 (1.8.1 available)
  source_span 1.8.1 (1.8.2 available)
  test_api 0.4.8 (0.4.9 available)
Changed 2 dependencies!

この最初のコマンドでは、クロス プラットフォームで一貫した方法で HTTP 呼び出しを行うために、http パッケージを追加します。次に、oauth2 パッケージを次のように追加します。

$ flutter pub add oauth2
Resolving dependencies...
+ crypto 3.0.1
+ oauth2 2.0.0
  path 1.8.0 (1.8.1 available)
  source_span 1.8.1 (1.8.2 available)
  test_api 0.4.8 (0.4.9 available)
Changed 2 dependencies!

最後に、url_launcher パッケージを追加します。

$ flutter pub add url_launcher
Resolving dependencies...
+ flutter_web_plugins 0.0.0 from sdk flutter
+ js 0.6.3 (0.6.4 available)
  path 1.8.0 (1.8.1 available)
+ plugin_platform_interface 2.1.2
  source_span 1.8.1 (1.8.2 available)
  test_api 0.4.8 (0.4.9 available)
+ url_launcher 6.0.18
+ url_launcher_android 6.0.14
+ url_launcher_ios 6.0.14
+ url_launcher_linux 2.0.3
+ url_launcher_macos 2.0.2
+ url_launcher_platform_interface 2.0.5
+ url_launcher_web 2.0.6
+ url_launcher_windows 2.0.2
Downloading url_launcher 6.0.18...
Downloading url_launcher_ios 6.0.14...
Downloading url_launcher_android 6.0.14...
Downloading url_launcher_platform_interface 2.0.5...
Downloading plugin_platform_interface 2.1.2...
Downloading url_launcher_linux 2.0.3...
Downloading url_launcher_web 2.0.6...
Changed 11 dependencies!

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

次のようにして、クライアント認証情報を新しいファイル 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,
    Key? key,
  }) : super(key: key);
  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(const MyApp());
}

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

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

class MyHomePage extends StatelessWidget {
  const 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: const Center(
            child: Text(
              'You are logged in to GitHub!',
            ),
          ),
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

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

OAuth 認証を習得したので、GitHub パッケージの使用に進みましょう。

5. GitHub にアクセスする

GitHub に接続する

OAuth 認証フローにより、GitHub のデータにアクセスするために必要なトークンを取得しました。このタスクを容易にするために、pub.dev で入手可能なパッケージ github を使用します。

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

次のコマンドを実行します。

$ flutter pub add github

GitHub パッケージで OAuth 認証情報を使用する

前のステップで作成した GithubLoginWidget は、GitHub API とやり取りできる HttpClient を提供します。このステップでは、以下に示すように、HttpClient に含まれる認証情報を使用して、GitHub パッケージを使用して GitHub API にアクセスします。

final accessToken = httpClient.credentials.accessToken;
final gitHub = GitHub(auth: Authentication.withToken(accessToken));

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

ここで、GitHub クライアントを lib/main.dart ファイルに統合します。

lib/main.dart

import 'package:flutter/material.dart';
import 'package:github/github.dart';

import 'github_oauth_credentials.dart';
import 'src/github_login.dart';

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

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

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

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

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        return FutureBuilder<CurrentUser>(
          future: viewerDetail(httpClient.credentials.accessToken),
          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<CurrentUser> viewerDetail(String accessToken) async {
  final gitHub = GitHub(auth: Authentication.withToken(accessToken));
  return gitHub.users.getCurrentUser();
}

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

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

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

不便を解消する

現在のコードには不便な面があります。認証フローの後で 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_client project
$ flutter create -t plugin --platforms=linux,macos,windows window_to_front

生成された 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_lints: ^1.0.0

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 = MethodChannel('window_to_front');
  // Add from here
  static Future<void> activate(){
    return _channel.invokeMethod('activate');
  }
  // to here.

  // Delete the getPlatformVersion getter method.
}

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

$ cd ../github_client

依存関係を追加する

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

$ flutter pub add --path ../window_to_front window_to_front
Resolving dependencies...
  js 0.6.3 (0.6.4 available)
  path 1.8.0 (1.8.1 available)
  source_span 1.8.1 (1.8.2 available)
  test_api 0.4.8 (0.4.9 available)
+ window_to_front 0.0.1 from path ..\window_to_front
Changed 1 dependency!

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

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

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

lib/main.dart

import 'package:flutter/material.dart';
import 'package:github/github.dart';
import 'package:window_to_front/window_to_front.dart';    // Add this

import 'github_oauth_credentials.dart';
import 'src/github_login.dart';

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

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

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

class MyHomePage extends StatelessWidget {
  const 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.
        return FutureBuilder<CurrentUser>(
          future: viewerDetail(httpClient.credentials.accessToken),
          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<CurrentUser> viewerDetail(String accessToken) async {
  final gitHub = GitHub(auth: Authentication.withToken(accessToken));
  return gitHub.users.getCurrentUser();
}

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

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

7. リポジトリ、pull リクエスト、割り当てられた問題を表示する

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

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

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

$ flutter pub add fluttericon
Resolving dependencies...
+ fluttericon 2.0.0
  js 0.6.3 (0.6.4 available)
  material_color_utilities 0.1.3 (0.1.4 available)
  path 1.8.0 (1.8.1 available)
  source_span 1.8.1 (1.8.2 available)
  test_api 0.4.8 (0.4.9 available)
  url_launcher_macos 2.0.2 (2.0.3 available)
Changed 1 dependency!

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

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

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

lib/src/github_summary.dart

import 'package:flutter/material.dart';
import 'package:fluttericon/octicons_icons.dart';
import 'package:github/github.dart';
import 'package:url_launcher/url_launcher.dart';

class GitHubSummary extends StatefulWidget {
  const GitHubSummary({required this.gitHub, Key? key}) : super(key: key);
  final GitHub gitHub;

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

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

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        NavigationRail(
          selectedIndex: _selectedIndex,
          onDestinationSelected: (index) {
            setState(() {
              _selectedIndex = index;
            });
          },
          labelType: NavigationRailLabelType.selected,
          destinations: const [
            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'),
            ),
          ],
        ),
        const VerticalDivider(thickness: 1, width: 1),
        // This is the main content.
        Expanded(
          child: IndexedStack(
            index: _selectedIndex,
            children: [
              RepositoriesList(gitHub: widget.gitHub),
              AssignedIssuesList(gitHub: widget.gitHub),
              PullRequestsList(gitHub: widget.gitHub),
            ],
          ),
        ),
      ],
    );
  }
}

class RepositoriesList extends StatefulWidget {
  const RepositoriesList({required this.gitHub, Key? key}) : super(key: key);
  final GitHub gitHub;

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

class _RepositoriesListState extends State<RepositoriesList> {
  @override
  initState() {
    super.initState();
    _repositories = widget.gitHub.repositories.listRepositories().toList();
  }

  late Future<List<Repository>> _repositories;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Repository>>(
      future: _repositories,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(child: Text('${snapshot.error}'));
        }
        if (!snapshot.hasData) {
          return const 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),
              onTap: () => _launchUrl(context, repository.htmlUrl),
            );
          },
          itemCount: repositories!.length,
        );
      },
    );
  }
}

class AssignedIssuesList extends StatefulWidget {
  const AssignedIssuesList({required this.gitHub, Key? key}) : super(key: key);
  final GitHub gitHub;

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

class _AssignedIssuesListState extends State<AssignedIssuesList> {
  @override
  initState() {
    super.initState();
    _assignedIssues = widget.gitHub.issues.listByUser().toList();
  }

  late Future<List<Issue>> _assignedIssues;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<Issue>>(
      future: _assignedIssues,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(child: Text('${snapshot.error}'));
        }
        if (!snapshot.hasData) {
          return const 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('${_nameWithOwner(assignedIssue)} '
                  'Issue #${assignedIssue.number} '
                  'opened by ${assignedIssue.user?.login ?? ''}'),
              onTap: () => _launchUrl(context, assignedIssue.htmlUrl),
            );
          },
          itemCount: assignedIssues!.length,
        );
      },
    );
  }

  String _nameWithOwner(Issue assignedIssue) {
    final endIndex = assignedIssue.url.lastIndexOf('/issues/');
    return assignedIssue.url.substring(29, endIndex);
  }
}

class PullRequestsList extends StatefulWidget {
  const PullRequestsList({required this.gitHub, Key? key}) : super(key: key);
  final GitHub gitHub;

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

class _PullRequestsListState extends State<PullRequestsList> {
  @override
  initState() {
    super.initState();
    _pullRequests = widget.gitHub.pullRequests
        .list(RepositorySlug('flutter', 'flutter'))
        .toList();
  }

  late Future<List<PullRequest>> _pullRequests;

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<List<PullRequest>>(
      future: _pullRequests,
      builder: (context, snapshot) {
        if (snapshot.hasError) {
          return Center(child: Text('${snapshot.error}'));
        }
        if (!snapshot.hasData) {
          return const 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('flutter/flutter '
                  'PR #${pullRequest.number} '
                  'opened by ${pullRequest.user?.login ?? ''} '
                  '(${pullRequest.state?.toLowerCase() ?? ''})'),
              onTap: () => _launchUrl(context, pullRequest.htmlUrl ?? ''),
            );
          },
          itemCount: pullRequests!.length,
        );
      },
    );
  }
}

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

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

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

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

lib/main.dart

import 'package:flutter/material.dart';
import 'package:github/github.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(const MyApp());
}

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

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

class MyHomePage extends StatelessWidget {
  const 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.
        return Scaffold(
          appBar: AppBar(
            title: Text(title),
          ),
          body: GitHubSummary(
            gitHub: _getGitHub(httpClient.credentials.accessToken),
          ),
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

GitHub _getGitHub(String accessToken) {
  return GitHub(auth: Authentication.withToken(accessToken));
}

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

d5c9bebf448a2519.png

8. 次のステップ

これで完了です

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

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