Flutter 데스크톱 애플리케이션 작성

Flutter는 하나의 코드베이스를 사용해 모바일, 웹, 데스크톱을 대상으로 아름다운 네이티브 컴파일 애플리케이션을 개발하기 위한 Google의 UI 도구 모음입니다. 이 Codelab에서는 GitHub API에 액세스하여 저장소, 할당된 문제, pull 요청을 가져오는 Flutter 데스크톱 앱을 개발합니다. 이 작업을 하는 과정에서 네이티브 API 및 데스크톱 애플리케이션과 상호작용하기 위해 플러그인을 만들고 사용하며, 유형 안전성을 갖춘 GitHub API용 클라이언트 라이브러리를 빌드하기 위해 코드 생성을 사용하게 됩니다.

학습할 내용

  • Flutter 데스크톱 애플리케이션 만드는 방법
  • 데스크톱에서 OAuth2를 사용하여 인증하는 방법
  • 코드 생성 시 Flutter의 GraphQL을 사용하는 방법
  • 네이티브 API와 통합하기 위해 Flutter 플러그인을 만드는 방법

빌드할 프로그램

이 Codelab에서는 Flutter SDK를 사용하는 GitHub GraphQL API 통합을 갖춘 데스크톱 애플리케이션을 빌드해 보겠습니다. 앱에서 다음 작업을 하게 됩니다.

  • GitHub에 인증
  • GitHub v4 API에서 데이터 가져오기
  • Windows, macOS 또는 Linux용 Flutter 플러그인 만들기
  • 네이티브 데스크톱 애플리케이션에 핫 리로드하는 Flutter UI 개발

다음은 개발할 데스크톱 애플리케이션을 Windows에서 실행한 화면의 스크린샷입니다.

775e773e58e53e85.png

이 Codelab은 Flutter 데스크톱 앱에 GraphQL 기능을 추가하는 데 중점을 둡니다. 이와 관련 없는 개념과 코드 블록은 다루지 않으므로 간단히 복사하여 붙여넣을 수 있도록 제공됩니다.

이 Codelab에서 어떤 내용을 배우고 싶으신가요?

주제를 처음 접하며 개요를 파악하고 싶습니다. 이 주제에 관해 약간 알고 있지만 한 번 더 확인하고 싶습니다. 프로젝트에 사용할 예시 코드를 찾고 있습니다. 구체적인 항목에 대한 설명을 찾고 있습니다.

배포에 사용할 플랫폼에서 개발해야 합니다. 따라서 Windows 데스크톱 앱을 개발하려면 적절한 빌드 체인에 액세스할 수 있도록 Windows에서 개발해야 합니다.

모든 운영체제에서 이 실습을 완료하려면 Flutter SDK편집기라는 두 가지 소프트웨어가 필요합니다.

또한 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를 실행하면 enable-[os]-desktop: true 결과를 통해 macOS가 사용 설정된 것으로 표시되나요?
  • flutter channel을 실행하면 dev 또는 master가 현재 채널로 표시되나요? stable 또는 beta 채널에서 코드가 실행되지 않으므로 이 작업이 필요합니다.

데스크톱 앱용 Flutter 작성을 쉽게 시작하는 방법은 Flutter 명령줄 도구를 사용하여 Flutter 프로젝트를 만드는 것입니다. 또는 IDE가 해당 UI를 통해 Flutter 프로젝트를 만들기 위한 워크플로를 제공할 수도 있습니다.

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

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

  $ cd github_graphql_client
  $ flutter run

To enable null safety, type:

  $ cd github_graphql_client
  $ dart migrate --apply-changes

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

macOS 및 Linux에서 다음과 같이 프로젝트를 이전하여 null 안전성 기능을 활성화합니다.

$ cd github_graphql_client
$ dart migrate --apply-changes

마찬가지로 Windows에서는 다음과 같이 합니다.

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

이 Codelab을 단순화하기 위해 Android, iOS 및 웹 지원 파일을 삭제합니다. 이러한 파일은 데스크톱 애플리케이션용 Flutter에는 필요하지 않습니다. 파일을 삭제하면 이 Codelab에서 실수로 잘못된 변형을 실행하는 일을 줄이는 데 도움이 됩니다.

macOS 및 Linux의 경우:

$ rm -r android ios web

Windows의 경우:

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

