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

1. 소개

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

학습할 내용

  • Flutter 데스크톱 애플리케이션 만드는 방법
  • 데스크톱에서 OAuth2를 사용하여 인증하는 방법
  • Dart GitHub 패키지 사용 방법
  • 네이티브 API와 통합하기 위해 Flutter 플러그인을 만드는 방법

빌드할 항목

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

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

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

a456fca6e2997992.png

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

이 Codelab에서 배우고 싶은 내용은 무엇인가요?

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

2. Flutter 환경 설정

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

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

또한 flutter.dev/desktop에 운영체제별 요구사항이 자세히 설명되어 있습니다.

3. 시작하기

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

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

  $ cd github_client
  $ flutter run

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

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

macOS 및 Linux의 경우:

$ rm -r android ios web

Windows의 경우:

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

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

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

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

💪 Running with sound null safety 💪

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

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

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

bee40fe7a8e69791.png

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

4. 인증 추가

데스크톱에서 인증

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

GitHub OAuth 애플리케이션 등록

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

GitHub의 OAuth2 흐름에 애플리케이션을 등록하려면 github.com으로 이동하여 GitHub의 OAuth 앱 빌드 첫 번째 단계에 있는 안내를 따르세요. 다음 단계는 출시할 애플리케이션이 있으나 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에 추가

다음과 같이 flutter pub add를 실행하여 애플리케이션의 패키지 종속 항목을 추가합니다.

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

이 첫 번째 명령어는 크로스 플랫폼에서 일관된 방식으로 HTTP를 호출하기 위해 http 패키지를 추가합니다. 다음은 아래와 같이 oauth2 패키지를 추가합니다.

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

마지막으로 url_launcher 패키지를 추가합니다.

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

클라이언트 사용자 인증 정보 포함

다음과 같이 클라이언트 사용자 인증 정보를 새 파일 lib/github_oauth_credentials.dart에 추가합니다.

lib/github_oauth_credentials.dart

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

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

이전 단계의 클라이언트 사용자 인증 정보를 복사하여 이 파일에 붙여넣습니다.

데스크톱 OAuth2 흐름 빌드

데스크톱 OAuth2 흐름을 포함하는 위젯을 빌드합니다. 이는 다소 복잡한 로직으로 임시 웹 서버를 실행하고, 사용자를 웹브라우저 내 GitHub 엔드포인트로 리디렉션하며, 사용자가 브라우저에서 인증 흐름을 완료하길 기다린 다음 코드를 포함하는 GitHub의 리디렉션 호출을 처리해야 합니다. 그러고 나서 GitHub API 서버로의 별도 호출이 포함된 OAuth2 토큰으로 변환해야 합니다.

lib/src/github_login.dart

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

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

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

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

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

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

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

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

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

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

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

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

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

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

데스크톱에서 Flutter와 Dart를 사용하는 몇 가지 기능을 보여 주기 때문에 이 코드를 자세히 살펴보는 것이 좋습니다. 물론 복잡한 코드지만 비교적 간편하게 사용할 수 있는 위젯에 많은 기능이 캡슐화되어 있습니다.

이 위젯은 임시 웹 서버를 노출하고 안전한 HTTP 요청을 전송합니다. macOS에서는 두 기능 모두 사용 권한 파일을 통해 요청해야 합니다.

클라이언트 및 서버 사용 권한 변경(macOS만 해당)

웹 요청을 수행하고 macOS 데스크톱 앱으로 웹 서버를 실행하려면 애플리케이션의 사용 권한을 변경해야 합니다. 자세한 내용은 사용 권한 및 앱 샌드박스를 참고하세요.

macos/Runner/DebugProfile.entitlements

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

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

macos/Runner/Release.entitlements

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

코드 통합

새로운 OAuth 앱을 구성하고, 필요한 패키지 및 플러그인이 포함되도록 프로젝트가 구성되었고, OAuth 인증 흐름을 캡슐화하는 위젯을 작성했으며, 사용 권한을 통해 앱이 macOS에서 네트워크 클라이언트와 서버 모두로 작동하도록 사용 설정했습니다. 이러한 필수 구성요소가 모두 갖춰져 있으므로 lib/main.dart 파일에 모두 통합할 수 있습니다.

lib/main.dart

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

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

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

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

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

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

