编写 Flutter 桌面应用

Flutter 是 Google 的界面工具包,用于通过单一代码库为移动设备、网络和桌面设备构建精致的原生编译应用。在此 Codelab 中,您将学习构建一个具备以下功能的 Flutter 桌面应用:可访问 GitHub API 来检索您的代码库、已分配的问题和拉取请求。为完成此任务,您不仅需要创建插件并使用它们与原生 API 和桌面应用互动,还需要使用代码生成工具为 GitHub 的 API 构建类型安全的客户端库。

您将学习什么

  • 如何创建 Flutter 桌面应用
  • 如何在桌面设备上使用 OAuth2 进行身份验证
  • 如何将 Flutter 中的 GraphQL 与代码生成工具结合使用
  • 如何创建 Flutter 插件以与原生 API 集成

您将构建什么

在此 Codelab 中,您将使用 Flutter SDK 构建一个与 GitHub GraphQL API 集成的桌面应用。您的应用会执行以下操作:

  • 向 GitHub 验证身份
  • 从 GitHub v4 API 检索数据
  • 创建一个适用于 Windows、macOS 和/或 Linux 的 Flutter 插件
  • 将 Flutter 界面热重载开发为原生桌面应用

下面的屏幕截图显示了您将构建的桌面应用在 Windows 上运行时的情景。

775e773e58e53e85.png

此 Codelab 将重点介绍如何向 Flutter 桌面应用添加 GraphQL 功能。对于不相关的概念和代码块,仅仅一带而过,提供这些内容只是为了方便您复制和粘贴。

您想通过此 Codelab 学习什么?

我不熟悉这个主题,想深入了解一下。 我对这个主题有所了解,但我想重温一下。 我在寻找示例代码以用到我的项目中。 我在寻找有关特定内容的说明。

您必须在打算部署到的平台上进行开发。因此,如果您要开发 Windows 桌面应用,则必须在 Windows 上进行开发,才能访问相应的构建链。

若想针对所有操作系统进行开发,您便需要使用 2 款软件才能完成这个实验:Flutter SDK一款编辑器

此外,flutter.dev/desktop 上详述了各种操作系统的具体要求。

开始使用 Flutter 开发桌面应用

您需要通过一次性的配置更改来配置桌面支持。

$ 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 可能会提供一个通过其界面创建 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 登录流程。这样会便于处理双重身份验证并轻松集成密码管理器。

若要注册一款采用 GitHub 的 OAuth2 流程的应用,请前往 github.com,只需按照 GitHub 的构建 OAuth 应用第一步中的说明进行操作即可。以下步骤在您准备发布应用时非常重要,但在完成 Codelab 期间则不然。

创建 OAuth 应用的过程中,第 8 步会要求您提供授权回调网址。对于桌面应用,请输入 http://localhost/ 作为回调网址。在设置 GitHub 的 OAuth2 流程时定义一个 localhost 回调网址可允许使用任何端口,从而让您能够在临时本地高端口上搭建一个网络服务器。这样可以避免要求用户在 OAuth 过程中将 OAuth 代码令牌复制到应用中。

下面的示例屏幕截图说明了如何填写创建 GitHub OAuth 应用的表单:

be454222e07f01d9.png

在 GitHub 管理界面中注册 OAuth 应用后,您会收到客户端 ID 和客户端密钥。如果日后需要使用这些值,您可从 GitHub 的开发者设置中检索它们。您需要使用应用内的这些凭据,才能构建有效的 OAuth2 授权网址。您将使用 oauth2 Dart 软件包来处理 OAuth2 流程,并且使用 url_launcher Flutter 插件来允许启动用户的网络浏览器。

将 oauth2 和 url_launcher 添加到 pubspec.yaml

若要为应用添加软件包依赖项,请将这些条目添加到 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>

您还需修改正式版 build 的发布权限。

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 会从 graphql.org 中择优挑选,为 API 中的数据提供简明易懂的完整说明,让客户可以仅请求获取所需内容。对开发者而言,这是一项真正的优势,使得他们能够提出以 API 为中心的问题,这些问题将填入界面中的特定部分。

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 架构

GitHub 发布了一个架构来描述其 API。您可以缓存此架构,以便代码生成工具使用此架构为您的查询创建类型安全的客户端库。

对于 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 中。在这种情况下,您可以配置 gql 软件包应如何基于 GitHub GraphQL 架构和您在 Explorer 中创建的查询生成代码。

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 软件包的功能非常强大,在这里无法一一详述。如需深入了解,请访问 YouTube 上由 Kevin Moore 讲解的使用 Dart 构建系统生成代码

现在您已妥善地完成了所有部分,可以运行 build_runner 生成 GraphQL 客户端库了。

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

如果仔细浏览 lib/third_party/github_graphql_schema/lib/src/github_gql/,,您会看到大量新生成的代码。

再次融为一体

现在,该将所有 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 的路径,因此您应指定路径而不是版本号。

再次融为一体

现在,该将 window_to_front 集成到您的 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 '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 上拥有的内容。您将检查帐号中的代码库列表、生成的拉取请求以及已分配的问题。

您已为构建该应用做了相当多的工作,但该应用所能做的只是将您的登录状态告知您。您可能需要从桌面 GitHub 客户端了解更多信息。接下来,您将添加用于列出代码库、拉取请求和已分配的问题的功能。

使用 GraphQL 查询代码库、拉取请求和问题

为了能够显示 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 微件,其中会包含您的代码库、已分配的问题和拉取请求的视图。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 代码,还包含一些微件用于区分对不同事项的责任。请花点时间检查该代码,然后再执行下一个步骤(让这款应用的所有元素都运行起来)。

最后一次融为一体

现在,该将 GitHubSummary 集成到您的 lib/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