모든 것이 제대로 작동하는지 확인하려면 아래와 같이 상용구 Flutter 애플리케이션을 데스크톱 애플리케이션으로 실행합니다. 또는 IDE에서 이 프로젝트를 열고 IDE 도구를 사용하여 애플리케이션을 실행합니다. 이전 단계로 인해 데스크톱 애플리케이션으로 실행하는 방법만 선택할 수 있습니다.

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

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

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

💪 Running with sound null safety 💪

화면에 다음 애플리케이션 창이 표시됩니다. 계속해서 플로팅 작업 버튼을 클릭하여 증분 기능이 정상적으로 작동하는지 확인합니다. lib/main.dart에서 _incrementCounter 메서드의 동작을 변경하거나 테마 색상을 변경하여 핫 리로드를 시도할 수도 있습니다.

다음은 Windows에서 실행되는 애플리케이션입니다.

ea232028115f24c.png

다음 섹션에서는 OAuth2를 사용하여 GitHub에서 인증합니다.

데스크톱에서 인증

Android, iOS 또는 웹에서 Flutter를 사용하는 경우 인증 패키지와 관련된 다양한 옵션이 제공됩니다. 그러나 데스크톱용으로 개발하는 경우에는 그렇지 않습니다. 현재는 처음부터 인증 통합을 빌드해야 하지만, 이는 패키지 작성자가 데스크톱용 Flutter 지원을 구현하면서 바뀌게 됩니다.

GitHub OAuth 애플리케이션 등록

GitHub의 API를 사용하는 데스크톱 애플리케이션을 빌드하려면 먼저 인증해야 합니다. 여러 가지 옵션이 있지만 가장 좋은 방법은 브라우저에서 GitHub의 OAuth2 로그인 흐름을 통해 사용자를 안내하는 것입니다. 이를 통해 2단계 인증 및 비밀번호 관리자의 간편 통합을 처리할 수 있습니다.

GitHub의 OAuth2 흐름에 애플리케이션을 등록하려면 github.com으로 이동하여 GitHub의 OAuth 앱 빌드 첫 번째 단계에 있는 안내를 따르세요. 다음 단계는 출시할 애플리케이션이 있으나 Codelab을 하고 있지 않은 경우 중요합니다.

OAuth 앱 만들기를 완료하면 8단계에서 승인 콜백 URL을 제공하라고 합니다. 데스크톱 앱의 경우 http://localhost/를 콜백 URL로 입력합니다. localhost 콜백 URL을 정의하면 어떤 포트든 허용하고 이를 통해 임시 로컬 하이 포트에서 웹 서버를 지원할 수 있도록 GitHub의 OAuth2 흐름이 설정되어 있습니다. 이렇게 하면 OAuth 프로세스의 일부로 애플리케이션에 OAuth 코드 토큰을 복사하라고 사용자에게 요청할 필요가 없습니다.

다음은 GitHub OAuth 애플리케이션을 만들기 위해 양식을 작성하는 방법을 보여 주는 예시 스크린샷입니다.

be454222e07f01d9.png

GitHub 관리 인터페이스에서 OAuth 앱을 등록하면 클라이언트 ID클라이언트 비밀번호가 생성됩니다. 나중에 이러한 값이 필요하면 GitHub 개발자 설정에서 값을 검색해 보세요. 애플리케이션에서 이 사용자 인증 정보가 있어야 유효한 OAuth2 승인 URL을 구성할 수 있습니다. 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>

프로덕션 빌드의 출시 사용 권한도 수정해야 합니다.

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 내 데이터에 관한 이해하기 쉬운 완벽한 설명을 제공하며 정확히 무엇이 필요한지, 필요하지 않은지 요청할 수 있는 권한을 고객에게 부여합니다. 이는 개발자에게 매우 중요한 사항입니다. UI의 특정 부분을 채우는 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 시작 시 사용되는 기본 쿼리로 시작하되 약간 변경합니다. 코드 생성기가 유형 안전성을 갖춘 클라이언트 라이브러리를 생성하려면 쿼리에 이름을 지정해야 합니다.

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에서 케빈 무어의 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_frontlib/main.dart 파일에 통합할 차례입니다. 적절한 시점에 가져오기를 추가하고 네이티브 코드를 호출하기만 하면 됩니다.

lib/main.dart

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

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

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

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

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

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

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