이 Flutter 애플리케이션을 실행하면 처음에 GitHub OAuth 로그인 흐름을 시작하는 버튼이 표시됩니다. 버튼을 클릭한 후에 웹브라우저에서 로그인 흐름을 완료하고 앱이 현재 로그인되어 있는지 확인합니다.

이제 OAuth 인증을 마쳤으므로 GitHub 패키지를 사용하여 시작할 수 있습니다.

5. GitHub 액세스

GitHub에 연결

OAuth 인증 흐름을 사용하여 GitHub의 데이터에 액세스하는 데 필요한 토큰을 얻었습니다. 이 작업을 원활하게 하기 위해 pub.dev에서 사용할 수 있는 github 패키지를 사용합니다.

더 많은 종속 항목 추가

다음 명령어를 실행합니다.

$ flutter pub add github

GitHub 패키지에서 OAuth 사용자 인증 정보 사용

이전 단계에서 만든 GithubLoginWidget은 GitHub API와 상호작용할 수 있는 HttpClient를 제공합니다. 이 단계에서는 HttpClient에 포함된 사용자 인증 정보를 사용하여 GitHub 패키지로 GitHub API에 액세스합니다(아래 참조).

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

다시 코드 통합

이제 GitHub 클라이언트를 lib/main.dart 파일에 통합할 차례입니다.

lib/main.dart

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

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

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

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

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

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

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        return FutureBuilder<CurrentUser>(
          future: viewerDetail(httpClient.credentials.accessToken),
          builder: (context, snapshot) {
            return Scaffold(
              appBar: AppBar(
                title: Text(title),
              ),
              body: Center(
                child: Text(
                  snapshot.hasData
                      ? 'Hello ${snapshot.data!.login}!'
                      : 'Retrieving viewer login details...',
                ),
              ),
            );
          },
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

Future<CurrentUser> viewerDetail(String accessToken) async {
  final gitHub = GitHub(auth: Authentication.withToken(accessToken));
  return gitHub.users.getCurrentUser();
}

이 Flutter 애플리케이션을 실행하면 GitHub OAuth 로그인 흐름을 시작하는 버튼이 표시됩니다. 버튼을 클릭한 다음 웹브라우저에서 로그인 흐름을 완료합니다. 이제 앱에 로그인되었습니다.

다음 단계에서는 현재 코드베이스의 문제를 해결할 수 있습니다. 웹브라우저에서 애플리케이션을 인증한 후 애플리케이션을 다시 포그라운드로 가져옵니다.

6. Windows, macOS 및 Linux용 Flutter 플러그인 만들기

문제 해결하기

현재 코드에는 불편한 부분이 있습니다. 인증 흐름 후 GitHub가 애플리케이션을 인증할 때 웹브라우저 페이지가 표시됩니다. 애플리케이션으로 자동 복귀하는 것이 최선입니다. 이를 해결하려면 데스크톱 플랫폼용 Flutter 플러그인을 생성해야 합니다.

Windows, macOS 및 Linux용 Flutter 플러그인 만들기

OAuth 흐름이 완료된 후 애플리케이션이 애플리케이션 창 스택의 맨 앞에 자동으로 표시되도록 하려면 몇 가지 네이티브 코드가 필요합니다. macOS의 경우 필요한 API는 NSApplicationactivate(ignoringOtherApps:) 인스턴스 메서드이며, Linux에는 gtk_window_present를 사용하고, Windows에서는 Stack Overflow를 이용합니다. 이러한 API를 호출하려면 Flutter 플러그인을 만들어야 합니다.

flutter를 사용하여 새 플러그인 프로젝트를 만들 수 있습니다.

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

생성된 pubspec.yaml이 다음과 같이 표시되는지 확인합니다.

../window_to_front/pubspec.yaml

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

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

dependencies:
  flutter:
    sdk: flutter

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^1.0.0

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

이 플러그인은 macOS, Linux, Windows용으로 구성되어 있습니다. 이제 창이 앞에 나타나게 하는 Swift 코드를 추가할 수 있습니다. 다음과 같이 macos/Classes/WindowToFrontPlugin.swift를 수정합니다.

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

import Cocoa
import FlutterMacOS

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

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

Linux 플러그인에서 동일한 작업을 하려면 linux/window_to_front_plugin.cc 콘텐츠를 다음으로 바꿉니다.

../window_to_front/linux/window_to_front_plugin.cc

#include "include/window_to_front/window_to_front_plugin.h"

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

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

struct _WindowToFrontPlugin {
  GObject parent_instance;

  FlPluginRegistrar* registrar;
};

G_DEFINE_TYPE(WindowToFrontPlugin, window_to_front_plugin, g_object_get_type())

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

  const gchar* method = fl_method_call_get_name(method_call);

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

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

  fl_method_call_respond(method_call, response, nullptr);
}

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

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

