Cómo escribir una aplicación de escritorio de Flutter

Flutter es el kit de herramientas de IU de Google diseñado para crear aplicaciones atractivas compiladas de forma nativa que funcionen en dispositivos móviles, la Web y computadoras de escritorio a partir de una base de código única. En este codelab, compilarás una app de escritorio de Flutter que accederá a las API de GitHub para recuperar tus repositorios, problemas asignados y solicitudes de extracción. Para completar esta tarea, crearás y usarás complementos a fin de interactuar con aplicaciones de escritorio y API nativas, y usarás la generación de código con el objetivo de compilar bibliotecas cliente de tipo seguro para las API de GitHub.

Qué aprenderás

  • Cómo crear una aplicación de escritorio de Flutter
  • Cómo autenticarte con OAuth2 en computadoras de escritorio
  • Cómo usar GraphQL desde Flutter con generación de código
  • Cómo crear un complemento de Flutter para que se integre con las API nativas

Qué compilarás

En este codelab, compilarás una aplicación de escritorio que incluya una integración de la API de GraphQL de GitHub con el SDK de Flutter. Tu app hará lo siguiente:

  • Autenticarse en GitHub
  • Recuperar datos desde la API v4 de GitHub
  • Crear un complemento de Flutter para Windows, macOS o Linux
  • Desarrollar una recarga en caliente de la IU de Flutter en una aplicación nativa de escritorio

A continuación, se muestra una captura de pantalla de la aplicación de escritorio que compilarás. Esta app se está ejecutando en Windows.

775e773e58e53e85.png

Este codelab se enfoca en agregar capacidades de GraphQL a una app de escritorio de Flutter. Los conceptos y los bloques de código no relevantes apenas se tratan, pero se proporcionan para que simplemente los copies y los pegues.

¿Qué te gustaría aprender en este codelab?

Desconozco el tema y me gustaría obtener una buena descripción general. Tengo algunos conocimientos sobre este tema, pero me gustaría repasarlo. Busco código de ejemplo para usar en mi proyecto. Busco una explicación sobre un tema específico.

Debes desarrollar contenido en la plataforma donde tengas pensado realizar la implementación. Por lo tanto, si deseas desarrollar una app de escritorio para Windows, debes desarrollarla en Windows para obtener acceso a la cadena de compilación correcta.

Desarrollar contenido para todos los sistemas operativos requiere dos tipos de software si deseas completar este lab: el SDK de Flutter y un editor.

Además, se exigen requisitos específicos de sistema operativo que se describen con detalle en flutter.dev/desktop.

Cómo comenzar a desarrollar aplicaciones de escritorio con Flutter

Debes configurar la compatibilidad con computadoras de escritorio mediante un cambio único de configuración.

$ 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

A fin de confirmar que se haya habilitado Flutter para computadoras de escritorio, ejecuta el siguiente comando:

$ 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

Si no ves la línea correcta para computadora de escritorio que se muestra en el resultado anterior, ten en cuenta lo siguiente:

  • ¿Estás desarrollando contenido en la plataforma de destino?
  • ¿El elemento flutter config en ejecución muestra macOS como habilitado con enable-[os]-desktop: true?
  • ¿El elemento flutter channel en ejecución muestra dev o master como el canal actual? Es obligatorio, ya que el código no se ejecutará en los canales stable o beta.

Una manera fácil de comenzar a escribir código de Flutter para apps de escritorio es usar la herramienta de línea de comandos de Flutter a fin de crear un proyecto en ese framework. De manera alternativa, tu IDE puede brindar un flujo de trabajo para crear un proyecto de Flutter a través de su IU.

$ 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.

Para habilitar la seguridad nula, migra el proyecto de la siguiente manera en macOS y Linux:

$ cd github_graphql_client
$ dart migrate --apply-changes

De manera similar, en Windows:

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

A fin de simplificar este codelab, borra los archivos de compatibilidad para la Web, iOS y Android, , ya que no se necesitan para una aplicación de escritorio de Flutter. Borrar los archivos evita ejecutar por accidente la variante incorrecta durante este codelab.

En macOS y Linux:

$ rm -r android ios web

En 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

Para asegurarte de que todo funcione correctamente, ejecuta la aplicación estándar de Flutter como una aplicación de escritorio, de la manera que se muestra más adelante. De forma alternativa, abre este proyecto en tu IDE y usa sus herramientas para ejecutar la aplicación. Gracias al paso anterior, ejecutar la aplicación como una de escritorio debería ser la única opción disponible.

$ 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 💪

Ahora, deberías ver en la pantalla la siguiente ventana de la aplicación. Haz clic en el botón de acción flotante para asegurarte de que el incrementador funcione como se espera. También puedes probar la recarga en caliente cambiando el color del tema o modificando el comportamiento del método _incrementCounter en lib/main.dart.