이 Flutter 애플리케이션을 실행하면 동일한 모습의 앱이 표시되지만 버튼을 클릭하면 동작 측면에서 차이를 보입니다. 인증에 사용하는 웹브라우저 위에 앱을 배치하면 로그인 버튼을 클릭할 때 웹브라우저 뒤에 애플리케이션이 푸시됩니다. 반면 브라우저에서 인증 흐름을 완료하면 애플리케이션이 다시 앞에 표시되어 훨씬 더 세련된 느낌을 줍니다.

다음 섹션에서는 보유하고 있는 코드베이스를 토대로 빌드하여 GitHub에서 이용할 수 있는 기능을 알려 주는 데스크톱 GitHub 클라이언트를 만듭니다. 계정 내 저장소 목록, 작성한 pull 요청, 할당된 문제를 살펴볼 수 있습니다.

이 애플리케이션 빌드 작업을 상당히 진행했으나 애플리케이션에서는 로그인할 것을 요청합니다. 데스크톱 GitHub 클라이언트를 조금 더 활용할 수 있습니다. 그런 다음 저장소, pull 요청, 할당된 문제의 목록을 작성하는 기능을 추가합니다.

GraphQL을 사용한 저장소, pull 요청, 문제 쿼리

GitHub의 정보를 표시하려면 정보를 가져와야 합니다. 따라서 다음과 같은 GraphQL 쿼리를 추가합니다.

lib/src/github_gql/github_queries.graphql

query ViewerDetail {
  viewer {
    login
  }
}

// Add everything below here.

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

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

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

GraphQL 클라이언트 라이브러리를 다시 생성하려면 다음 명령어를 실행합니다.

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

마지막 종속 항목 추가

위 쿼리에서 반환된 데이터를 렌더링할 때 추가 패키지인 fluttericon을 사용하여 GitHub의 Octicons를 쉽게 표시합니다.

pubspec.yaml

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

environment:
  sdk: '>=2.12.0 <3.0.0'

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

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

flutter:
  uses-material-design: true

화면에 결과를 렌더링하는 위젯

위에서 만든 GraphQL 쿼리를 사용하여 NavigationRail 위젯을 저장소, 할당된 문제, pull 요청 보기로 채웁니다. Material.io 디자인 시스템 문서에는 탐색 레일이 애플리케이션에서 기본 목적지 간의 인체공학적 이동을 제공하는 방법이 설명되어 있습니다.

새 파일을 만들고 다음 콘텐츠로 채웁니다.

lib/src/github_summary.dart

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

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

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

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

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

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

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

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

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

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

  late Future<List<GAssignedIssuesData_search_edges_node__asIssue>>
      _assignedIssues;

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

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

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

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

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

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

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

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

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

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

여기에 새로운 코드를 많이 추가했습니다. 장점은 이 모든 것이 다양한 우려사항을 해결하는 데 사용되는 위젯을 갖춘 지극히 일반적인 Flutter 코드라는 점입니다. 다음 단계로 넘어가 실행하기 전에 잠시 시간을 내어 다음 코드를 살펴보세요.

마지막으로 코드 통합

이제 GitHubSummarylib/main.dart 파일에 통합할 차례입니다. 이번에는 상당히 중요한 변경사항에 해당하지만 대부분 삭제로 구성되어 있습니다. lib/main.dart 파일의 콘텐츠를 다음으로 바꿉니다.

lib/main.dart

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

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

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

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

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

애플리케이션을 실행하면 다음과 같은 내용이 표시되어야 합니다.

775e773e58e53e85.png

축하합니다.

Codelab을 완료하고 GitHub의 GraphQL API에 액세스하는 데스크톱 Flutter 애플리케이션을 빌드했습니다. OAuth를 통해 인증된 API를 사용했고, 유형 안전성을 갖춘 클라이언트 라이브러리를 생성했으며, 직접 생성한 플러그인을 통해 네이티브 API를 사용했습니다.

데스크톱의 Flutter에 관해 자세히 알아보려면 flutter.dev/desktop을 방문하세요. GraphQL에 대한 자세한 내용은 graphql.org/learn을 참고하세요. 마지막으로 Flutter와 GitHub에 관한 완전히 다른 관점을 보려면 GroovinChip의 GitHub-Activity-Feed를 참고하세요.