static void window_to_front_plugin_init(WindowToFrontPlugin* self) {}

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

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

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

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

  g_object_unref(plugin);
}

Windows 플러그인에서 동일한 작업을 하려면 windows/window_to_front_plugin.cc 콘텐츠를 다음과 같이 바꿉니다.

..\window_to_front\windows\window_to_front_plugin.cpp

#include "include/window_to_front/window_to_front_plugin.h"

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

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

#include <map>
#include <memory>

namespace {

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

  WindowToFrontPlugin(flutter::PluginRegistrarWindows *registrar);

  virtual ~WindowToFrontPlugin();

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

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

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

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

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

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

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

WindowToFrontPlugin::~WindowToFrontPlugin() {}

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

}  // namespace

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

코드를 추가하여 위에서 만든 네이티브 기능을 Flutter에서 사용할 수 있게 합니다.

../window_to_front/lib/window_to_front.dart

import 'dart:async';

import 'package:flutter/services.dart';

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

  // Delete the getPlatformVersion getter method.
}

이 Flutter 플러그인은 완료되면 다시 github_graphql_client 프로젝트 편집으로 돌아갈 수 있습니다.

$ cd ../github_client

종속 항목 추가

방금 만든 Flutter 플러그인은 훌륭하지만 그 자체만으로는 그리 유용하지 않습니다. 이를 제대로 사용하려면 Flutter 애플리케이션에 종속 항목으로 추가해야 합니다.

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

window_to_front 종속 항목에 지정된 경로를 확인합니다. pub.dev에 게시된 패키지가 아닌 로컬 패키지이므로 버전 번호 대신 경로를 지정합니다.

다시 또 한 번 코드 통합

이제 window_to_frontlib/main.dart 파일에 통합할 차례입니다. 적절한 시점에 가져오기를 추가하고 네이티브 코드를 호출하기만 하면 됩니다.

lib/main.dart

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

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

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

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

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

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

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        WindowToFront.activate();                        // and this.
        return FutureBuilder<CurrentUser>(
          future: viewerDetail(httpClient.credentials.accessToken),
          builder: (context, snapshot) {
            return Scaffold(
              appBar: AppBar(
                title: Text(title),
              ),
              body: Center(
                child: Text(
                  snapshot.hasData
                      ? 'Hello ${snapshot.data!.login}!'
                      : 'Retrieving viewer login details...',
                ),
              ),
            );
          },
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

Future<CurrentUser> viewerDetail(String accessToken) async {
  final gitHub = GitHub(auth: Authentication.withToken(accessToken));
  return gitHub.users.getCurrentUser();
}

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

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

7. 저장소, pull 요청, 할당된 문제 보기

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

마지막 종속 항목 추가

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

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

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

이전에 추가한 GitHub 패키지를 사용하여 NavigationRail 위젯을 저장소 뷰, 할당된 문제, Flutter 프로젝트의 pull 요청으로 채웁니다. Material.io 디자인 시스템 문서에는 탐색 레일이 애플리케이션에서 기본 목적지 간의 인체공학적 이동을 제공하는 방법이 설명되어 있습니다.

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

lib/src/github_summary.dart

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

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

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

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

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

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

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

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

  late Future<List<Repository>> _repositories;

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

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

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

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

  late Future<List<Issue>> _assignedIssues;

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

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

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

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

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

  late Future<List<PullRequest>> _pullRequests;

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

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

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

마지막으로 코드 통합

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

lib/main.dart

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

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

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

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

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

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

  @override
  Widget build(BuildContext context) {
    return GithubLoginWidget(
      builder: (context, httpClient) {
        WindowToFront.activate(); // and this.
        return Scaffold(
          appBar: AppBar(
            title: Text(title),
          ),
          body: GitHubSummary(
            gitHub: _getGitHub(httpClient.credentials.accessToken),
          ),
        );
      },
      githubClientId: githubClientId,
      githubClientSecret: githubClientSecret,
      githubScopes: githubScopes,
    );
  }
}

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

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

d5c9bebf448a2519.png

8. 다음 단계

수고하셨습니다.

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

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