A continuación, se muestra la aplicación ejecutándose en Windows.

ea232028115f24c.png

En la siguiente sección, te autenticarás en GitHub con OAuth2.

Cómo autenticarse en computadoras de escritorio

Si usas Flutter en Android, iOS o la Web, cuentas con una variedad de opciones respecto de los paquetes de autenticación. Sin embargo, desarrollar contenido para computadoras de escritorio cambia la ecuación. En la actualidad, debes compilar una integración de autenticación desde cero, pero esto cambiará a medida que los autores de paquetes implementen Flutter para ofrecer compatibilidad con computadoras de escritorio.

Cómo registrar una aplicación de OAuth de GitHub

Para compilar una aplicación de escritorio que use las API de GitHub, primero debes autenticarte. Existen varias opciones disponibles, pero para brindar la mejor experiencia del usuario, te recomendamos que dirijas al usuario mediante el flujo de acceso de OAuth2 de GitHub en su navegador. De esa manera, se permite el control de la autenticación de dos factores y la integración sin esfuerzo de administradores de contraseñas.

A fin de registrar una aplicación para el flujo de OAuth2 de GitHub, ve a github.com y sigue las instrucciones solo del primer paso de Cómo compilar apps de OAuth de GitHub. Los siguientes pasos son importantes cuando tienes una aplicación para lanzar, pero no mientras realizas un codelab.

Para completar las instrucciones de Cómo crear una app de OAuth, en el paso 8, se te pedirá que proporciones la URL de devolución de llamada de autorización. Para una app de escritorio, introduce http://localhost/ como la URL de devolución de llamada. El flujo de OAuth2 de GitHub se configuró de tal manera que definir una URL de devolución de llamada de localhost habilita cualquier puerto y permite poner en funcionamiento un servidor web en un puerto alto, local y efímero. De esta manera, se evita solicitarle al usuario que copie el token de código de OAuth en la aplicación como parte del proceso de OAuth.

A continuación, se muestra una captura de pantalla de ejemplo de la manera en que se completa el formulario para crear una aplicación de OAuth de GitHub:

be454222e07f01d9.png

Después de registrar una app de OAuth en la interfaz de administrador de GitHub, recibirás un ID de cliente y un secreto de cliente. Si necesitas esos valores más adelante, puedes recuperarlos desde la configuración de desarrollador de GitHub. Necesitarás estas credenciales en tu aplicación para crear una URL válida de autorización de OAuth2. Podrás usar el paquete de Dart oauth2 a fin de controlar el flujo de OAuth2 y el complemento de Flutter url_launcher para habilitar el lanzamiento del navegador web del usuario.

Cómo agregar oauth2 y url_launcher a pubspec.yaml

Para incluir dependencias de paquete en tu aplicación, agrega entradas al archivo pubspec.yaml, de la siguiente manera:

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

Cómo incluir credenciales de cliente

Agrega credenciales de cliente a un archivo nuevo, lib/github_oauth_credentials.dart, de la siguiente manera:

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'];

Copia y pega tus credenciales de cliente del paso anterior en este archivo.

Cómo compilar el flujo de OAuth2 para computadoras de escritorio

Compila un widget que contenga el flujo de OAuth2 para computadoras de escritorio. Se aplica una lógica razonablemente complicada, ya que debes ejecutar un servidor web temporal, redireccionar al usuario a un extremo en GitHub en su navegador web, esperar a que el usuario complete el flujo de autorización en su navegador y controlar una llamada de redireccionamiento de GitHub que incluye el código (que luego debe convertirse en un token de OAuth2 con una llamada diferente a los servidores de la API de GitHub).

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;
}

Vale la pena dedicar un poco de tiempo a avanzar con este código, ya que demuestra algunas de las capacidades de usar Flutter y Dart en computadoras de escritorio. Sí, el código es complicado, pero muchas funcionalidades se encapsulan en un widget relativamente fácil de usar.

Este widget expone un servidor web temporal y realiza solicitudes HTTP seguras. En macOS, se deben solicitar ambas capacidades mediante archivos de autorización.

Cómo cambiar las autorizaciones de cliente y servidor (solo macOS)

Realizar solicitudes web y ejecutar un servidor web como una app de escritorio para macOS requiere cambios en las autorizaciones para la aplicación. Si necesitas más información al respecto, consulta Autorizaciones y zona de pruebas de apps.

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>

También, debes modificar las autorizaciones de lanzamiento para las compilaciones de producción.

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>

Cómo combinar todo

Configuraste una aplicación nueva de OAuth, se configuró el proyecto con los paquetes y los complementos necesarios, creaste un widget para encapsular el flujo de autenticación de OAuth y habilitaste la app de modo que actúe como cliente de red y servidor en macOS mediante autorizaciones. Con todos estos bloques necesarios de compilación implementados, puedes combinarlos en el archivo 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,
    );
  }
}

Cuando ejecutas esta aplicación de Flutter, inicialmente, se te muestra un botón para iniciar el flujo de acceso de OAuth de GitHub. Después de hacer clic en el botón, completa el flujo de acceso en el navegador web para verificar que accediste con la app.

Ahora que completaste la autenticación de OAuth, puedes comenzar a usar la API de GraphQL de GitHub.

Introducción a GraphQL

Si seleccionas desde graphql.org, GraphQL brinda una descripción completa y comprensible de los datos en la API, y les proporciona a los clientes la capacidad de preguntar exactamente qué necesitan y nada más. Esto es un aspecto positivo muy cierto para los desarrolladores: la posibilidad de hacer preguntas enfocadas en la API que propaguen partes específicas de una IU.

La API v4 de GitHub se define en términos de GraphQL, lo que brinda una excelente zona de pruebas para explorar GraphQL con datos reales. GitHub ofrece Explorer de GraphQL de GitHub, con la tecnología de GraphiQL, que te brinda una manera de crear consultas de GraphQL en la API de GraphQL de GitHub. A fin de obtener más información para usar Explorer de GraphQL de GitHub, consulta Cómo usar Explorer en GitHub.

En este codelab, usarás el paquete gql con el objetivo de generar código de agrupación de tipo seguro para las consultas que se compilen en Explorer.

Cómo agregar más dependencias

Si deseas usar la generación de código para compilar la biblioteca cliente de GraphQL, necesitas build_runner y muchos paquetes gql. Para comenzar, agrega estas dependencias a tu archivo 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

Cómo recuperar el esquema de GraphQL de GitHub

GitHub publica un esquema que describe su API. Almacena en caché este esquema a fin de que tus herramientas de generación de código puedan usarlo con el objetivo de crear bibliotecas cliente de tipo seguro para tus consultas.

En macOS y 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

En 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

Los dos comandos anteriores crean un elemento schema.docs.graphql que usa la canalización de generación de código para verificar el tipo de tus consultas y generar bibliotecas cliente de tipo seguro. También necesitas una consulta. Comienza con la consulta predeterminada con la que empieza Explorer de GraphQL de GitHub, con una alteración leve. Debes asignarle un nombre a la consulta para que el generador de código cree tu biblioteca cliente de tipo seguro.

lib/src/github_gql/github_queries.graphql

query ViewerDetail {
  viewer {
    login
  }
}

Cómo configurar build_runner

Para configurar build_runner, agrega reglas en build.yaml. En este caso, configuras la manera en que el paquete gql genera código a partir del esquema de GraphQL de GitHub y las consultas que creas en 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

El paquete build_runner es bastante potente, más que de lo que puede explicarse en este codelab. Si deseas consultar un análisis detallado, mira el video Code generation with the Dart build system de Kevin Moore en YouTube.

Ahora que ya implementaste todas las partes, puedes ejecutar build_runner para generar tu biblioteca cliente de GraphQL.

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

Si observas lib/third_party/github_graphql_schema/ y lib/src/github_gql/,, notarás que ahora tienes mucho código recién generado.

Cómo combinar todo, otra vez

Es momento de integrar todo lo mejor de GraphQL en tu archivo 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(',')}';
  }
}

Después de ejecutar esta aplicación de Flutter, se mostrará un botón que inicia el flujo de acceso de OAuth de GitHub. Después de hacer clic en el botón, completa el flujo de acceso en tu navegador web. Ya accediste a la app.

En el próximo paso, eliminarás una molestia en la base de código actual. Volverás a colocar la aplicación en primer plano después de autenticar la aplicación en el navegador web.

Cómo eliminar molestias

Actualmente, el código tiene un aspecto molesto. Después del flujo de autenticación, cuando GitHub autentica tu aplicación, quedas mirando una página del navegador web. Sería ideal que regreses automáticamente a la aplicación. Con el fin de corregir este problema, se necesita crear un complemento de Flutter para las plataformas de computadoras de escritorio.

Cómo crear un complemento de Flutter para Windows, macOS y Linux

Para que la aplicación regrese automáticamente al frente de la pila de ventanas de la aplicación una vez que se completa el flujo de OAuth, se requiere código nativo. En macOS, la API que necesitas es el método de instancia NSApplicationactivate(ignoringOtherApps:). Para Linux, usaremos gtk_window_present y para Windows, recurriremos a Stack Overflow. Para poder llamar a estas API, debes crear un complemento de Flutter.

Puedes usar flutter para crear un proyecto nuevo de complemento.

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

Ahora, migra tu complemento a seguridad nula y quita la app de ejemplo. En macOS o Linux:

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

De manera similar, en 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

Confirma que el elemento pubspec.yaml generado se vea de la siguiente manera.

../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

Este complemento se configuró para macOS, Linux y Windows. Ahora, puedes agregar el código Swift que permite que la ventana aparezca delante de todo. Edita macos/Classes/WindowToFrontPlugin.swift de la siguiente manera:

../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)
    }
  }
}

Para hacer lo mismo en el complemento de Linux, reemplaza el contenido de linux/window_to_front_plugin.cc con lo siguiente:

../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);
}

Para hacer lo mismo en el complemento de Windows, reemplaza el contenido de windows/window_to_front_plugin.cc con lo siguiente:

..\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));
}

Agrega el código para permitir que la funcionalidad nativa que creamos anteriormente esté disponible en el mundo de 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.
}

Este complemento de Flutter está completo, y puedes volver a editar el proyecto github_graphql_client.

$ cd ../github_graphql_client

Cómo agregar dependencias

El complemento de Flutter que recién creaste es excelente, pero así solo no sirve de nada. Debes agregarlo como una dependencia en tu aplicación de Flutter para poder usarlo.

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

Observa la ruta que se especifica para la dependencia window_to_front: como se trata de un paquete local en lugar de uno publicado en pub.dev, debes especificar una ruta de acceso en lugar de un número de versión.

Cómo combinar todo una vez más

Es momento de integrar window_to_front en tu archivo lib/main.dart. Solo debemos agregar una importación y una llamada al código nativo en el momento adecuado.

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(',')}';
  }
}

Una vez que ejecutes esta aplicación de Flutter, aparecerá una app de apariencia similar, pero si haces clic en el botón, notarás una diferencia de comportamiento. Si colocas la app delante del navegador web con el que te estás autenticando, cuando hagas clic en el botón Acceder, tu aplicación se moverá detrás del navegador web, pero una vez que hayas completado el flujo de autenticación en el navegador, la aplicación aparecerá nuevamente delante de todo. Mucho mejor.

En la siguiente sección, compilarás sobre lo que ya tengas, a fin de crear un cliente de GitHub para computadoras de escritorio que te brinde información sobre lo que tienes en GitHub. Inspeccionarás la lista de repositorios en la cuenta, las solicitudes de extracción creadas y los problemas asignados.

Avanzaste bastante con la compilación de esta aplicación, pero, aun así, lo único que hace es indicar tus datos de acceso. Es probable que esperes un poco más de un cliente de GitHub para computadoras de escritorio. A continuación, agregarás la capacidad de enumerar repositorios, solicitudes de extracción y problemas asignados.

Cómo consultar repositorios, solicitudes de extracción y problemas con GraphQL

Para poder mostrar información desde GitHub, debes recuperarla. Por lo tanto, agrega las siguientes consultas de GraphQL a la combinación:

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
        }
      }
    }
  }
}

Para volver a generar tu biblioteca cliente de GraphQL, ejecuta el siguiente comando:

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

Cómo agregar una última dependencia

Para representar los datos que se muestran en las consultas anteriores, usarás un paquete adicional, fluttericon, para mostrar, con mayor facilidad, los Octconos de GitHub.

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

Cómo usar widgets para representar los resultados en la pantalla

Usarás las consultas de GraphQL que creaste anteriormente para propagar un widget NavigationRail con vistas de tus repositorios, problemas asignados y solicitudes de extracción. En la documentación del sistema de diseño de Material.io, se explica cómo los rieles de navegación brindan movimiento ergonómico entre destinos principales en aplicaciones.

Crea un archivo nuevo y complétalo con el siguiente contenido.

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'),
          ),
        ],
      ),
    );
  }
}

Agregaste mucho código nuevo en este paso. El aspecto positivo es que se trata de un código de Flutter bastante común, con widgets que se usan para separar la responsabilidad de diferentes inquietudes. Revisa este código durante unos minutos antes de continuar con el siguiente paso para lograr que todo funcione correctamente.

Cómo combinar todo por última vez

Es momento de integrar GitHubSummary en tu archivo lib/main.dart. En este caso, los cambios son bastante importantes, pero consisten, sobre todo, en eliminaciones. Reemplaza el contenido de tu archivo lib/main.dart con lo siguiente:

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,
    );
  }
}

Ejecuta la aplicación. Debería aparecer algo similar a lo siguiente:

775e773e58e53e85.png

¡Felicitaciones!

Completaste el codelab y creaste una aplicación de escritorio de Flutter que accede a la API de GraphQL de GitHub. Utilizaste una API autenticada con OAuth, generaste una biblioteca cliente de tipo seguro y usaste API nativas mediante un complemento que también creaste.

Para obtener más información sobre Flutter en computadoras de escritorio, visita flutter.dev/desktop. Para obtener más información sobre GraphQL, visita graphql.org/learn. Por último, para conocer una opinión totalmente diferente sobre Flutter y GitHub, consulta GitHub-Activity-Feed de GroovinChip.